// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ // ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ // ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ // ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ // ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ // ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ // ┃ 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 { ColumnDataMap } from "../../data/view-reader"; import type { WebGLContextManager } from "../../webgl/context-manager"; import { AbstractChart } from "../chart-base"; import { PlotLayout } from "../../layout/plot-layout"; import type { CategoricalLevel } from "../../axis/categorical-axis"; import type { FacetGrid } from "../../layout/facet-grid"; import type { AxisMode, NumericCategoryDomain, } from "../common/category-axis-resolver"; import { buildHeatmapPipeline, partitionColumnsPerFacet, type HeatmapCell, type HeatmapPipelineResult, } from "./heatmap-build"; import { renderHeatmapFrame, renderHeatmapChromeOverlay, type HeatmapLocations, } from "./heatmap-render"; import { handleHeatmapHover } from "./heatmap-interact"; /** * One heatmap in a facet-grid layout. Each facet corresponds to one * user-selected column in the `Color` slot; its `pipeline` holds the * cell data, and `layout` is the cell's `PlotLayout` from `buildFacetGrid`. * `instanceStart`/`instanceCount` give the range of the packed * cell/colorT buffers that belong to this facet. */ export interface HeatmapFacet { label: string; pipeline: HeatmapPipelineResult; layout: PlotLayout; instanceStart: number; instanceCount: number; /** * Origins used to rebase cell positions before f32 narrowing — * matches the per-axis convention in `SeriesChart._categoryOrigin`. * `0` for non-numeric axes; for numeric (especially datetime) axes * pinned to `xNumericDomain.min` / `yNumericDomain.min` so the * shader's projection matrix can be built in rebased space. */ xOrigin: number; yOrigin: number; } /** * Heatmap chart. `yIdx` maps 1:1 to the arrow column iteration order * (after skipping `__ROW_PATH_N__` metadata). `xIdx` is the row index * post-`rowOffset`. * * With one user column in the `Color` slot the chart renders a single * heatmap filling the canvas. With more than one, each column becomes * its own heatmap in a facet grid; all facets share a common color * scale and a single legend. */ export class HeatmapChart extends AbstractChart { _program: WebGLProgram | null = null; _locations: HeatmapLocations | null = null; _cornerBuffer: WebGLBuffer | null = null; _gradientCache: | import("../../webgl/gradient-texture").GradientTextureCache | null = null; _xLevels: CategoricalLevel[] = []; _yLevels: CategoricalLevel[] = []; _yColumnNames: string[] = []; _numX = 0; _numY = 0; _rowOffset = 0; _xAxisMode: AxisMode = { mode: "category" }; _yAxisMode: AxisMode = { mode: "category" }; _xPositions: Float64Array | null = null; _yPositions: Float64Array | null = null; _xNumericDomain: NumericCategoryDomain | null = null; _yNumericDomain: NumericCategoryDomain | null = null; /** * Single-plot rebase origins. Datetime numeric axes carry ~1.7e12 * timestamps which the f32 GPU pipeline cannot resolve below * ~256ms; the cell upload subtracts these and the projection * matrix is built with the same values so its `tx`/`ty` terms stay * small enough for f32 cancellation in the shader. `0` for * categorical or numeric-non-date axes. */ _xOrigin = 0; _yOrigin = 0; _cells: HeatmapCell[] = []; _cells2D: (HeatmapCell | null)[] = []; _uploadedCells = 0; _colorMin = 0; _colorMax = 1; _aggName = ""; _hoveredCell: HeatmapCell | null = null; _lastLayout: PlotLayout | null = null; /** * Last cursor position (canvas-CSS pixels) recorded by * `handleHeatmapHover`. Used as the tooltip anchor instead of the * cell center so the hover label tracks the mouse — necessary * because heatmap cells can be many pixels wide and a center-anchored * tooltip drifts away from the cursor. */ _hoveredMouseX = 0; _hoveredMouseY = 0; _facets: HeatmapFacet[] = []; _facetGrid: FacetGrid | null = null; _hoveredFacetIdx = -1; /** * Bound accessor so the interact module can trigger a chrome redraw. */ _renderChromeOverlay = () => renderHeatmapChromeOverlay(this); protected override tooltipCallbacks() { return { onHover: (mx: number, my: number) => handleHeatmapHover(this, mx, my), onLeave: () => { if (this._hoveredCell) { this._hoveredCell = null; this._renderChromeOverlay(); } }, onPin: (mx: number, my: number) => { // Refresh the hit-test at the click coords so the pin // path doesn't depend on the RAF-throttled hover state // — see comment in `series.ts` `onPin`. handleHeatmapHover(this, mx, my); if (this._hoveredCell) { void this._emitHeatmapClickSelect( this._hoveredCell.xIdx, this._hoveredCell.yIdx, ); } }, onUnpin: () => { this.emitUnselect(); }, }; } /** * Resolve a clicked heatmap cell into a `PerspectiveClickDetail` * and emit both `perspective-click` and * `perspective-global-filter selected:true`. `xIdx` indexes the * outer (group-by) hierarchy; `yIdx` indexes the column-side * hierarchy (split-by + value-column splits). Each level is read * directly from the pre-resolved `_xLevels` / `_yLevels` labels. * * Representative source row: `xIdx + _rowOffset` — the row in the * pivoted view that owns this category. Sufficient for the * `row.{col}` lookups consumers typically do; not authoritative for * cells that aggregate across many source rows. */ private async _emitHeatmapClickSelect( xIdx: number, yIdx: number, ): Promise { const groupByValues: (string | null)[] = this._xLevels.map( (level) => level.labels[xIdx] ?? null, ); const splitByValues: (string | null)[] = this._yLevels .slice(0, this._splitBy.length) .map((level) => level.labels[yIdx] ?? null); const colorColumn = this._columnSlots[0] ?? ""; await this.emitClickAndSelect({ rowIdx: xIdx + this._rowOffset, columnName: colorColumn, groupByValues, splitByValues, }); } async uploadAndRender( glManager: WebGLContextManager, columns: ColumnDataMap, startRow: number, endRow: number, ): Promise { this._glManager = glManager; if (startRow !== 0) { // Heatmap renders a single consolidated pass; the viewer // should not chunk this but guard defensively. return; } const userColumns = this._columnSlots.filter((s): s is string => !!s); if (userColumns.length > 1) { const partitions = partitionColumnsPerFacet(columns, userColumns); const facets: HeatmapFacet[] = []; const allCells: HeatmapCell[] = []; let globalMin = Infinity; let globalMax = -Infinity; for (const part of partitions) { const pipeline = buildHeatmapPipeline({ columns: part.columns, numRows: endRow, groupBy: this._groupBy, splitBy: this._splitBy, groupByTypes: this._groupByTypes, }); const instanceStart = allCells.length; // Re-stamp each cell with its facet offset so the packed // instance buffer can be drawn in one sweep; the facet's // own `pipeline.cells` keeps its original indices for // hit-testing via `cells2D`. for (const c of pipeline.cells) { allCells.push({ xIdx: c.xIdx, yIdx: c.yIdx, value: c.value, }); } facets.push({ label: part.label, pipeline, layout: new PlotLayout(1, 1, { hasXLabel: false, hasYLabel: false, hasLegend: false, }), instanceStart, instanceCount: pipeline.cells.length, xOrigin: pipeline.xNumericDomain?.min ?? 0, yOrigin: pipeline.yNumericDomain?.min ?? 0, }); if ( isFinite(pipeline.colorMin) && pipeline.colorMin < globalMin ) { globalMin = pipeline.colorMin; } if ( isFinite(pipeline.colorMax) && pipeline.colorMax > globalMax ) { globalMax = pipeline.colorMax; } } if (!isFinite(globalMin) || !isFinite(globalMax)) { globalMin = 0; globalMax = 1; } else if (globalMin === globalMax) { globalMax = globalMin + 1; } // Reset single-plot state so render-time dispatch on // `_facets.length > 0` is unambiguous. this._xLevels = []; this._yLevels = []; this._yColumnNames = []; this._numX = 0; this._numY = 0; this._rowOffset = 0; this._cells2D = []; this._lastLayout = null; this._xAxisMode = { mode: "category" }; this._yAxisMode = { mode: "category" }; this._xPositions = null; this._yPositions = null; this._xNumericDomain = null; this._yNumericDomain = null; this._xOrigin = 0; this._yOrigin = 0; this._facets = facets; this._cells = allCells; this._colorMin = globalMin; this._colorMax = globalMax; this._aggName = userColumns.join(", "); } else { const result = buildHeatmapPipeline({ columns, numRows: endRow, groupBy: this._groupBy, splitBy: this._splitBy, groupByTypes: this._groupByTypes, }); this._facets = []; this._facetGrid = null; this._xLevels = result.xLevels; this._yLevels = result.yLevels; this._yColumnNames = result.yColumnNames; this._numX = result.numX; this._numY = result.numY; this._rowOffset = result.rowOffset; this._cells = result.cells; this._cells2D = result.cells2D; this._colorMin = result.colorMin; this._colorMax = result.colorMax; this._aggName = userColumns[0] ?? "Color"; this._xAxisMode = result.xAxisMode; this._yAxisMode = result.yAxisMode; this._xPositions = result.xPositions; this._yPositions = result.yPositions; this._xNumericDomain = result.xNumericDomain; this._yNumericDomain = result.yNumericDomain; this._xOrigin = result.xNumericDomain?.min ?? 0; this._yOrigin = result.yNumericDomain?.min ?? 0; } await this.requestRender(glManager); } _fullRender(glManager: WebGLContextManager): void { this._glManager = glManager; const hasSingle = this._numX > 0 && this._numY > 0; const hasFacets = this._facets.length > 0; if (!hasSingle && !hasFacets) { return; } renderHeatmapFrame(this, glManager); } protected destroyInternal(): void { if (this._cornerBuffer && this._glManager) { this._glManager.gl.deleteBuffer(this._cornerBuffer); } this._program = null; this._locations = null; this._cornerBuffer = null; this._xLevels = []; this._yLevels = []; this._yColumnNames = []; this._cells = []; this._cells2D = []; this._hoveredCell = null; this._facets = []; this._facetGrid = null; this._hoveredFacetIdx = -1; this._xAxisMode = { mode: "category" }; this._yAxisMode = { mode: "category" }; this._xPositions = null; this._yPositions = null; this._xNumericDomain = null; this._yNumericDomain = null; } }