// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ // ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ // ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ // ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ // ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ // ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ // ┃ Copyright (c) 2017, the Perspective Authors. ┃ // ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ // ┃ This file is part of the Perspective library, distributed under the terms ┃ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import type { WebGLContextManager } from "../../webgl/context-manager"; import type { HeatmapChart } from "./heatmap"; import { PlotLayout } from "../../layout/plot-layout"; import { drawFacetTitle } from "../../axis/facet-chrome"; import { renderInPlotFrame, clearAndSetupFrame, withScissor, } from "../../webgl/plot-frame"; import { getInstancing } from "../../webgl/instanced-attrs"; import { compileProgram } from "../../webgl/program-cache"; import { initCanvas } from "../../axis/canvas"; import { buildFacetGrid } from "../../layout/facet-grid"; import { measureCategoricalAxisHeight, renderCategoricalXTicks, type CategoricalDomain, } from "../../axis/categorical-axis"; import { measureCategoricalAxisWidth, renderCategoricalYTicks, type CategoricalYAxisOptions, } from "./heatmap-y-axis"; import { drawNumericCategoryX, drawNumericCategoryY, } from "../../axis/bar-axis"; import { computeNiceTicks } from "../../layout/ticks"; // The heatmap's Y-axis column names end with the (single, externally // enforced) aggregate name. That leaf column is a redundant constant and // doesn't belong on the axis — promote the deepest split prefix to the // leaf position instead. const HEATMAP_Y_AXIS_OPTS: CategoricalYAxisOptions = { skipLeafLevel: true, }; import { renderLegend, renderLegendAt } from "../../axis/legend"; import heatmapVert from "../../shaders/heatmap.vert.glsl"; import heatmapFrag from "../../shaders/heatmap.frag.glsl"; import { colorValueToT } from "../../theme/gradient"; import { bindGradientTexture, ensureGradientTexture, } from "../../webgl/gradient-texture"; import { renderHeatmapTooltip } from "./heatmap-interact"; export interface HeatmapLocations { u_projection: WebGLUniformLocation | null; u_cell_inset: WebGLUniformLocation | null; u_cell_size: WebGLUniformLocation | null; u_gradient_lut: WebGLUniformLocation | null; a_corner: number; a_cell: number; a_color_t: number; } /** * Full-frame render: WebGL cells → chrome overlay. */ export function renderHeatmapFrame( chart: HeatmapChart, glManager: WebGLContextManager, ): void { const gl = glManager.gl; const dpr = glManager.dpr; const cssWidth = gl.canvas.width / dpr; const cssHeight = gl.canvas.height / dpr; if (cssWidth <= 0 || cssHeight <= 0) { return; } if (chart._facets.length > 0) { renderFacetedHeatmap(chart, glManager, cssWidth, cssHeight); return; } if (chart._numX === 0 || chart._numY === 0) { return; } const theme = chart._resolveTheme(); const xDomain: CategoricalDomain = { levels: chart._xLevels, numRows: chart._numX, levelLabels: chart._groupBy.slice(), }; const yDomain: CategoricalDomain = { levels: chart._yLevels, numRows: chart._numY, levelLabels: [], }; const xNumeric = chart._xAxisMode.mode === "numeric"; const yNumeric = chart._yAxisMode.mode === "numeric"; // Measure both hierarchical axes *before* building the layout so the // plot rect accounts for their footprints. Numeric axes get fixed // gutters matching bar's branch (24px bottom, 55px left). const estLeft = yNumeric ? 55 : measureCategoricalAxisWidth(yDomain, HEATMAP_Y_AXIS_OPTS); const bottomExtra = xNumeric ? 24 : measureCategoricalAxisHeight( xDomain, Math.max(1, cssWidth - estLeft - 110), ); const layout = new PlotLayout(cssWidth, cssHeight, { hasXLabel: chart._groupBy.length > 0, hasYLabel: false, hasLegend: true, bottomExtra, leftExtra: estLeft, }); chart._lastLayout = layout; if (chart._zoomController) { chart._zoomController.updateLayout(layout); } // Domain depends on axis mode. Category mode: cell grid // `[-0.5, N-0.5]` so cells sit at integer coordinates. Numeric mode: // pre-padded `numericDomain` already includes a half-band on each // edge so cells stay flush with the axis. const xDomainMin = xNumeric ? chart._xNumericDomain!.min : -0.5; const xDomainMax = xNumeric ? chart._xNumericDomain!.max : chart._numX - 0.5; const yDomainMin = yNumeric ? chart._yNumericDomain!.min : -0.5; const yDomainMax = yNumeric ? chart._yNumericDomain!.max : chart._numY - 0.5; if (chart._zoomController) { chart._zoomController.setBaseDomain( xDomainMin, xDomainMax, yDomainMin, yDomainMax, ); } const vis = chart._zoomController ? chart._zoomController.getVisibleDomain() : { xMin: xDomainMin, xMax: xDomainMax, yMin: yDomainMin, yMax: yDomainMax, }; // Heatmap cell rects span the exact domain edge-to-edge, so any // cosmetic padding leaves a visible sliver between the outermost // cells and the axis chrome. Force `padRatio: 0` for flush edges. const projection = layout.buildProjectionMatrix( vis.xMin, vis.xMax, vis.yMin, vis.yMax, undefined, undefined, 0, chart._xOrigin, chart._yOrigin, ); // Cell gap is specified in CSS pixels but the shader needs data-space // insets. Convert using the plot's data-per-pixel scale; clamp to // half a band so the gap can't eat the entire cell. const plot = layout.plotRect; const pxPerDataX = plot.width / (vis.xMax - vis.xMin); const pxPerDataY = plot.height / (vis.yMax - vis.yMin); const halfGap = theme.heatmapGapPx * 0.5; const cellSizeX = xNumeric ? chart._xNumericDomain!.bandWidth : 1; const cellSizeY = yNumeric ? chart._yNumericDomain!.bandWidth : 1; const insetX = Math.min( cellSizeX * 0.5, pxPerDataX > 0 ? halfGap / pxPerDataX : 0, ); const insetY = Math.min( cellSizeY * 0.5, pxPerDataY > 0 ? halfGap / pxPerDataY : 0, ); // Gridline canvas isn't used by heatmap — clear it so stale content // from a previous plugin doesn't bleed through. if (chart._gridlineCanvas) { const _gctx = initCanvas(chart._gridlineCanvas, layout, glManager.dpr); } ensureProgram(chart, glManager); uploadInstanceBuffers(chart, glManager); chart._gradientCache = ensureGradientTexture( glManager, chart._gradientCache, theme.gradientStops, ); renderInPlotFrame(gl, layout, glManager.dpr, () => { gl.useProgram(chart._program!); const loc = chart._locations!; gl.uniformMatrix4fv(loc.u_projection, false, projection); gl.uniform2f(loc.u_cell_inset, insetX, insetY); gl.uniform2f(loc.u_cell_size, cellSizeX, cellSizeY); bindGradientTexture( glManager, chart._gradientCache!.texture, loc.u_gradient_lut, 0, ); drawCellsInstanced(chart, gl, glManager, 0, chart._uploadedCells); }); renderHeatmapChromeOverlay(chart); } function ensureProgram( chart: HeatmapChart, glManager: WebGLContextManager, ): void { if (chart._program) { return; } const gl = glManager.gl; const compiled = compileProgram< { program: WebGLProgram } & NonNullable >( glManager, "heatmap", heatmapVert, heatmapFrag, ["u_projection", "u_cell_inset", "u_cell_size", "u_gradient_lut"], ["a_corner", "a_cell", "a_color_t"], ); chart._program = compiled.program; chart._locations = compiled; const cornerBuffer = gl.createBuffer()!; gl.bindBuffer(gl.ARRAY_BUFFER, cornerBuffer); // Triangle strip: (0,0) (1,0) (0,1) (1,1) gl.bufferData( gl.ARRAY_BUFFER, new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]), gl.STATIC_DRAW, ); chart._cornerBuffer = cornerBuffer; } function uploadInstanceBuffers( chart: HeatmapChart, glManager: WebGLContextManager, ): void { const n = chart._cells.length; chart._uploadedCells = n; if (n === 0) { return; } const cellXY = new Float32Array(n * 2); const colorT = new Float32Array(n); // Sign-aware `t`: 0 always lands at 0.5. See `theme/gradient.ts`. // Numeric-axis mode pre-multiplies the integer index into the real // data position so the shader can apply `u_cell_size` (band width) // uniformly without per-instance attrs. Datetime axes need an // origin rebase before f32 narrowing — see {@link HeatmapFacet} // and `HeatmapChart._xOrigin/_yOrigin`. Origins are 0 for category // mode, where the integer index is already small. if (chart._facets.length > 0) { let i = 0; for (const facet of chart._facets) { const xPos = facet.pipeline.xPositions; const yPos = facet.pipeline.yPositions; const xO = facet.xOrigin; const yO = facet.yOrigin; for (const c of facet.pipeline.cells) { cellXY[i * 2] = xPos ? xPos[c.xIdx] - xO : c.xIdx; cellXY[i * 2 + 1] = yPos ? yPos[c.yIdx] - yO : c.yIdx; colorT[i] = colorValueToT( c.value, chart._colorMin, chart._colorMax, ); i++; } } } else { const xPos = chart._xPositions; const yPos = chart._yPositions; const xO = chart._xOrigin; const yO = chart._yOrigin; for (let i = 0; i < n; i++) { const c = chart._cells[i]; cellXY[i * 2] = xPos ? xPos[c.xIdx] - xO : c.xIdx; cellXY[i * 2 + 1] = yPos ? yPos[c.yIdx] - yO : c.yIdx; colorT[i] = colorValueToT( c.value, chart._colorMin, chart._colorMax, ); } } glManager.bufferPool.ensureCapacity(n); glManager.bufferPool.upload("heatmap_cell", cellXY, 0, 2); glManager.bufferPool.upload("heatmap_t", colorT, 0, 1); } function drawCellsInstanced( chart: HeatmapChart, gl: WebGL2RenderingContext | WebGLRenderingContext, glManager: WebGLContextManager, instanceStart: number, instanceCount: number, ): void { if (instanceCount === 0) { return; } const loc = chart._locations!; const instancing = getInstancing(glManager); const { setDivisor } = instancing; const f = Float32Array.BYTES_PER_ELEMENT; // Per-vertex corner buffer. gl.bindBuffer(gl.ARRAY_BUFFER, chart._cornerBuffer!); gl.enableVertexAttribArray(loc.a_corner); gl.vertexAttribPointer(loc.a_corner, 2, gl.FLOAT, false, 0, 0); setDivisor(loc.a_corner, 0); // Per-instance cell position. Byte offset into the packed buffer // advances instance 0 of this draw to slot `instanceStart`. // // Render-path uses `peek` (not `getOrCreate`); if the buffers // haven't been uploaded yet, skip the draw rather than render // against a recreated zero buffer. const cellBuf = glManager.bufferPool.peek("heatmap_cell"); const tBuf = glManager.bufferPool.peek("heatmap_t"); if (!cellBuf || !tBuf) { return; } gl.bindBuffer(gl.ARRAY_BUFFER, cellBuf.buffer); gl.enableVertexAttribArray(loc.a_cell); gl.vertexAttribPointer( loc.a_cell, 2, gl.FLOAT, false, 0, instanceStart * 2 * f, ); setDivisor(loc.a_cell, 1); gl.bindBuffer(gl.ARRAY_BUFFER, tBuf.buffer); gl.enableVertexAttribArray(loc.a_color_t); gl.vertexAttribPointer( loc.a_color_t, 1, gl.FLOAT, false, 0, instanceStart * f, ); setDivisor(loc.a_color_t, 1); instancing.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, instanceCount); setDivisor(loc.a_cell, 0); setDivisor(loc.a_color_t, 0); } /** * Chrome overlay: X axis + Y axis + color legend + (optional) tooltip. */ export function renderHeatmapChromeOverlay(chart: HeatmapChart): void { if (!chart._chromeCanvas) { return; } if (chart._facets.length > 0) { renderFacetedHeatmapChromeOverlay(chart); return; } if (!chart._lastLayout) { return; } const layout = chart._lastLayout; const theme = chart._resolveTheme(); const dpr = chart._glManager?.dpr ?? 1; const ctx = initCanvas(chart._chromeCanvas, layout, dpr); if (!ctx) { return; } // L-shaped axis line, same as bar chart chrome. ctx.strokeStyle = theme.gridlineColor; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(layout.plotRect.x, layout.plotRect.y); ctx.lineTo(layout.plotRect.x, layout.plotRect.y + layout.plotRect.height); ctx.lineTo( layout.plotRect.x + layout.plotRect.width, layout.plotRect.y + layout.plotRect.height, ); ctx.stroke(); const xDomain: CategoricalDomain = { levels: chart._xLevels, numRows: chart._numX, levelLabels: chart._groupBy.slice(), }; const yDomain: CategoricalDomain = { levels: chart._yLevels, numRows: chart._numY, levelLabels: [], }; // Heatmap X axis is the first group_by level; Y axis is the // second (when present) or the first split_by level. const xColumn = chart._groupBy[0]; const yColumn = chart._groupBy[1] ?? chart._splitBy[0]; if (chart._xAxisMode.mode === "numeric" && chart._xNumericDomain) { const ticks = computeNiceTicks(layout.paddedXMin, layout.paddedXMax, 6); drawNumericCategoryX( ctx, layout, chart._xNumericDomain, ticks, theme, chart.getColumnFormatter(xColumn, "tick"), ); } else { renderCategoricalXTicks(ctx, layout, xDomain, theme); } if (chart._yAxisMode.mode === "numeric" && chart._yNumericDomain) { const ticks = computeNiceTicks(layout.paddedYMin, layout.paddedYMax, 6); drawNumericCategoryY( ctx, layout, chart._yNumericDomain, ticks, theme, chart.getColumnFormatter(yColumn, "tick"), ); } else { renderCategoricalYTicks( ctx, layout, yDomain, theme, HEATMAP_Y_AXIS_OPTS, ); } // Color legend on the right. The aggregate column name is in // `_columnSlots[0]` (heatmap's only data column slot is "Color"). renderLegend( chart._chromeCanvas, layout, { min: chart._colorMin, max: chart._colorMax, label: chart._aggName, }, theme.gradientStops, theme, chart.getColumnFormatter(chart._columnSlots[0], "value"), ); if (chart._hoveredCell) { renderHeatmapTooltip(chart); } } /** Multi-facet WebGL render. Packs all facets' cells into one instance * buffer and dispatches once per facet with a rebound pointer offset, * matching projection, and scissor to the facet's plot rect. */ function renderFacetedHeatmap( chart: HeatmapChart, glManager: WebGLContextManager, cssWidth: number, cssHeight: number, ): void { const gl = glManager.gl; const theme = chart._resolveTheme(); // Derive the effective shared-axis flags for this frame. Stamps // `_lastEffectiveSharedX/Y` on the chart so // `renderFacetedHeatmapChromeOverlay` reads the same values without // re-deriving (and without us having to mutate `_facetConfig`). const { effectiveSharedX, effectiveSharedY } = chart.computeEffectiveFacetFlags(); const grid = buildFacetGrid( chart._facets.map((f) => f.label), { cssWidth, cssHeight, xAxis: effectiveSharedX ? "outer" : "cell", yAxis: effectiveSharedY ? "outer" : "cell", hasLegend: true, hasXLabel: chart._groupBy.length > 0, hasYLabel: false, gap: 8, }, ); chart._facetGrid = grid; for (let i = 0; i < chart._facets.length; i++) { const cell = grid.cells[i]; if (cell) { chart._facets[i].layout = cell.layout; } } // Wire every active zoom controller's layout pointer so wheel/pan // hit-tests compute correct data deltas. chart.syncFacetZoomLayouts(grid.cells); ensureProgram(chart, glManager); uploadInstanceBuffers(chart, glManager); chart._gradientCache = ensureGradientTexture( glManager, chart._gradientCache, theme.gradientStops, ); gl.useProgram(chart._program!); const loc = chart._locations!; bindGradientTexture( glManager, chart._gradientCache!.texture, loc.u_gradient_lut, 0, ); // One clear for the whole frame; per-facet scissor keeps each // facet's draw confined to its plot rect without wiping its // neighbours. clearAndSetupFrame(gl); for (let i = 0; i < chart._facets.length; i++) { const facet = chart._facets[i]; if (facet.instanceCount === 0) { continue; } const { numX, numY } = facet.pipeline; if (numX === 0 || numY === 0) { continue; } const layout = facet.layout; const xNumeric = facet.pipeline.xAxisMode.mode === "numeric"; const yNumeric = facet.pipeline.yAxisMode.mode === "numeric"; const xDomainMin = xNumeric ? facet.pipeline.xNumericDomain!.min : -0.5; const xDomainMax = xNumeric ? facet.pipeline.xNumericDomain!.max : numX - 0.5; const yDomainMin = yNumeric ? facet.pipeline.yNumericDomain!.min : -0.5; const yDomainMax = yNumeric ? facet.pipeline.yNumericDomain!.max : numY - 0.5; // Anchor the controller's base domain to this facet's data // extent so wheel/pan transforms compose against a meaningful // identity. In shared mode every facet writes the same base // (heatmap facets share group_by → identical X domain, and // matching Y shapes from `partitionColumnsPerFacet` → identical // Y domain), so last-write-wins is a no-op. In independent // mode each facet's own controller gets its own base. const zc = chart.getZoomControllerForFacet(i); if (zc) { zc.setBaseDomain(xDomainMin, xDomainMax, yDomainMin, yDomainMax); } const vis = zc ? zc.getVisibleDomain() : { xMin: xDomainMin, xMax: xDomainMax, yMin: yDomainMin, yMax: yDomainMax, }; const projection = layout.buildProjectionMatrix( vis.xMin, vis.xMax, vis.yMin, vis.yMax, undefined, undefined, 0, facet.xOrigin, facet.yOrigin, ); const plot = layout.plotRect; const pxPerDataX = plot.width / (vis.xMax - vis.xMin); const pxPerDataY = plot.height / (vis.yMax - vis.yMin); const halfGap = theme.heatmapGapPx * 0.5; const cellSizeX = xNumeric ? facet.pipeline.xNumericDomain!.bandWidth : 1; const cellSizeY = yNumeric ? facet.pipeline.yNumericDomain!.bandWidth : 1; const insetX = Math.min( cellSizeX * 0.5, pxPerDataX > 0 ? halfGap / pxPerDataX : 0, ); const insetY = Math.min( cellSizeY * 0.5, pxPerDataY > 0 ? halfGap / pxPerDataY : 0, ); withScissor(gl, layout, glManager.dpr, () => { gl.uniformMatrix4fv(loc.u_projection, false, projection); gl.uniform2f(loc.u_cell_inset, insetX, insetY); gl.uniform2f(loc.u_cell_size, cellSizeX, cellSizeY); drawCellsInstanced( chart, gl, glManager, facet.instanceStart, facet.instanceCount, ); }); } renderHeatmapChromeOverlay(chart); } /** * Multi-facet chrome: per-facet X/Y axis + title, one shared legend. */ function renderFacetedHeatmapChromeOverlay(chart: HeatmapChart): void { if (!chart._chromeCanvas || !chart._facetGrid) { return; } const theme = chart._resolveTheme(); // `initCanvas` wants a `PlotLayout` to sync DPR-aware sizing. The // first facet's layout is canvas-sized (cssWidth/cssHeight match // the element), so either facet works for the DPR handshake. const dpr = chart._glManager?.dpr ?? 1; const ctx = initCanvas(chart._chromeCanvas, chart._facets[0].layout, dpr); if (!ctx) { return; } // Shared-axis suppression: when shared-X is active the X tick // labels paint just below `cell.layout.plotRect` — which, because // `buildFacetGrid` was called with `xAxis: "outer"`, falls into // the reserved `outerXAxisRect` band rather than per-cell padding. // Painting from a bottom-edge cell's layout is enough; non-edge // rows would paint the same labels at the wrong y coordinate, so // we skip them. Symmetric for Y. The cleanest way to express // "shared = only edge cells render axes" is to gate the per-cell // call on `!sharedX || isBottomEdge` (and analogously for Y). const sharedX = chart._lastEffectiveSharedX; const sharedY = chart._lastEffectiveSharedY; const grid = chart._facetGrid; for (let i = 0; i < chart._facets.length; i++) { const facet = chart._facets[i]; const cell = grid.cells[i]; const layout = facet.layout; const plot = layout.plotRect; ctx.strokeStyle = theme.gridlineColor; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(plot.x, plot.y); ctx.lineTo(plot.x, plot.y + plot.height); ctx.lineTo(plot.x + plot.width, plot.y + plot.height); ctx.stroke(); const xDomain: CategoricalDomain = { levels: facet.pipeline.xLevels, numRows: facet.pipeline.numX, levelLabels: chart._groupBy.slice(), }; const yDomain: CategoricalDomain = { levels: facet.pipeline.yLevels, numRows: facet.pipeline.numY, levelLabels: [], }; const xColumn = chart._groupBy[0]; const yColumn = chart._groupBy[1] ?? chart._splitBy[0]; if (!sharedX || cell.isBottomEdge) { if ( facet.pipeline.xAxisMode.mode === "numeric" && facet.pipeline.xNumericDomain ) { const ticks = computeNiceTicks( layout.paddedXMin, layout.paddedXMax, 6, ); drawNumericCategoryX( ctx, layout, facet.pipeline.xNumericDomain, ticks, theme, chart.getColumnFormatter(xColumn, "tick"), ); } else { renderCategoricalXTicks(ctx, layout, xDomain, theme); } } if (!sharedY || cell.isLeftEdge) { if ( facet.pipeline.yAxisMode.mode === "numeric" && facet.pipeline.yNumericDomain ) { const ticks = computeNiceTicks( layout.paddedYMin, layout.paddedYMax, 6, ); drawNumericCategoryY( ctx, layout, facet.pipeline.yNumericDomain, ticks, theme, chart.getColumnFormatter(yColumn, "tick"), ); } else { renderCategoricalYTicks( ctx, layout, yDomain, theme, HEATMAP_Y_AXIS_OPTS, ); } } } // Per-facet titles sit in the grid cell's titleRect — one strip per // facet, above the plot rect. The grid's cells and the chart's // facets are parallel arrays by construction. for (let i = 0; i < grid.cells.length; i++) { const cell = grid.cells[i]; const facet = chart._facets[i]; if (!facet || !cell.titleRect) { continue; } drawFacetTitle( chart._chromeCanvas, facet.label, cell.titleRect, theme, dpr, ); } // Shared colorbar at `grid.legendRect`. No meaningful single label — // the facet titles already name each column, and a combined label // would be ambiguous when columns differ. if (grid.legendRect) { renderLegendAt( chart._chromeCanvas, { x: grid.legendRect.x, y: grid.legendRect.y + 20, width: grid.legendRect.width, height: Math.max(1, grid.legendRect.height - 20), }, { min: chart._colorMin, max: chart._colorMax, label: "", }, theme.gradientStops, theme, chart.getColumnFormatter(chart._columnSlots[0], "value"), ); } if (chart._hoveredCell) { renderHeatmapTooltip(chart); } }