// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ // ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ // ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ // ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ // ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ // ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ // ┃ 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 { Context2D } from "../canvas-types"; import type { WebGLContextManager } from "../../webgl/context-manager"; import { ensurePalette, type SeriesChart, type SeriesAutoFitCache, } from "./series"; import type { PlotRect } from "../../layout/plot-layout"; import { PlotLayout } from "../../layout/plot-layout"; import { renderInPlotFrame } from "../../webgl/plot-frame"; import { renderCanvasTooltip } from "../../interaction/tooltip-controller"; import { drawBars, BAR_TYPE_BAR_VAL as BAR_TYPE_BAR } from "./glyphs/draw-bars"; import { getHoveredBar } from "./series-interact"; import { computeNiceTicks } from "../../layout/ticks"; import { type AxisDomain } from "../../axis/numeric-axis"; import { renderBarAxesChrome, renderBarGridlines, type BarCategoryAxis, type BarValueAxis, } from "../../axis/bar-axis"; import { measureCategoricalAxisHeight, measureCategoricalAxisWidth, type CategoricalDomain, } from "../../axis/categorical-axis"; import { buildBarTooltipLines } from "./series-interact"; /** * Reusable scratch for bar instance uploads. */ interface BarInstanceScratch { xCenters: Float32Array; halfWidths: Float32Array; y0s: Float32Array; y1s: Float32Array; seriesIds: Float32Array; axes: Float32Array; colors: Float32Array; } let _barInstanceScratch: BarInstanceScratch | null = null; function ensureBarInstanceScratch(n: number): BarInstanceScratch { if ( _barInstanceScratch && _barInstanceScratch.xCenters.length >= n && _barInstanceScratch.colors.length >= n * 3 ) { return _barInstanceScratch; } const cap = Math.max(n, _barInstanceScratch?.xCenters.length ?? 0); _barInstanceScratch = { xCenters: new Float32Array(cap), halfWidths: new Float32Array(cap), y0s: new Float32Array(cap), y1s: new Float32Array(cap), seriesIds: new Float32Array(cap), axes: new Float32Array(cap), colors: new Float32Array(cap * 3), }; return _barInstanceScratch; } /** * Upload bar instance buffers from the columnar `_bars` storage. */ export function uploadBarInstances( chart: SeriesChart, glManager: WebGLContextManager, ): void { const bars = chart._bars; const total = bars.count; let n = 0; if (total > 0) { const scratch = ensureBarInstanceScratch(total); if ( !chart._visibleBarIndices || chart._visibleBarIndices.length < total ) { chart._visibleBarIndices = new Int32Array(total); } const indices = chart._visibleBarIndices; // Rebase each xCenter by `_categoryOrigin` before f32 narrowing. const xOrigin = chart._categoryOrigin; const series = chart._series; const hidden = chart._hiddenSeries; const ct = bars.chartType; const sid = bars.seriesId; const xC = bars.xCenter; const hw = bars.halfWidth; const by0 = bars.y0; const by1 = bars.y1; const ax = bars.axis; for (let i = 0; i < total; i++) { if (ct[i] !== BAR_TYPE_BAR) { continue; } const seriesId = sid[i]; if (hidden.has(seriesId)) { continue; } scratch.xCenters[n] = xC[i] - xOrigin; scratch.halfWidths[n] = hw[i]; scratch.y0s[n] = by0[i]; scratch.y1s[n] = by1[i]; scratch.seriesIds[n] = seriesId; scratch.axes[n] = ax[i]; const color = series[seriesId].color; scratch.colors[n * 3] = color[0]; scratch.colors[n * 3 + 1] = color[1]; scratch.colors[n * 3 + 2] = color[2]; indices[n] = i; n++; } } chart._uploadedBars = n; if (n === 0) { chart._lastUploadedColors = null; return; } const scratch = _barInstanceScratch!; glManager.bufferPool.ensureCapacity(n); // `subarray(0, n)` slices the scratch to the current frame's // valid-data length. The scratch grows monotonically across // frames (see `ensureBarInstanceScratch`) so its `.length` reflects // historical peak, not current `n` — passing it whole would // overflow the GPU buffer after any session reset. glManager.bufferPool.upload("bar_x", scratch.xCenters.subarray(0, n), 0, 1); glManager.bufferPool.upload( "bar_hw", scratch.halfWidths.subarray(0, n), 0, 1, ); glManager.bufferPool.upload("bar_y0", scratch.y0s.subarray(0, n), 0, 1); glManager.bufferPool.upload("bar_y1", scratch.y1s.subarray(0, n), 0, 1); glManager.bufferPool.upload( "bar_sid", scratch.seriesIds.subarray(0, n), 0, 1, ); glManager.bufferPool.upload("bar_axis", scratch.axes.subarray(0, n), 0, 1); glManager.bufferPool.upload( "bar_color", scratch.colors.subarray(0, n * 3), 0, 3, ); // Snapshot the uploaded color bytes so subsequent palette-only // changes can detect a no-op and skip the GPU write. if ( !chart._lastUploadedColors || chart._lastUploadedColors.length < n * 3 ) { chart._lastUploadedColors = new Float32Array( Math.max(n * 3, chart._lastUploadedColors?.length ?? 0), ); } chart._lastUploadedColors.set(scratch.colors.subarray(0, n * 3)); } /** * Re-upload the per-bar color attribute. Short-circuits when the new * colors match the last-uploaded snapshot byte-for-byte. Legacy code * ran this every frame regardless; with the cached palette now stable * across pan/zoom this becomes a no-op except after data load / * `restyle()`. */ export function uploadBarColors( chart: SeriesChart, glManager: WebGLContextManager, ): void { const n = chart._uploadedBars; if (n === 0) { return; } const indices = chart._visibleBarIndices; const series = chart._series; const sid = chart._bars.seriesId; const scratch = ensureBarInstanceScratch(n); for (let i = 0; i < n; i++) { const color = series[sid[indices[i]]].color; scratch.colors[i * 3] = color[0]; scratch.colors[i * 3 + 1] = color[1]; scratch.colors[i * 3 + 2] = color[2]; } const last = chart._lastUploadedColors; if (last && last.length >= n * 3) { let same = true; for (let i = 0; i < n * 3; i++) { if (last[i] !== scratch.colors[i]) { same = false; break; } } if (same) { return; } } glManager.bufferPool.upload( "bar_color", scratch.colors.subarray(0, n * 3), 0, 3, ); if (!last || last.length < n * 3) { chart._lastUploadedColors = new Float32Array(n * 3); } chart._lastUploadedColors!.set(scratch.colors.subarray(0, n * 3)); } /** * Drop persistent vertex buffers for line / scatter / area glyphs. * Called from `uploadAndRender` before {@link rebuildGlyphBuffers}. */ export function invalidateGlyphBuffers(chart: SeriesChart): void { chart._glyphs.lines.invalidateBuffers(chart); chart._glyphs.scatter.invalidateBuffers(chart); chart._glyphs.areas.invalidateBuffers(chart); } /** * Build persistent vertex buffers for line / scatter / area glyphs. * The legacy renderers rebuilt and re-uploaded these every frame inside * the per-glyph draw functions; with stable post-build geometry the * uploads now happen exactly once per data-load / palette change. */ export function rebuildGlyphBuffers( chart: SeriesChart, glManager: WebGLContextManager, ): void { chart._glyphs.lines.rebuildBuffers(chart, glManager); chart._glyphs.scatter.rebuildBuffers(chart, glManager); chart._glyphs.areas.rebuildBuffers(chart, glManager); } /** * Full-frame render: gridlines → WebGL bars (instanced) → chrome overlay. */ export function renderBarFrame( chart: SeriesChart, 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._numCategories === 0) { return; } // Resolve the theme + palette. `ensurePalette` is a no-op when the // palette inputs (theme refs + series count) are unchanged — under // pan/zoom this short-circuits, leaving frame work to the GPU draw // calls only. After data load / `restyle()` it stamps fresh RGB // onto `_series[i].color`, and the color upload path detects the // change and re-uploads the bar instance colors. const theme = chart._resolveTheme(); if (ensurePalette(chart) && chart._uploadedBars > 0) { uploadBarColors(chart, glManager); } const horizontal = chart._isHorizontal; const numericCat = chart._categoryAxisMode === "numeric"; // Category axis bounds. Category mode runs [-0.5, N-0.5] in logical // units; numeric mode reads min/max from the data-unit // `_numericCategoryDomain`. Horizontal mode flips the Y domain so // catIdx=0 sits at the top (handled below in the projection call). const catMin = numericCat ? chart._numericCategoryDomain!.min : -0.5; const catMax = numericCat ? chart._numericCategoryDomain!.max : chart._numCategories - 0.5; const valMin = chart._leftDomain.min; const valMax = chart._leftDomain.max; if (chart._zoomController) { if (horizontal) { chart._zoomController.setBaseDomain(valMin, valMax, catMin, catMax); } else { chart._zoomController.setBaseDomain(catMin, catMax, valMin, valMax); } } // `visCat*` and `visVal*` always describe the currently-visible window // in logical (category/value) coords regardless of orientation. let visCatMin = catMin; let visCatMax = catMax; let visValMin = valMin; let visValMax = valMax; let visRightMin = chart._rightDomain?.min ?? 0; let visRightMax = chart._rightDomain?.max ?? 1; if (chart._zoomController) { const vd = chart._zoomController.getVisibleDomain(); if (horizontal) { visValMin = vd.xMin; visValMax = vd.xMax; visCatMin = vd.yMin; visCatMax = vd.yMax; } else { visCatMin = vd.xMin; visCatMax = vd.xMax; visValMin = vd.yMin; visValMax = vd.yMax; } } // Auto-fit the value axis to the visible categorical window. Gated // on `_autoFitValue` + the categorical axis being non-default: the // refit only narrows when the categorical axis is itself zoomed // (otherwise the visible window equals the data extent and the // refit collapses back to `_leftDomain`/`_rightDomain`). Vertical // charts put the category on X; horizontal charts put it on Y. const catNonDefault = horizontal ? !chart._zoomController?.isYDefault() : !chart._zoomController?.isXDefault(); if (chart._autoFitValue && chart._zoomController && catNonDefault) { const fit = computeVisibleValueExtent(chart, visCatMin, visCatMax); if (fit.hasLeft) { visValMin = fit.leftMin; visValMax = fit.leftMax; } if (chart._rightDomain && fit.hasRight) { visRightMin = fit.rightMin; visRightMax = fit.rightMax; } } // `include_zero` is absolute — zero must stay inside the rendered // domain even after a dynamic-zoom refit (`computeVisibleValueExtent` // returns the data-only extent, which can drop the baseline). // Without this, tick computation sees the refit window while the // projection's `requireZero` snap silently re-anchors to zero, so // ticks crowd one edge of an otherwise zero-anchored plot. if (chart._pluginConfig.include_zero) { if (visValMin > 0) { visValMin = 0; } if (visValMax < 0) { visValMax = 0; } if (chart._rightDomain) { if (visRightMin > 0) { visRightMin = 0; } if (visRightMax < 0) { visRightMax = 0; } } } const hasLegend = chart._series.length > 1; const hasCatLabel = chart._groupBy.length > 0; const provisionalDomain: CategoricalDomain = { levels: chart._rowPaths, numRows: chart._numCategories, levelLabels: chart._groupBy.slice(), }; // Categorical value-axis sizing. Y Bar puts the value axis on the // left (so the category labels need extra `leftExtra` width); X Bar // puts it on the bottom (extra `bottomExtra` height for the leaf // labels). We additionally override the category-axis gutter on // the opposite side via the existing `provisionalDomain` path. const valueCatDomain = chart._leftValueCategoryDomain; const valueCatActive = chart._leftValueAxisMode === "category" && valueCatDomain !== null && valueCatDomain.numRows > 0; let layout: PlotLayout; if (horizontal) { // X Bar: category axis on the left (Y side), value axis on the // bottom (X side). Categorical value axis grows the bottom // gutter; numeric value axis uses the fixed 24px row. const leftExtra = numericCat ? 55 : measureCategoricalAxisWidth(provisionalDomain); const estLeft = leftExtra + (hasCatLabel ? 16 : 0); const estRight = hasLegend ? 80 : 16; const estPlotWidthH = Math.max(1, cssWidth - estLeft - estRight); const bottomExtra = valueCatActive ? measureCategoricalAxisHeight(valueCatDomain, estPlotWidthH) : undefined; layout = new PlotLayout(cssWidth, cssHeight, { hasXLabel: true, hasYLabel: hasCatLabel, hasLegend, leftExtra, bottomExtra, }); } else if (numericCat) { // Y Bar with numeric category axis on X. Value axis (Y, left) // may still be categorical when all aggregates are string. const leftExtra = valueCatActive ? measureCategoricalAxisWidth(valueCatDomain) : undefined; layout = new PlotLayout(cssWidth, cssHeight, { hasXLabel: hasCatLabel, hasYLabel: true, hasLegend, bottomExtra: 24, leftExtra, }); } else { // Y Bar with categorical X. Value axis on the left may be // categorical too — independently sized. const leftExtraBase = valueCatActive ? measureCategoricalAxisWidth(valueCatDomain) : 55; const estLeft = leftExtraBase + 16; const estRight = hasLegend ? 80 : 16; const estPlotWidth = Math.max(1, cssWidth - estLeft - estRight); const bottomExtra = measureCategoricalAxisHeight( provisionalDomain, estPlotWidth, ); layout = new PlotLayout(cssWidth, cssHeight, { hasXLabel: hasCatLabel, hasYLabel: true, hasLegend, bottomExtra, leftExtra: valueCatActive ? leftExtraBase : undefined, }); } chart._lastLayout = layout; if (chart._zoomController) { chart._zoomController.updateLayout(layout); } // Build the primary projection. `clamp` names the axis that carries // the *value* data (Y for Y Bar, X for X Bar). `requireZero` pins // the baseline at zero so bar / area glyphs grow from the axis // line; it must track `include_zero` so the projection's padded // domain matches the build pipeline's `leftDomain` (otherwise the // tick computation and the WebGL geometry use different scales). const requireZero = chart._pluginConfig.include_zero; const projLeft = horizontal ? layout.buildProjectionMatrix( visValMin, visValMax, // Flip so catIdx=0 renders at the top. visCatMax, visCatMin, "x", requireZero, undefined, 0, chart._categoryOrigin, ) : layout.buildProjectionMatrix( visCatMin, visCatMax, visValMin, visValMax, "y", requireZero, undefined, chart._categoryOrigin, 0, ); let projRight: Float32Array; if (chart._hasRightAxis && chart._rightDomain && !horizontal) { const savedPadXMin = layout.paddedXMin; const savedPadXMax = layout.paddedXMax; const savedPadYMin = layout.paddedYMin; const savedPadYMax = layout.paddedYMax; projRight = layout.buildProjectionMatrix( visCatMin, visCatMax, visRightMin, visRightMax, "y", requireZero, undefined, chart._categoryOrigin, 0, ); layout.paddedXMin = savedPadXMin; layout.paddedXMax = savedPadXMax; layout.paddedYMin = savedPadYMin; layout.paddedYMax = savedPadYMax; } else { // Dual-axis horizontal is not supported in this iteration; fall // through to a single axis when horizontal + _hasRightAxis. projRight = projLeft; } const leftValueTicks = computeNiceTicks(visValMin, visValMax, 6); const rightValueTicks = chart._hasRightAxis && chart._rightDomain && !horizontal ? computeNiceTicks(visRightMin, visRightMax, 6) : null; const catDomain: CategoricalDomain = provisionalDomain; const valueDomain: AxisDomain = { min: visValMin, max: visValMax, label: chart._primaryValueLabel, }; const altValueDomain: AxisDomain | null = chart._rightDomain && !horizontal ? { min: visRightMin, max: visRightMax, label: chart._altValueLabel, } : null; if (chart._gridlineCanvas) { renderBarGridlines( chart._gridlineCanvas, layout, leftValueTicks, theme, glManager.dpr, horizontal, ); } renderInPlotFrame(gl, layout, glManager.dpr, () => { // Paint order: areas behind bars (so bar borders stay crisp), // bars above, lines above those, scatter points on top. X Bar // only paints bars — the other glyphs bake in vertical geometry // and aren't supported for horizontal orientation. if (!horizontal) { chart._glyphs.areas.draw( chart, gl, glManager, projLeft, projRight, theme.areaOpacity, ); } gl.useProgram(chart._program!); const loc = chart._locations!; gl.uniformMatrix4fv(loc.u_proj_left, false, projLeft); gl.uniformMatrix4fv(loc.u_proj_right, false, projRight); gl.uniform1f(loc.u_horizontal, horizontal ? 1.0 : 0.0); const hovered = chart._series.length > 1 ? getHoveredBar(chart) : null; gl.uniform1f(loc.u_hover_series, hovered ? hovered.seriesId : -1); drawBars(chart, gl, glManager); if (!horizontal) { chart._glyphs.lines.draw(chart, gl, glManager, projLeft, projRight); chart._glyphs.scatter.draw( chart, gl, glManager, projLeft, projRight, ); } }); chart._lastXDomain = catDomain; chart._lastYDomain = valueDomain; chart._lastYTicks = leftValueTicks; chart._lastAltYDomain = altValueDomain; chart._lastAltYTicks = rightValueTicks; chart._lastCatTicks = numericCat ? computeNiceTicks(visCatMin, visCatMax, 6) : null; renderBarChromeOverlay(chart); } /** * Draw axes chrome + legend + tooltip onto the overlay canvas. */ export function renderBarChromeOverlay(chart: SeriesChart): void { if ( !chart._chromeCanvas || !chart._lastLayout || !chart._lastYDomain || !chart._lastYTicks ) { return; } const theme = chart._resolveTheme(); let catAxis: BarCategoryAxis; if ( chart._categoryAxisMode === "numeric" && chart._numericCategoryDomain && chart._lastCatTicks ) { catAxis = { mode: "numeric", domain: { min: chart._numericCategoryDomain.min, max: chart._numericCategoryDomain.max, isDate: chart._numericCategoryDomain.isDate, label: chart._numericCategoryDomain.label, }, ticks: chart._lastCatTicks, }; } else if (chart._lastXDomain) { catAxis = { mode: "category", domain: chart._lastXDomain }; } else { return; } // Y axis columns: the primary axis aggregates the unique Y column // shared by all series on it. With `auto_alt_y_axis`, series can // split across primary/secondary by `_series[i].onAltAxis`; the // primary formatter follows the first non-alt series, alt follows // the first alt series (falls back to the formatter's own type- // aware fallback if no such series exists). const primarySeries = chart._series.find((s) => s.axis === 0); const altSeries = chart._series.find((s) => s.axis === 1); const xColumn = chart._groupBy[0]; // Discriminate each value-axis side independently: a side becomes // categorical when every aggregate on it is post-aggregation // `string`-typed (the build pipeline already applied this // all-or-nothing rule and stamped `_*ValueAxisMode`). const valueAxis: BarValueAxis = chart._leftValueAxisMode === "category" && chart._leftValueCategoryDomain ? { mode: "category", domain: chart._leftValueCategoryDomain } : { mode: "numeric", domain: chart._lastYDomain, ticks: chart._lastYTicks, }; let altAxis: BarValueAxis | undefined; if (chart._lastAltYDomain && chart._lastAltYTicks) { altAxis = chart._rightValueAxisMode === "category" && chart._rightValueCategoryDomain ? { mode: "category", domain: chart._rightValueCategoryDomain, } : { mode: "numeric", domain: chart._lastAltYDomain, ticks: chart._lastAltYTicks, }; } renderBarAxesChrome( chart._chromeCanvas, catAxis, valueAxis, chart._lastLayout, theme, chart._glManager?.dpr ?? 1, altAxis, chart._isHorizontal, { value: chart.getColumnFormatter( primarySeries?.aggName ?? null, "tick", ), alt: chart.getColumnFormatter(altSeries?.aggName ?? null, "tick"), category: chart.getColumnFormatter(xColumn, "tick"), }, ); renderBarLegend(chart); if (getHoveredBar(chart)) { renderBarTooltipCanvas(chart); } } /** * Cached parallel array of measured legend text widths. The legend * renderer reads from this each frame instead of re-running * `ctx.measureText` per series; the widths only change on series-set * or theme change. `_legendCacheValid` gates rebuild. */ let _legendTextWidths: Float64Array = new Float64Array(0); function ensureLegendLayout( chart: SeriesChart, ctx: Context2D, fontFamily: string, ): void { if (chart._legendCacheValid) { return; } const series = chart._series; if (_legendTextWidths.length < series.length) { _legendTextWidths = new Float64Array(series.length); } ctx.save(); ctx.font = `11px ${fontFamily}`; for (let i = 0; i < series.length; i++) { _legendTextWidths[i] = ctx.measureText(series[i].label).width; } ctx.restore(); chart._legendCacheValid = true; } function renderBarLegend(chart: SeriesChart): void { chart._legendRects = []; if (!chart._chromeCanvas || !chart._lastLayout) { return; } if (chart._series.length <= 1) { return; } const ctx = chart._chromeCanvas.getContext("2d") as Context2D | null; if (!ctx) { return; } ctx.save(); const theme = chart._resolveTheme(); const textColor = theme.legendText; const fontFamily = theme.fontFamily; ensureLegendLayout(chart, ctx, fontFamily); const layout = chart._lastLayout; const swatchSize = 10; const lineHeight = 18; const x = layout.plotRect.x + layout.plotRect.width + 12; let y = layout.margins.top + 10; ctx.font = `11px ${fontFamily}`; ctx.textAlign = "left"; ctx.textBaseline = "middle"; const series = chart._series; const widths = _legendTextWidths; for (let i = 0; i < series.length; i++) { const s = series[i]; const hidden = chart._hiddenSeries.has(s.seriesId); const r = Math.round(s.color[0] * 255); const g = Math.round(s.color[1] * 255); const b = Math.round(s.color[2] * 255); ctx.globalAlpha = hidden ? 0.3 : 1.0; ctx.fillStyle = `rgb(${r},${g},${b})`; ctx.fillRect(x, y - swatchSize / 2, swatchSize, swatchSize); ctx.fillStyle = textColor; ctx.fillText(s.label, x + swatchSize + 6, y); const textW = widths[i]; if (hidden) { ctx.strokeStyle = textColor; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(x + swatchSize + 6, y); ctx.lineTo(x + swatchSize + 6 + textW, y); ctx.stroke(); } ctx.globalAlpha = 1.0; const rect: PlotRect = { x: x - 2, y: y - lineHeight / 2, width: swatchSize + 6 + textW + 4, height: lineHeight, }; chart._legendRects.push({ seriesId: s.seriesId, rect }); y += lineHeight; } ctx.restore(); } function renderBarTooltipCanvas(chart: SeriesChart): void { if (!chart._chromeCanvas || !chart._lastLayout) { return; } const b = getHoveredBar(chart); if (!b) { return; } const layout = chart._lastLayout; // Bar glyphs anchor the tooltip at the midpoint of the bar body so // it reads against a solid swatch. Line / scatter / area glyphs // have no body — the data point sits at `y1`, so anchor there // (the tooltip visually hovers *over* the point). Hit records // synthesized from line/scatter hover tag themselves as "bar" in // `_hoveredSample` for rendering purposes, so we pull the true // glyph from the series info instead. const glyph = chart._series[b.seriesId]?.chartType ?? "bar"; const anchorV = glyph === "bar" ? (b.y0 + b.y1) / 2 : b.y1; const pos = b.axis === 0 ? chart._isHorizontal ? layout.dataToPixel(anchorV, b.xCenter) : layout.dataToPixel(b.xCenter, anchorV) : rightAxisDataToPixel(chart, b.xCenter, anchorV); const lines = buildBarTooltipLines(chart, b); const theme = chart._resolveTheme(); renderCanvasTooltip( chart._chromeCanvas, pos, lines, layout, theme, chart._glManager?.dpr ?? 1, ); } export function rightAxisDataToPixel( chart: SeriesChart, x: number, y: number, ): { px: number; py: number } { const layout = chart._lastLayout!; const { x: px, y: py, width, height } = layout.plotRect; const tx = (x - layout.paddedXMin) / (layout.paddedXMax - layout.paddedXMin); const r = chart._rightDomain!; const ty = (y - r.min) / (r.max - r.min); return { px: px + tx * width, py: py + (1 - ty) * height }; } /** * Compute per-axis value extent over bars whose `catIdx` falls inside * `[visCatMin, visCatMax]`. Skips hidden series. Returns a cached * result on `chart._autoFitCache` when `(visCatMin, visCatMax, * _hiddenSeries)` match the previous call — hover-only redraws hit * the cache every time. * * Value source is `min(y0, y1)`/`max(y0, y1)` per bar, which handles * stacked + negative-value bars uniformly. * * TODO(perf): O(|_bars|) linear scan. `_bars` is already ordered by * `catIdx`, so a binary-search pair to locate the visible slice would * drop this to O(log N + K_visible). Deferred — under current * `max_cells` ceilings the scan is <1% of frame time. * * Cache lifetime: reset on data upload ([bar.ts] `uploadAndRender`) * and legend toggle ([bar-interact.ts] `handleBarLegendClick`). Any * other mutation that affects the bar set must also null the cache. */ function computeVisibleValueExtent( chart: SeriesChart, visCatMin: number, visCatMax: number, ): { leftMin: number; leftMax: number; hasLeft: boolean; rightMin: number; rightMax: number; hasRight: boolean; } { const cache = chart._autoFitCache; if ( cache && cache.catMin === visCatMin && cache.catMax === visCatMax && cache.hidden === chart._hiddenSeries ) { return cache; } // Pre-bucketed extent table — built once per data load (and on // hidden-series mutation) — turns the per-frame walk from // O(`bars.count` = N×M×P) into O(visibleCats). The original // O(`bars.count`) walk now runs only inside `ensureCatExtents`. const buckets = ensureCatExtents(chart); let leftMin = Infinity; let leftMax = -Infinity; let hasLeft = false; let rightMin = Infinity; let rightMax = -Infinity; let hasRight = false; if (buckets.n > 0) { // Resolve the visible catIdx range. Category mode: `visCat*` are // already in catIdx space, so floor/ceil into `[0, n-1]`. // Numeric mode (`date | datetime | integer | float` group_by): // `visCat*` are absolute data values from the zoom controller's // visible domain — for a datetime axis they're ~1.7e12-magnitude // timestamps. A blind `Math.floor(visCatMin)` of that gives `lo // ≫ n`, the loop body never executes, and the value-axis refit // silently no-ops (chart looks the same horizontally-zoomed as // unzoomed). Map the data range back to catIdx via the sorted // `_categoryPositions`. See [series-interact.ts:239-250] for the // parallel hit-test branch. const positions = chart._categoryPositions; let lo: number; let hi: number; if (positions) { const r = mapDomainToCatRange( positions, buckets.n, visCatMin, visCatMax, ); lo = r.lo; hi = r.hi; } else { lo = Math.max(0, Math.floor(visCatMin)); hi = Math.min(buckets.n - 1, Math.ceil(visCatMax)); } const lMin = buckets.leftMin; const lMax = buckets.leftMax; const rMin = buckets.rightMin; const rMax = buckets.rightMax; const hL = buckets.hasLeft; const hR = buckets.hasRight; for (let i = lo; i <= hi; i++) { if (hL[i]) { if (lMin[i] < leftMin) { leftMin = lMin[i]; } if (lMax[i] > leftMax) { leftMax = lMax[i]; } hasLeft = true; } if (hR[i]) { if (rMin[i] < rightMin) { rightMin = rMin[i]; } if (rMax[i] > rightMax) { rightMax = rMax[i]; } hasRight = true; } } } // Reuse the same cache object to avoid per-frame allocation. // `hidden` stored by reference — identity comparison in the cache // hit path catches set-content changes because the legend-click // handler swaps / mutates the set in ways that invalidate the // cache via the explicit null-out. const next = cache ?? newSeriesAutoFitCache(); next.catMin = visCatMin; next.catMax = visCatMax; next.hidden = chart._hiddenSeries; next.leftMin = leftMin; next.leftMax = leftMax; next.hasLeft = hasLeft; next.rightMin = rightMin; next.rightMax = rightMax; next.hasRight = hasRight; chart._autoFitCache = next; return next; } /** * Map a numeric visible domain `[visMin, visMax]` to the inclusive catIdx * range `[lo, hi]` that intersects it, using a sorted `categoryPositions` * vector (ASC, per the pivot order). Returns an empty range (`lo > hi`) * when the domain misses every category. * * Edges are expanded by one catIdx on each side so a category whose * center sits just outside the visible window — but whose band-half * still overlaps it — still contributes to the auto-fit extent. */ function mapDomainToCatRange( positions: Float64Array, n: number, visMin: number, visMax: number, ): { lo: number; hi: number } { if (n === 0 || visMin > visMax) { return { lo: 0, hi: -1 }; } // Lower bound: smallest idx where positions[idx] >= visMin. let l = 0; let r = n; while (l < r) { const m = (l + r) >>> 1; if (positions[m] < visMin) { l = m + 1; } else { r = m; } } const lo = Math.max(0, l - 1); // Upper bound: smallest idx where positions[idx] > visMax (`l` after // loop). `l` itself is one past the last in-range catIdx, so the // inclusive `hi` for an exactly-overlapping band is `l - 1`; the // `+1`-then-clamp expands by one to capture partial-overlap bands. l = 0; r = n; while (l < r) { const m = (l + r) >>> 1; if (positions[m] <= visMax) { l = m + 1; } else { r = m; } } const hi = Math.min(n - 1, l); return { lo, hi }; } function newSeriesAutoFitCache(): SeriesAutoFitCache { return { catMin: 0, catMax: 0, hidden: new Set(), leftMin: 0, leftMax: 0, hasLeft: false, rightMin: 0, rightMax: 0, hasRight: false, }; } /** * Build (or rebuild) the per-category extent buckets for the current * `_bars` set plus the line / scatter sample grid, filtered by the * current `_hiddenSeries` set. The buckets answer "what's the value * range across this category?" in O(1) per category, replacing the * O(`bars.count` + N × |line+scatter|) per-frame walk. * * Bar / area glyphs contribute via `_bars` (min/max of `y0`,`y1`, so * stacking and negative values are handled uniformly). Line / scatter * glyphs have no `_bars` records — they contribute the raw sample * value `v` as the single-point extent `[v, v]`; without this pass * `series_zoom_mode === "dynamic"` would silently behave as `"fixed"` * on any pure line/scatter chart. * * Capacity-reused: typed arrays grown only when `_numCategories` * exceeds prior capacity. Amortizes across pan/zoom frames — runs * once per data load + once per legend toggle, not per frame. */ function ensureCatExtents( chart: SeriesChart, ): NonNullable { const N = chart._numCategories; let buckets = chart._catExtents; const sameCapacity = buckets && buckets.leftMin.length >= N; if ( buckets && sameCapacity && chart._catExtentsHidden === chart._hiddenSeries ) { return buckets; } if (!buckets || !sameCapacity) { buckets = { leftMin: new Float64Array(N), leftMax: new Float64Array(N), rightMin: new Float64Array(N), rightMax: new Float64Array(N), hasLeft: new Uint8Array(N), hasRight: new Uint8Array(N), n: N, }; chart._catExtents = buckets; } else { buckets.n = N; } // Initialize every per-cat slot to the empty extent. `Infinity` / // `-Infinity` so that the first contributing bar wins on // min/max comparisons. for (let i = 0; i < N; i++) { buckets.leftMin[i] = Infinity; buckets.leftMax[i] = -Infinity; buckets.rightMin[i] = Infinity; buckets.rightMax[i] = -Infinity; buckets.hasLeft[i] = 0; buckets.hasRight[i] = 0; } const bars = chart._bars; const hidden = chart._hiddenSeries; const catIdxArr = bars.catIdx; const seriesIdArr = bars.seriesId; const y0Arr = bars.y0; const y1Arr = bars.y1; const axisArr = bars.axis; for (let i = 0; i < bars.count; i++) { if (hidden.has(seriesIdArr[i])) { continue; } const ci = catIdxArr[i]; if (ci < 0 || ci >= N) { continue; } const y0 = y0Arr[i]; const y1 = y1Arr[i]; const lo = y0 < y1 ? y0 : y1; const hi = y0 < y1 ? y1 : y0; if (axisArr[i] === 1) { if (lo < buckets.rightMin[ci]) { buckets.rightMin[ci] = lo; } if (hi > buckets.rightMax[ci]) { buckets.rightMax[ci] = hi; } buckets.hasRight[ci] = 1; } else { if (lo < buckets.leftMin[ci]) { buckets.leftMin[ci] = lo; } if (hi > buckets.leftMax[ci]) { buckets.leftMax[ci] = hi; } buckets.hasLeft[ci] = 1; } } // Line / scatter glyphs route through `_samples`, not `_bars`, so // fold their per-cat values in here. Bar / area series are already // covered by the loop above (including non-stacking bar/area, which // emit `_bars` records with `y0=0`, `y1=v`); line / scatter never // stack, so the sample grid is their only contribution. const samplingSeries = [chart._lineSeries, chart._scatterSeries]; const samples = chart._samples; const sampleValid = chart._sampleValid; const S = chart._series.length; for (const seriesArr of samplingSeries) { for (const s of seriesArr) { if (hidden.has(s.seriesId)) { continue; } const onRight = s.axis === 1; const sid = s.seriesId; for (let ci = 0; ci < N; ci++) { const sampleIdx = ci * S + sid; if (!((sampleValid[sampleIdx >> 3] >> (sampleIdx & 7)) & 1)) { continue; } const v = samples[sampleIdx]; if (onRight) { if (v < buckets.rightMin[ci]) { buckets.rightMin[ci] = v; } if (v > buckets.rightMax[ci]) { buckets.rightMax[ci] = v; } buckets.hasRight[ci] = 1; } else { if (v < buckets.leftMin[ci]) { buckets.leftMin[ci] = v; } if (v > buckets.leftMax[ci]) { buckets.leftMax[ci] = v; } buckets.hasLeft[ci] = 1; } } } } chart._catExtentsHidden = hidden; return buckets; }