diff --git a/packages/viewer-charts/src/ts/axis/bar-axis.ts b/packages/viewer-charts/src/ts/axis/bar-axis.ts index 5c15280761..9ac5de4188 100644 --- a/packages/viewer-charts/src/ts/axis/bar-axis.ts +++ b/packages/viewer-charts/src/ts/axis/bar-axis.ts @@ -142,6 +142,10 @@ export type BarCategoryAxis = | { mode: "category"; domain: CategoricalDomain } | { mode: "numeric"; domain: AxisDomain; ticks: number[] }; +export type BarValueAxis = + | { mode: "category"; domain: CategoricalDomain } + | { mode: "numeric"; domain: AxisDomain; ticks: number[] }; + /** * Render a numeric date-aware axis along the bottom of the plot. Aliases * the bar-axis bottom variant so heatmap can share the implementation. @@ -190,13 +194,11 @@ export interface BarAxesFormatters { export function renderBarAxesChrome( canvas: Canvas2D, catAxis: BarCategoryAxis, - valueDomain: AxisDomain, - valueTicks: number[], + valueAxis: BarValueAxis, layout: PlotLayout, theme: Theme, dpr: number, - altDomain?: AxisDomain, - altTicks?: number[], + altAxis: BarValueAxis | undefined, isHorizontal = false, formatters: BarAxesFormatters = {}, ): void { @@ -212,7 +214,7 @@ export function renderBarAxesChrome( ctx.moveTo(plot.x, plot.y); ctx.lineTo(plot.x, plot.y + plot.height); ctx.lineTo(plot.x + plot.width, plot.y + plot.height); - if (altDomain) { + if (altAxis) { if (isHorizontal) { ctx.moveTo(plot.x, plot.y); ctx.lineTo(plot.x + plot.width, plot.y); @@ -238,31 +240,49 @@ export function renderBarAxesChrome( ); } - drawNumericXAxis( - ctx, - layout, - valueDomain, - valueTicks, - "bottom", - theme, - formatters.value, - ); - if (altDomain && altTicks) { - const origMin = layout.paddedXMin; - const origMax = layout.paddedXMax; - layout.paddedXMin = altDomain.min; - layout.paddedXMax = altDomain.max; + if (valueAxis.mode === "category") { + // Categorical value axis on the bottom: reuse the X + // categorical painter. Slot indices on the layout's X + // domain already place each category at its slot pixel. + renderCategoricalXTicks(ctx, layout, valueAxis.domain, theme); + } else { drawNumericXAxis( ctx, layout, - altDomain, - altTicks, - "top", + valueAxis.domain, + valueAxis.ticks, + "bottom", theme, - formatters.alt, + formatters.value, ); - layout.paddedXMin = origMin; - layout.paddedXMax = origMax; + } + + if (altAxis) { + // Alt-axis painter expects the layout's X domain to match + // the alt domain — temporarily swap in `altDomain.min/max` + // for the duration of the call. Categorical alt has no + // top-side painter; render with the bottom-side painter + // (visual overlap with the primary side — documented + // limitation; user-pinned categorical alt is rare). + if (altAxis.mode === "category") { + renderCategoricalXTicks(ctx, layout, altAxis.domain, theme); + } else { + const origMin = layout.paddedXMin; + const origMax = layout.paddedXMax; + layout.paddedXMin = altAxis.domain.min; + layout.paddedXMax = altAxis.domain.max; + drawNumericXAxis( + ctx, + layout, + altAxis.domain, + altAxis.ticks, + "top", + theme, + formatters.alt, + ); + layout.paddedXMin = origMin; + layout.paddedXMax = origMax; + } } } else { if (catAxis.mode === "category") { @@ -278,31 +298,40 @@ export function renderBarAxesChrome( ); } - drawYAxis( - ctx, - layout, - valueDomain, - valueTicks, - "left", - theme, - formatters.value, - ); - if (altDomain && altTicks) { - const origMin = layout.paddedYMin; - const origMax = layout.paddedYMax; - layout.paddedYMin = altDomain.min; - layout.paddedYMax = altDomain.max; + if (valueAxis.mode === "category") { + renderCategoricalYTicks(ctx, layout, valueAxis.domain, theme); + } else { drawYAxis( ctx, layout, - altDomain, - altTicks, - "right", + valueAxis.domain, + valueAxis.ticks, + "left", theme, - formatters.alt, + formatters.value, ); - layout.paddedYMin = origMin; - layout.paddedYMax = origMax; + } + + if (altAxis) { + if (altAxis.mode === "category") { + renderCategoricalYTicks(ctx, layout, altAxis.domain, theme); + } else { + const origMin = layout.paddedYMin; + const origMax = layout.paddedYMax; + layout.paddedYMin = altAxis.domain.min; + layout.paddedYMax = altAxis.domain.max; + drawYAxis( + ctx, + layout, + altAxis.domain, + altAxis.ticks, + "right", + theme, + formatters.alt, + ); + layout.paddedYMin = origMin; + layout.paddedYMax = origMax; + } } } } diff --git a/packages/viewer-charts/src/ts/charts/candlestick/candlestick-render.ts b/packages/viewer-charts/src/ts/charts/candlestick/candlestick-render.ts index a555d31a76..6634821e72 100644 --- a/packages/viewer-charts/src/ts/charts/candlestick/candlestick-render.ts +++ b/packages/viewer-charts/src/ts/charts/candlestick/candlestick-render.ts @@ -272,13 +272,15 @@ export function renderCandlestickChromeOverlay(chart: CandlestickChart): void { renderBarAxesChrome( chart._chromeCanvas, catAxis, - chart._lastYDomain, - chart._lastYTicks, + { + mode: "numeric", + domain: chart._lastYDomain, + ticks: chart._lastYTicks, + }, chart._lastLayout, theme, chart._glManager?.dpr ?? 1, undefined, - undefined, false, { value: chart.getColumnFormatter(valueColumn, "tick"), diff --git a/packages/viewer-charts/src/ts/charts/candlestick/glyphs/draw-candlesticks.ts b/packages/viewer-charts/src/ts/charts/candlestick/glyphs/draw-candlesticks.ts index 5257ef6977..e5334c09d6 100644 --- a/packages/viewer-charts/src/ts/charts/candlestick/glyphs/draw-candlesticks.ts +++ b/packages/viewer-charts/src/ts/charts/candlestick/glyphs/draw-candlesticks.ts @@ -46,9 +46,23 @@ interface WickCache { u_color: WebGLUniformLocation | null; u_resolution: WebGLUniformLocation | null; u_line_width: WebGLUniformLocation | null; + + /** + * `line-uniform` was extended to support the Y-Line `interpolate` + * feature with a per-segment alpha multiplier driven by + * `a_real_start * a_real_end` and `u_interp_alpha`. Wicks are + * always "real" data — every segment renders fully — so we hold + * these locations to neutralize them at draw time (uniform = 1.0, + * constant attribute values = 1.0). Without that, an unset uniform + * defaults to 0 and the fragment alpha collapses to 0, rendering + * the wicks invisible. + */ + u_interp_alpha: WebGLUniformLocation | null; a_corner: number; a_start: number; a_end: number; + a_real_start: number; + a_real_end: number; } interface ProgramCache { @@ -124,8 +138,14 @@ export class BodyWickGlyph { "line-uniform", lineVert, lineFrag, - ["u_projection", "u_color", "u_resolution", "u_line_width"], - ["a_corner", "a_start", "a_end"], + [ + "u_projection", + "u_color", + "u_resolution", + "u_line_width", + "u_interp_alpha", + ], + ["a_corner", "a_start", "a_end", "a_real_start", "a_real_end"], ); const wick: WickCache = { ...wickPartial, @@ -375,6 +395,20 @@ function drawWicks( gl.uniform2f(cache.u_resolution, gl.canvas.width, gl.canvas.height); gl.uniform1f(cache.u_line_width, chart._pluginConfig.wick_width_px * dpr); + // `line-uniform` was extended for the Y-Line interpolate feature + // with a per-segment alpha multiplier; neutralize it here. + // Constant attribute values (used when the array is disabled) and + // uniform are stable for every draw, so set once after + // `useProgram`. Disabling the arrays first guards against a prior + // Y-Line draw that left them enabled at the same attribute index + // (locations are shared because both programs link from the same + // source). + gl.disableVertexAttribArray(cache.a_real_start); + gl.disableVertexAttribArray(cache.a_real_end); + gl.vertexAttrib1f(cache.a_real_start, 1.0); + gl.vertexAttrib1f(cache.a_real_end, 1.0); + gl.uniform1f(cache.u_interp_alpha, 1.0); + const instancing = getInstancing(glManager); const { setDivisor } = instancing; diff --git a/packages/viewer-charts/src/ts/charts/candlestick/glyphs/draw-ohlc.ts b/packages/viewer-charts/src/ts/charts/candlestick/glyphs/draw-ohlc.ts index 3320f63dc7..d89d2f8c06 100644 --- a/packages/viewer-charts/src/ts/charts/candlestick/glyphs/draw-ohlc.ts +++ b/packages/viewer-charts/src/ts/charts/candlestick/glyphs/draw-ohlc.ts @@ -30,9 +30,23 @@ interface OHLCCache { u_color: WebGLUniformLocation | null; u_resolution: WebGLUniformLocation | null; u_line_width: WebGLUniformLocation | null; + + /** + * `line-uniform` was extended to support the Y-Line `interpolate` + * feature with a per-segment alpha multiplier driven by + * `a_real_start * a_real_end` and `u_interp_alpha`. The OHLC glyph + * doesn't need that — every segment is "real" — so we hold these + * locations to neutralize them at draw time (uniform = 1.0, + * constant attribute values = 1.0). Without that, an unset uniform + * defaults to 0 and the fragment alpha collapses to 0, rendering + * the entire OHLC glyph invisible. + */ + u_interp_alpha: WebGLUniformLocation | null; a_corner: number; a_start: number; a_end: number; + a_real_start: number; + a_real_end: number; } /** @@ -76,8 +90,14 @@ export class OHLCGlyph { "line-uniform", lineVert, lineFrag, - ["u_projection", "u_color", "u_resolution", "u_line_width"], - ["a_corner", "a_start", "a_end"], + [ + "u_projection", + "u_color", + "u_resolution", + "u_line_width", + "u_interp_alpha", + ], + ["a_corner", "a_start", "a_end", "a_real_start", "a_real_end"], ); this._program = { ...partial, @@ -229,6 +249,20 @@ export class OHLCGlyph { chart._pluginConfig.ohlc_line_width_px * dpr, ); + // `line-uniform` was extended for the Y-Line interpolate + // feature with a per-segment alpha multiplier; neutralize it + // here. Constant attribute values (used when the array is + // disabled) and uniform are stable for every draw, so set + // once after `useProgram`. Disabling the arrays first guards + // against a prior Y-Line draw that left them enabled at the + // same attribute index (locations are shared because both + // programs link from the same source). + gl.disableVertexAttribArray(cache.a_real_start); + gl.disableVertexAttribArray(cache.a_real_end); + gl.vertexAttrib1f(cache.a_real_start, 1.0); + gl.vertexAttrib1f(cache.a_real_end, 1.0); + gl.uniform1f(cache.u_interp_alpha, 1.0); + const instancing = getInstancing(glManager); const { setDivisor } = instancing; diff --git a/packages/viewer-charts/src/ts/charts/cartesian/cartesian-build.ts b/packages/viewer-charts/src/ts/charts/cartesian/cartesian-build.ts index 3b5a468b93..12b0fa271e 100644 --- a/packages/viewer-charts/src/ts/charts/cartesian/cartesian-build.ts +++ b/packages/viewer-charts/src/ts/charts/cartesian/cartesian-build.ts @@ -16,6 +16,49 @@ import type { WebGLContextManager } from "../../webgl/context-manager"; import type { CartesianChart, SplitGroup } from "./cartesian"; import { LabelInterner } from "./label-interner"; +/** + * Resolve a row's string value into a slot index in `dictionary`, + * inserting on first encounter. Invalid / missing values land in a + * lazily-added `"(null)"` slot — no reserved slot 0 when the data has + * no nulls. Shared by the X and Y categorical paths in + * `processCartesianChunk`. + */ +function lookupCategorySlot( + col: ColumnData | null, + rowIdx: number, + dictionary: string[], + seen: Map, +): number { + let label: string; + if (!col) { + label = "(null)"; + } else { + const valid = col.valid; + const isValid = valid + ? !!((valid[rowIdx >> 3] >> (rowIdx & 7)) & 1) + : true; + if (!isValid) { + label = "(null)"; + } else if (col.indices && col.dictionary) { + label = col.dictionary[col.indices[rowIdx]] ?? "(null)"; + } else if (col.values) { + const v = col.values[rowIdx]; + label = v == null ? "(null)" : String(v); + } else { + label = "(null)"; + } + } + + let slot = seen.get(label); + if (slot === undefined) { + slot = dictionary.length; + dictionary.push(label); + seen.set(label, slot); + } + + return slot; +} + /** * Resolve per-split-prefix column-name tuples. `colorBase`/`sizeBase` * are optional (empty string when the corresponding slot is unset). @@ -122,6 +165,41 @@ export function initCartesianPipeline( chart._yLabel = yBase; chart._xIsRowIndex = !xBase; + // Post-aggregation `string` columns on X / Y switch the axis to + // categorical: per-row slot indices are written into `_xData` / + // `_yData` instead of raw values, and the chrome overlay paints a + // categorical axis. Reset the per-frame dictionary state here at + // chunk 0 so slot 0 is always the first non-null label encountered + // in arrival order (matching the perspective view's sort). + chart._xIsString = !!xBase && chart._columnTypes[xBase] === "string"; + chart._yIsString = !!yBase && chart._columnTypes[yBase] === "string"; + chart._xCategoryDictionary = []; + chart._yCategoryDictionary = []; + chart._xCategorySeen = new Map(); + chart._yCategorySeen = new Map(); + chart._xCategoryDomain = null; + chart._yCategoryDomain = null; + + // Categorical axes use 0-based slot indices, so the rebase origin + // is fixed at 0. Skipping the NaN-init guard below prevents the + // first-seen-slot from being adopted as the origin (which would + // shift every other slot's pixel position). + if (chart._xIsString) { + chart._xOrigin = 0; + chart._xMin = 0; + chart._xMax = 0; + chart._expandedXMin = Infinity; + chart._expandedXMax = -Infinity; + } + + if (chart._yIsString) { + chart._yOrigin = 0; + chart._yMin = 0; + chart._yMax = 0; + chart._expandedYMin = Infinity; + chart._expandedYMax = -Infinity; + } + // Capture the per-series row budget BEFORE any split expansion. When // split_by is active we grow `totalCapacity` to fit `numSplits` // parallel slot ranges; reading `totalCapacity` again after that @@ -245,9 +323,16 @@ export function processCartesianChunk( // carries the user's selected color column. The color-resolution // logic in the inner loop reads uniformly from `ser.colorCol` // across both modes. + // + // `xColData` / `yColData` carry the full `ColumnData` so the + // categorical path can read `indices` + `dictionary` for slot + // lookup; `xCol` / `yCol` keep the numeric fast path zero-cost + // (and stay `null` on string columns where `values` is unset). type SeriesSrc = { xCol: Float32Array | Float64Array | Int32Array | null; - yCol: Float32Array | Float64Array | Int32Array; + yCol: Float32Array | Float64Array | Int32Array | null; + xColData: ColumnData | null; + yColData: ColumnData | null; xValid: Uint8Array | undefined; yValid: Uint8Array | undefined; colorCol: ColumnData | null; @@ -260,7 +345,11 @@ export function processCartesianChunk( for (const sg of chart._splitGroups) { const xc = sg.xColName ? columns.get(sg.xColName) : null; const yc = columns.get(sg.yColName); - if (!yc?.values) { + if (!yc) { + continue; + } + + if (!chart._yIsString && !yc.values) { continue; } @@ -273,7 +362,9 @@ export function processCartesianChunk( : null; series.push({ xCol: xc?.values ?? null, - yCol: yc.values, + yCol: yc.values ?? null, + xColData: xc ?? null, + yColData: yc, xValid: xc?.valid, yValid: yc.valid, colorCol: cc, @@ -284,7 +375,11 @@ export function processCartesianChunk( } else { const xc = chart._xName ? columns.get(chart._xName) : null; const yc = chart._yName ? columns.get(chart._yName) : null; - if (!yc?.values) { + if (!yc) { + return; + } + + if (!chart._yIsString && !yc.values) { return; } @@ -296,7 +391,9 @@ export function processCartesianChunk( : null; series.push({ xCol: xc?.values ?? null, - yCol: yc.values, + yCol: yc.values ?? null, + xColData: xc ?? null, + yColData: yc, xValid: xc?.valid, yValid: yc?.valid, colorCol: cc, @@ -386,11 +483,20 @@ export function processCartesianChunk( let writeIdx = 0; for (let j = 0; j < sourceLength && writeIdx < maxWrite; j++) { const i = j; - if (ser.yValid && !((ser.yValid[i >> 3] >> (i & 7)) & 1)) { + // Numeric axes filter out null/invalid rows entirely; + // categorical axes route them into a `"(null)"` slot + // instead, so the validity / NaN guards only apply on + // the numeric branch. + if ( + !chart._yIsString && + ser.yValid && + !((ser.yValid[i >> 3] >> (i & 7)) & 1) + ) { continue; } if ( + !chart._xIsString && ser.xCol && ser.xValid && !((ser.xValid[i >> 3] >> (i & 7)) & 1) @@ -402,12 +508,40 @@ export function processCartesianChunk( colorValid !== undefined && !((colorValid[i >> 3] >> (i & 7)) & 1); - const rawY = ser.yCol[i] as number; - const rawX = ser.xCol ? (ser.xCol[i] as number) : startRow + i; - if (isNaN(rawX) || isNaN(rawY)) { + let rawY: number; + if (chart._yIsString) { + rawY = lookupCategorySlot( + ser.yColData, + i, + chart._yCategoryDictionary, + chart._yCategorySeen, + ); + } else if (ser.yCol) { + rawY = ser.yCol[i] as number; + if (isNaN(rawY)) { + continue; + } + } else { continue; } + let rawX: number; + if (chart._xIsString) { + rawX = lookupCategorySlot( + ser.xColData, + i, + chart._xCategoryDictionary, + chart._xCategorySeen, + ); + } else if (ser.xCol) { + rawX = ser.xCol[i] as number; + if (isNaN(rawX)) { + continue; + } + } else { + rawX = startRow + i; + } + // Project raw (x, y) → data-space (x, y). Default is // identity for cartesian charts; map subclasses override // to apply Mercator. Second NaN guard catches projection diff --git a/packages/viewer-charts/src/ts/charts/cartesian/cartesian-render.ts b/packages/viewer-charts/src/ts/charts/cartesian/cartesian-render.ts index 6a240cdc2f..1a7b5f9840 100644 --- a/packages/viewer-charts/src/ts/charts/cartesian/cartesian-render.ts +++ b/packages/viewer-charts/src/ts/charts/cartesian/cartesian-render.ts @@ -34,7 +34,6 @@ import { renderCanvasTooltip } from "../../interaction/tooltip-controller"; import { computeTicks, renderGridlines, - renderAxesChrome, renderCellXAxis, renderCellYAxis, renderOuterXAxis, @@ -42,6 +41,14 @@ import { type AxisDomain, } from "../../axis/numeric-axis"; import { initCanvas, getScaledContext } from "../../axis/canvas"; +import { + type CategoricalDomain, + type CategoricalLevel, + measureCategoricalAxisHeight, + measureCategoricalAxisWidth, + renderCategoricalXTicks, + renderCategoricalYTicks, +} from "../../axis/categorical-axis"; import { renderLegend, renderLegendAt, @@ -240,6 +247,88 @@ function buildYDomain( }; } +/** + * Wrap a value-axis dictionary into the single-level `CategoricalDomain` + * the categorical axis painter expects. Caches by reference identity on + * the chart so chrome-overlay redraws on hover don't rebuild the domain. + */ +function buildCategoricalDomainFromDict( + dictionary: string[], + label: string, +): CategoricalDomain { + let maxLabelChars = 0; + for (const s of dictionary) { + if (s.length > maxLabelChars) { + maxLabelChars = s.length; + } + } + + const level: CategoricalLevel = { + labels: dictionary.slice(), + runs: [], + maxLabelChars, + }; + return { + levels: [level], + numRows: dictionary.length, + levelLabels: [label], + }; +} + +/** + * Dispatch each axis side to the categorical painter when its source + * column is post-aggregation `string`-typed, or to the numeric painter + * otherwise. Used by both single-plot and faceted (per-cell) chrome + * overlays. + */ +function renderCartesianCellAxes( + chart: CartesianChart, + canvas: Canvas2D, + layout: PlotLayout, + xDomain: AxisDomain, + yDomain: AxisDomain, + xTicks: number[], + yTicks: number[], + theme: Theme, + dpr: number, +): void { + if (chart._xIsString && chart._xCategoryDomain) { + const ctx = getScaledContext(canvas, dpr); + if (ctx) { + renderCategoricalXTicks(ctx, layout, chart._xCategoryDomain, theme); + } + } else { + renderCellXAxis( + canvas, + xDomain, + layout, + xTicks, + theme, + true, + dpr, + chart.getColumnFormatter(chart._xName, "tick"), + ); + } + + if (chart._yIsString && chart._yCategoryDomain) { + const ctx = getScaledContext(canvas, dpr); + if (ctx) { + renderCategoricalYTicks(ctx, layout, chart._yCategoryDomain, theme); + } + } else { + renderCellYAxis( + canvas, + yDomain, + layout, + yTicks, + theme, + true, + dpr, + chart.getColumnFormatter(chart._yName, "tick"), + ); + } +} + /** * Original single-plot render path — all series drawn into one * `PlotLayout` with one projection matrix. Used when splits are absent @@ -255,10 +344,42 @@ function renderSinglePlotFrame( const gl = glManager.gl; const { cssWidth, cssHeight, xIsDate, yIsDate, hasColorCol } = ctx; + // Materialize per-axis categorical domains from the build-pass + // dictionaries before measuring gutters — the leaf-rotation budget + // in `measureCategoricalAxisHeight` depends on `maxLabelChars`. + chart._xCategoryDomain = + chart._xIsString && chart._xCategoryDictionary.length > 0 + ? buildCategoricalDomainFromDict( + chart._xCategoryDictionary, + chart._xLabel || chart._xName || "", + ) + : null; + chart._yCategoryDomain = + chart._yIsString && chart._yCategoryDictionary.length > 0 + ? buildCategoricalDomainFromDict( + chart._yCategoryDictionary, + chart._yLabel || chart._yName || "", + ) + : null; + + // One-pass plot-width / plot-height estimate to size the + // categorical gutter overrides; same approach as series-render. + const estRight = hasColorCol ? 80 : 16; + const estLeftPlain = 55 + (chart._yLabel ? 16 : 0); + const estPlotWidth = Math.max(1, cssWidth - estLeftPlain - estRight); + const leftExtra = chart._yCategoryDomain + ? measureCategoricalAxisWidth(chart._yCategoryDomain) + : undefined; + const bottomExtra = chart._xCategoryDomain + ? measureCategoricalAxisHeight(chart._xCategoryDomain, estPlotWidth) + : undefined; + const layout = new PlotLayout(cssWidth, cssHeight, { hasXLabel: !!chart._xLabel, hasYLabel: !!chart._yLabel, hasLegend: hasColorCol, + leftExtra, + bottomExtra, }); chart._lastLayout = layout; if (chart._zoomController) { @@ -279,7 +400,9 @@ function renderSinglePlotFrame( const xDomain = buildXDomain(chart, domain.xMin, domain.xMax, xIsDate); const yDomain = buildYDomain(chart, domain.yMin, domain.yMax, yIsDate); - const { xTicks, yTicks } = computeTicks(xDomain, yDomain, layout); + const numericTicks = computeTicks(xDomain, yDomain, layout); + const xTicks = chart._xIsString ? [] : numericTicks.xTicks; + const yTicks = chart._yIsString ? [] : numericTicks.yTicks; const isMap = chart._renderMode === "map"; @@ -349,6 +472,26 @@ function renderFacetedFrame( const gl = glManager.gl; const { cssWidth, cssHeight, xIsDate, yIsDate } = ctx; + // Materialize per-axis categorical domains (shared across facets: + // the build dictionary is global, so slot N refers to the same + // string in every cell). Faceted layout still uses the default + // per-cell gutters from `buildFacetGrid` — rotated leaf labels in + // tight cells may overflow but won't crash. + chart._xCategoryDomain = + chart._xIsString && chart._xCategoryDictionary.length > 0 + ? buildCategoricalDomainFromDict( + chart._xCategoryDictionary, + chart._xLabel || chart._xName || "", + ) + : null; + chart._yCategoryDomain = + chart._yIsString && chart._yCategoryDictionary.length > 0 + ? buildCategoricalDomainFromDict( + chart._yCategoryDictionary, + chart._yLabel || chart._yName || "", + ) + : null; + const labels = chart._splitGroups.map((g) => g.prefix); // Legend: reserve space only when the user wired a color column. @@ -415,11 +558,15 @@ function renderFacetedFrame( // Gridlines + per-facet axes use the first cell's layout for tick // sampling (all cells have identical plotRect dimensions). Per-facet - // rendering then reuses the same tick arrays. + // rendering then reuses the same tick arrays. Categorical sides + // skip numeric tick computation; the categorical painter handles + // its own label placement off the dictionary. const sampleLayout = grid.cells[0]?.layout; - const { xTicks, yTicks } = sampleLayout + const numericFacetTicks = sampleLayout ? computeTicks(xDomain, yDomain, sampleLayout) : { xTicks: [], yTicks: [] }; + const xTicks = chart._xIsString ? [] : numericFacetTicks.xTicks; + const yTicks = chart._yIsString ? [] : numericFacetTicks.yTicks; // One-shot destructive prep for the gridline + WebGL canvases. // Both phases below are per-facet; calling their destructive @@ -566,17 +713,16 @@ function renderSinglePlotChromeOverlay(chart: CartesianChart): void { if (isMap) { chart.renderMapChrome(chart._chromeCanvas!, layout, theme, dpr); } else { - renderAxesChrome( + renderCartesianCellAxes( + chart, chart._chromeCanvas!, + layout, chart._lastXDomain!, chart._lastYDomain!, - layout, chart._lastXTicks!, chart._lastYTicks!, theme, dpr, - chart.getColumnFormatter(chart._xName, "tick"), - chart.getColumnFormatter(chart._yName, "tick"), ); } @@ -633,8 +779,13 @@ function renderFacetedChromeOverlay(chart: CartesianChart): void { // — these already fold in the independent-zoom override (outer // axes are incompatible with per-cell viewports), so `sharedX` / // `sharedY` true here implies shared-zoom too. - const sharedX = chart._lastEffectiveSharedX; - const sharedY = chart._lastEffectiveSharedY; + // + // Categorical axes additionally force per-cell rendering: the + // outer-axis painter is numeric-only and the shared dictionary + // already produces the same labels in every cell, so a per-cell + // categorical axis is equivalent to a shared one visually. + const sharedX = chart._lastEffectiveSharedX && !chart._xIsString; + const sharedY = chart._lastEffectiveSharedY && !chart._yIsString; const independent = chart._facetConfig.zoom_mode === "independent"; // Shared X axis: one outer band across the bottom of the grid, @@ -689,29 +840,53 @@ function renderFacetedChromeOverlay(chart: CartesianChart): void { : { xTicks: sharedXTicks, yTicks: sharedYTicks }; if (!isMap && !sharedX) { - renderCellXAxis( - canvas, - localX, - cell.layout, - ticks.xTicks, - theme, - !!chart._xLabel, - dpr, - chart.getColumnFormatter(chart._xName, "tick"), - ); + if (chart._xIsString && chart._xCategoryDomain) { + const cellCtx = getScaledContext(canvas, dpr); + if (cellCtx) { + renderCategoricalXTicks( + cellCtx, + cell.layout, + chart._xCategoryDomain, + theme, + ); + } + } else { + renderCellXAxis( + canvas, + localX, + cell.layout, + ticks.xTicks, + theme, + !!chart._xLabel, + dpr, + chart.getColumnFormatter(chart._xName, "tick"), + ); + } } if (!isMap && !sharedY) { - renderCellYAxis( - canvas, - localY, - cell.layout, - ticks.yTicks, - theme, - !!chart._yLabel, - dpr, - chart.getColumnFormatter(chart._yName, "tick"), - ); + if (chart._yIsString && chart._yCategoryDomain) { + const cellCtx = getScaledContext(canvas, dpr); + if (cellCtx) { + renderCategoricalYTicks( + cellCtx, + cell.layout, + chart._yCategoryDomain, + theme, + ); + } + } else { + renderCellYAxis( + canvas, + localY, + cell.layout, + ticks.yTicks, + theme, + !!chart._yLabel, + dpr, + chart.getColumnFormatter(chart._yName, "tick"), + ); + } } if (cell.titleRect) { diff --git a/packages/viewer-charts/src/ts/charts/cartesian/cartesian.ts b/packages/viewer-charts/src/ts/charts/cartesian/cartesian.ts index e58bc29a98..6db0452e32 100644 --- a/packages/viewer-charts/src/ts/charts/cartesian/cartesian.ts +++ b/packages/viewer-charts/src/ts/charts/cartesian/cartesian.ts @@ -16,6 +16,7 @@ import { AbstractChart } from "../chart-base"; import { SpatialHitTester } from "../../interaction/hit-test"; import { PlotLayout } from "../../layout/plot-layout"; import { type AxisDomain } from "../../axis/numeric-axis"; +import type { CategoricalDomain } from "../../axis/categorical-axis"; import type { GradientTextureCache } from "../../webgl/gradient-texture"; import type { Glyph } from "./glyph"; import { @@ -146,6 +147,32 @@ export class CartesianChart extends AbstractChart { _sizeName = ""; _labelName = ""; _colorIsString = false; + + /** + * When the X (or Y) axis source column is post-aggregation + * `string`-typed, the build pipeline writes per-row dictionary slot + * indices into `_xData` (or `_yData`) instead of numeric values, + * and the render pass dispatches `renderCategoricalXTicks` / + * `renderCategoricalYTicks` instead of the numeric axis painter. + * + * The companion `_xCategoryDictionary` / `_xCategorySeen` pair is + * built lazily during `processCartesianChunk` in first-seen row + * order; `(null)` is appended on first encounter of an invalid row + * rather than reserved at slot 0, so charts without missing values + * don't get a phantom slot. + * + * `_xCategoryDomain` is materialized once per frame in + * `cartesian-render` and held for chrome overlay redraws (same + * lifecycle as `_lastXDomain` on the numeric path). + */ + _xIsString = false; + _yIsString = false; + _xCategoryDictionary: string[] = []; + _yCategoryDictionary: string[] = []; + _xCategorySeen: Map = new Map(); + _yCategorySeen: Map = new Map(); + _xCategoryDomain: CategoricalDomain | null = null; + _yCategoryDomain: CategoricalDomain | null = null; _splitGroups: SplitGroup[] = []; // Data extents @@ -375,11 +402,23 @@ export class CartesianChart extends AbstractChart { // (the prior accumulator); the union is in `_xMin` etc., so we // copy it back. Idempotent across multi-chunk uploads — every // chunk leaves the accumulator equal to the running union. + // + // Categorical axes opt out: slot indices are first-seen-order + // and only meaningful within a single frame's dictionary, so + // expanding across frames would mix dictionaries and shift + // every category's slot. Force-fit those axes per frame + // instead. if (this._pluginConfig.domain_mode === "expand") { - this._expandedXMin = this._xMin; - this._expandedXMax = this._xMax; - this._expandedYMin = this._yMin; - this._expandedYMax = this._yMax; + if (!this._xIsString) { + this._expandedXMin = this._xMin; + this._expandedXMax = this._xMax; + } + + if (!this._yIsString) { + this._expandedYMin = this._yMin; + this._expandedYMax = this._yMax; + } + this._expandedColorMin = this._colorMin; this._expandedColorMax = this._colorMax; this._expandedSizeMin = this._sizeMin; diff --git a/packages/viewer-charts/src/ts/charts/cartesian/glyphs/density.ts b/packages/viewer-charts/src/ts/charts/cartesian/glyphs/density.ts index 9cc77dcc26..7a5506bfc2 100644 --- a/packages/viewer-charts/src/ts/charts/cartesian/glyphs/density.ts +++ b/packages/viewer-charts/src/ts/charts/cartesian/glyphs/density.ts @@ -15,6 +15,7 @@ import type { CartesianChart } from "../cartesian"; import type { Glyph } from "../glyph"; import { bindGradientTexture } from "../../../webgl/gradient-texture"; import { getInstancing } from "../../../webgl/instanced-attrs"; +import { compileProgram } from "../../../webgl/program-cache"; import { buildPointRowTooltipLines } from "../tooltip-lines"; import splatVert from "../../../shaders/density-splat.vert.glsl"; import splatFrag from "../../../shaders/density-splat.frag.glsl"; @@ -183,16 +184,6 @@ export class DensityGlyph implements Glyph { } const gl = glManager.gl; - const splatProgram = glManager.shaders.getOrCreate( - "density-splat", - splatVert, - splatFrag, - ); - const resolveProgram = glManager.shaders.getOrCreate( - "density-resolve", - resolveVert, - resolveFrag, - ); const quadCornerBuffer = gl.createBuffer()!; gl.bindBuffer(gl.ARRAY_BUFFER, quadCornerBuffer); @@ -217,24 +208,28 @@ export class DensityGlyph implements Glyph { const heatFramebuffer = gl.createFramebuffer()!; this._cache = { - splat: extractSplatLocations(gl, splatProgram), + splat: compileSplatProgram( + glManager, + "density-splat", + splatVert, + splatFrag, + ), extremeSplat: null, mrtSplat: null, - resolve: { - program: resolveProgram, - u_heat: gl.getUniformLocation(resolveProgram, "u_heat"), - u_extreme: gl.getUniformLocation(resolveProgram, "u_extreme"), - u_gradient_lut: gl.getUniformLocation( - resolveProgram, + resolve: compileProgram( + glManager, + "density-resolve", + resolveVert, + resolveFrag, + [ + "u_heat", + "u_extreme", "u_gradient_lut", - ), - u_heat_max: gl.getUniformLocation(resolveProgram, "u_heat_max"), - u_color_mode: gl.getUniformLocation( - resolveProgram, + "u_heat_max", "u_color_mode", - ), - a_corner: gl.getAttribLocation(resolveProgram, "a_corner"), - }, + ], + ["a_corner"], + ), quadCornerBuffer, tripleCornerBuffer, heatTexture, @@ -541,12 +536,12 @@ export class DensityGlyph implements Glyph { return cache.extremeSplat; } - const program = glManager.shaders.getOrCreate( + cache.extremeSplat = compileSplatProgram( + glManager, "density-extreme", splatVert, extremeFrag, ); - cache.extremeSplat = extractSplatLocations(glManager.gl, program); return cache.extremeSplat; } @@ -568,12 +563,12 @@ export class DensityGlyph implements Glyph { // the legacy GLSL 100 splat vert can't link against it because // a program's shaders must share a version. Use the paired // `density-mrt.vert.glsl` instead — same math, 300 ES dialect. - const program = glManager.shaders.getOrCreate( + cache.mrtSplat = compileSplatProgram( + glManager, "density-mrt", mrtVert, mrtFrag, ); - cache.mrtSplat = extractSplatLocations(glManager.gl, program); return cache.mrtSplat; } @@ -1088,20 +1083,20 @@ function createAccumTexture( return tex; } -function extractSplatLocations( - gl: WebGL2RenderingContext | WebGLRenderingContext, - program: WebGLProgram, +function compileSplatProgram( + glManager: WebGLContextManager, + key: string, + vert: string, + frag: string, ): SplatProgramCache { - return { - program, - u_projection: gl.getUniformLocation(program, "u_projection"), - u_radius_ndc: gl.getUniformLocation(program, "u_radius_ndc"), - u_intensity: gl.getUniformLocation(program, "u_intensity"), - u_color_range: gl.getUniformLocation(program, "u_color_range"), - a_corner: gl.getAttribLocation(program, "a_corner"), - a_position: gl.getAttribLocation(program, "a_position"), - a_color_value: gl.getAttribLocation(program, "a_color_value"), - }; + return compileProgram( + glManager, + key, + vert, + frag, + ["u_projection", "u_radius_ndc", "u_intensity", "u_color_range"], + ["a_corner", "a_position", "a_color_value"], + ); } /** diff --git a/packages/viewer-charts/src/ts/charts/cartesian/glyphs/lines.ts b/packages/viewer-charts/src/ts/charts/cartesian/glyphs/lines.ts index 69eb6913da..3110df08c3 100644 --- a/packages/viewer-charts/src/ts/charts/cartesian/glyphs/lines.ts +++ b/packages/viewer-charts/src/ts/charts/cartesian/glyphs/lines.ts @@ -18,6 +18,7 @@ import { createLineCornerBuffer, getInstancing, } from "../../../webgl/instanced-attrs"; +import { compileProgram } from "../../../webgl/program-cache"; import { formatTickValue, formatDateTickValue } from "../../../layout/ticks"; import lineVert from "../../../shaders/line.vert.glsl"; import lineFrag from "../../../shaders/line.frag.glsl"; @@ -56,26 +57,23 @@ export class LineGlyph implements Glyph { return; } - const gl = glManager.gl; - const program = glManager.shaders.getOrCreate( + const partial = compileProgram>( + glManager, "line", lineVert, lineFrag, + [ + "u_projection", + "u_resolution", + "u_line_width", + "u_color_range", + "u_gradient_lut", + ], + ["a_start", "a_end", "a_color_start", "a_color_end", "a_corner"], ); - const cornerBuffer = createLineCornerBuffer(gl); this._cache = { - program, - cornerBuffer, - u_projection: gl.getUniformLocation(program, "u_projection"), - u_resolution: gl.getUniformLocation(program, "u_resolution"), - u_line_width: gl.getUniformLocation(program, "u_line_width"), - u_color_range: gl.getUniformLocation(program, "u_color_range"), - u_gradient_lut: gl.getUniformLocation(program, "u_gradient_lut"), - a_start: gl.getAttribLocation(program, "a_start"), - a_end: gl.getAttribLocation(program, "a_end"), - a_color_start: gl.getAttribLocation(program, "a_color_start"), - a_color_end: gl.getAttribLocation(program, "a_color_end"), - a_corner: gl.getAttribLocation(program, "a_corner"), + ...partial, + cornerBuffer: createLineCornerBuffer(glManager.gl), }; } diff --git a/packages/viewer-charts/src/ts/charts/cartesian/glyphs/points.ts b/packages/viewer-charts/src/ts/charts/cartesian/glyphs/points.ts index 16352b4655..74b2f6fa68 100644 --- a/packages/viewer-charts/src/ts/charts/cartesian/glyphs/points.ts +++ b/packages/viewer-charts/src/ts/charts/cartesian/glyphs/points.ts @@ -14,6 +14,7 @@ import type { WebGLContextManager } from "../../../webgl/context-manager"; import type { CartesianChart } from "../cartesian"; import type { Glyph } from "../glyph"; import { bindGradientTexture } from "../../../webgl/gradient-texture"; +import { compileProgram } from "../../../webgl/program-cache"; import { buildPointRowTooltipLines } from "../tooltip-lines"; import scatterVert from "../../../shaders/scatter.vert.glsl"; import scatterFrag from "../../../shaders/scatter.frag.glsl"; @@ -53,27 +54,21 @@ export class PointGlyph implements Glyph { return; } - const gl = glManager.gl; - const program = glManager.shaders.getOrCreate( + this._cache = compileProgram( + glManager, "scatter", scatterVert, scatterFrag, - ); - this._cache = { - program, - u_projection: gl.getUniformLocation(program, "u_projection"), - u_point_size: gl.getUniformLocation(program, "u_point_size"), - u_color_range: gl.getUniformLocation(program, "u_color_range"), - u_gradient_lut: gl.getUniformLocation(program, "u_gradient_lut"), - u_size_range: gl.getUniformLocation(program, "u_size_range"), - u_point_size_range: gl.getUniformLocation( - program, + [ + "u_projection", + "u_point_size", + "u_color_range", + "u_gradient_lut", + "u_size_range", "u_point_size_range", - ), - a_position: gl.getAttribLocation(program, "a_position"), - a_color_value: gl.getAttribLocation(program, "a_color_value"), - a_size_value: gl.getAttribLocation(program, "a_size_value"), - }; + ], + ["a_position", "a_color_value", "a_size_value"], + ); } draw( diff --git a/packages/viewer-charts/src/ts/charts/chart.ts b/packages/viewer-charts/src/ts/charts/chart.ts index a585db5450..74ba7ed224 100644 --- a/packages/viewer-charts/src/ts/charts/chart.ts +++ b/packages/viewer-charts/src/ts/charts/chart.ts @@ -411,7 +411,7 @@ export const DEFAULT_PLUGIN_CONFIG: PluginConfig = { facet_zoom_mode: "shared", series_zoom_mode: "dynamic", include_zero: false, - domain_mode: "fit", + domain_mode: "expand", line_width_px: 2.0, point_size_px: 8.0, band_inner_frac: 0.5, diff --git a/packages/viewer-charts/src/ts/charts/common/category-axis-resolver.ts b/packages/viewer-charts/src/ts/charts/common/category-axis-resolver.ts index 4ac5cac818..a4d449b489 100644 --- a/packages/viewer-charts/src/ts/charts/common/category-axis-resolver.ts +++ b/packages/viewer-charts/src/ts/charts/common/category-axis-resolver.ts @@ -11,7 +11,10 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import type { ColumnDataMap, ColumnData } from "../../data/view-reader"; -import type { CategoricalLevel } from "../../axis/categorical-axis"; +import type { + CategoricalDomain, + CategoricalLevel, +} from "../../axis/categorical-axis"; import { buildGroupRuns } from "../../axis/categorical-axis-core"; import { formatTickValue, formatDateTickValue } from "../../layout/ticks"; @@ -312,3 +315,134 @@ export function resolveCategoryAxis( return { rowPaths, numCategories, rowOffset }; } + +export interface ValueCategoryColumn { + /** + * Source aggregate column name; used only for the axis label fallback. + */ + name: string; + + /** + * Post-aggregation perspective type string from `chart._columnTypes` + * (`"string"` is what triggers categorical mode). + */ + type: string; + + /** + * The actual `ColumnData` from the view. May be undefined when the + * caller couldn't resolve the column (treated as all-null). + */ + data: ColumnData | undefined; +} + +export interface ValueCategoryDomain { + /** + * Single-level `CategoricalDomain` shared across all input columns. + * `levels[0].labels` is the dictionary in slot order. + */ + domain: CategoricalDomain; + + /** + * Per-column slot-index buffers. Length === `numCategories`. + * Indexed in the same order as the input `columns` array. + */ + perColumnSlots: Int32Array[]; +} + +/** + * Build a single shared categorical domain across one or more aggregate + * columns that land on the same axis side (primary or alt). Implements + * the "all-or-nothing per axis side" rule: returns `null` (= caller stays + * numeric) when any column is non-string; otherwise returns a single- + * level domain with the dictionary built in first-seen row order plus + * per-column slot indices the build pipeline writes into its pixel/slot + * buffer. + * + * Null / invalid rows surface as a `"(null)"` slot that's lazily added + * to the dictionary on first encounter — no reserved slot 0 when the + * data has no missing values. + */ +export function resolveValueCategoryDomain( + columns: ValueCategoryColumn[], + numRows: number, + rowOffset: number, + axisLabel: string, +): ValueCategoryDomain | null { + if (columns.length === 0) { + return null; + } + + for (const c of columns) { + if (c.type !== "string") { + return null; + } + } + + const numCategories = Math.max(0, numRows - rowOffset); + const dictionary: string[] = []; + const seen = new Map(); + const perColumnSlots: Int32Array[] = columns.map( + () => new Int32Array(numCategories), + ); + + const slotFor = (s: string): number => { + let slot = seen.get(s); + if (slot === undefined) { + slot = dictionary.length; + dictionary.push(s); + seen.set(s, slot); + } + + return slot; + }; + + for (let ci = 0; ci < columns.length; ci++) { + const col = columns[ci].data; + const slots = perColumnSlots[ci]; + for (let r = 0; r < numCategories; r++) { + const rowIdx = r + rowOffset; + let label: string; + if (!col) { + label = "(null)"; + } else { + const valid = col.valid; + const isValid = valid + ? !!((valid[rowIdx >> 3] >> (rowIdx & 7)) & 1) + : true; + if (!isValid) { + label = "(null)"; + } else if (col.indices && col.dictionary) { + label = col.dictionary[col.indices[rowIdx]] ?? "(null)"; + } else if (col.values) { + const v = col.values[rowIdx]; + label = v == null ? "(null)" : String(v); + } else { + label = "(null)"; + } + } + + slots[r] = slotFor(label); + } + } + + let maxLabelChars = 0; + for (const s of dictionary) { + if (s.length > maxLabelChars) { + maxLabelChars = s.length; + } + } + + const level: CategoricalLevel = { + labels: dictionary.slice(), + runs: [], + maxLabelChars, + }; + + const domain: CategoricalDomain = { + levels: [level], + numRows: dictionary.length, + levelLabels: [axisLabel], + }; + + return { domain, perColumnSlots }; +} diff --git a/packages/viewer-charts/src/ts/charts/common/tree-interact.ts b/packages/viewer-charts/src/ts/charts/common/tree-interact.ts new file mode 100644 index 0000000000..2890f1817d --- /dev/null +++ b/packages/viewer-charts/src/ts/charts/common/tree-interact.ts @@ -0,0 +1,209 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { TreeChartBase } from "./tree-chart"; +import { NULL_NODE, ancestorNames } from "./node-store"; +import { rebuildBreadcrumbs } from "./tree-data"; + +/** + * Common subset of `TreemapChart` / `SunburstChart` reached by the + * shared interaction helpers — anything that lives on `TreeChartBase` + * plus the pinned/hover/facet-drill state the two charts both declare + * with identical shape but on the subclass (so we type it as an + * intersection). + */ +export type TreeInteractChart = TreeChartBase & { + _pinnedNodeId: number; + _hoveredNodeId: number; + _facetDrillRoots: Map; +}; + +/** + * Emit `perspective-click` + `perspective-global-filter selected:true` + * for a treemap/sunburst node. The path is walked via `ancestorNames` + * and split into split-by prefix + group-by levels using + * `_splitBy.length` as the boundary; faceted mode keeps the depth-0 + * ancestor as the split prefix. + */ +export async function emitTreeNodeEvent( + chart: TreeInteractChart, + nodeId: number, + kind: "leaf" | "branch", +): Promise { + const store = chart._nodeStore; + const path = ancestorNames(store, nodeId); + const isFaceted = + chart._splitBy.length > 0 && chart._facetConfig.facet_mode === "grid"; + const splitByValues: (string | null)[] = isFaceted + ? path.slice(0, chart._splitBy.length) + : []; + const groupByValues: (string | null)[] = isFaceted + ? path.slice( + chart._splitBy.length, + chart._splitBy.length + chart._groupBy.length, + ) + : path.slice(0, chart._groupBy.length); + + const rowIdx = kind === "leaf" ? (store.leafRowIdx[nodeId] ?? null) : null; + + await chart.emitClickAndSelect({ + rowIdx: rowIdx != null && rowIdx >= 0 ? rowIdx : null, + columnName: chart._sizeName, + groupByValues, + splitByValues, + }); +} + +/** + * Build tooltip lines for `nodeId`: ancestor name path + aggregate + * value + (numeric) color value + per-row tooltip columns from + * `_lazyRows` for leaves. The leaf branch awaits the source-view row + * fetch; branch nodes have no underlying row so they emit a Children + * count instead. + */ +export async function buildTreeTooltipLines( + chart: TreeInteractChart, + nodeId: number, +): Promise { + const store = chart._nodeStore; + const lines: string[] = []; + + const pathNames: string[] = []; + let p = nodeId; + while (store.parent[p] !== NULL_NODE) { + pathNames.push(store.name[p]); + p = store.parent[p]; + } + + pathNames.reverse(); + if (pathNames.length > 0) { + lines.push(pathNames.join(" › ")); + } else { + lines.push(store.name[nodeId]); + } + + const sizeFmt = chart.getColumnFormatter(chart._sizeName, "value"); + lines.push(`Value: ${sizeFmt(store.value[nodeId])}`); + + if (chart._colorName && !isNaN(store.colorValue[nodeId])) { + const colorFmt = chart.getColumnFormatter(chart._colorName, "value"); + lines.push( + `${chart._colorName}: ${colorFmt(store.colorValue[nodeId])}`, + ); + } + + const rowIdx = store.leafRowIdx[nodeId]; + const isLeaf = + store.firstChild[nodeId] === NULL_NODE && rowIdx !== NULL_NODE; + + if (isLeaf && chart._lazyRows) { + const row = await chart._lazyRows.fetchRow(rowIdx); + for (const [name, value] of row) { + if (value === null || value === undefined) { + continue; + } + + if (name === chart._colorName && !isNaN(store.colorValue[nodeId])) { + continue; + } + + if (typeof value === "number") { + lines.push( + `${name}: ${chart.getColumnFormatter(name, "value")(value)}`, + ); + } else { + lines.push(`${name}: ${value}`); + } + } + } + + if (store.firstChild[nodeId] !== NULL_NODE) { + lines.push(`Children: ${store.childCount[nodeId]}`); + } + + return lines; +} + +/** + * Pin a tooltip at the chart-supplied anchor. Lines are fetched lazily; + * the `_pinnedNodeId` check on resolve discards stale results from a + * prior pin or dismissal. + */ +export function showTreePinnedTooltip( + chart: TreeInteractChart, + nodeId: number, + anchor: { cx: number; cy: number }, + renderChromeOverlay: () => void, +): void { + chart._tooltip.dismiss(); + chart._pinnedNodeId = nodeId; + + const cssWidth = chart._glManager?.cssWidth ?? 0; + const cssHeight = chart._glManager?.cssHeight ?? 0; + + buildTreeTooltipLines(chart, nodeId).then((lines) => { + if (chart._pinnedNodeId !== nodeId) { + return; + } + + if (lines.length === 0) { + return; + } + + chart._tooltip.pin( + lines, + { px: anchor.cx, py: anchor.cy }, + { cssWidth, cssHeight }, + ); + }); + + chart._hoveredNodeId = NULL_NODE; + renderChromeOverlay(); +} + +export function dismissTreePinnedTooltip(chart: TreeInteractChart): void { + chart._tooltip.dismiss(); + chart._pinnedNodeId = NULL_NODE; +} + +/** + * Drill the clicked facet (or the whole chart in non-facet mode). + * Faceted drill walks up to the facet root (top-level child of + * `_rootId`), records the new drill node under that facet's label, and + * re-renders. + */ +export function treeDrillTo( + chart: TreeInteractChart, + nodeId: number, + renderFrame: () => void, +): void { + const store = chart._nodeStore; + if (chart._splitBy.length > 0 && chart._facetConfig.facet_mode === "grid") { + let p = nodeId; + while (p !== NULL_NODE && store.parent[p] !== chart._rootId) { + p = store.parent[p]; + } + + if (p !== NULL_NODE) { + chart._facetDrillRoots.set(store.name[p], nodeId); + } + + chart._hoveredNodeId = NULL_NODE; + renderFrame(); + return; + } + + chart._currentRootId = nodeId; + rebuildBreadcrumbs(chart, nodeId); + chart._hoveredNodeId = NULL_NODE; + renderFrame(); +} diff --git a/packages/viewer-charts/src/ts/charts/heatmap/heatmap-render.ts b/packages/viewer-charts/src/ts/charts/heatmap/heatmap-render.ts index 464090c4f0..334959c90f 100644 --- a/packages/viewer-charts/src/ts/charts/heatmap/heatmap-render.ts +++ b/packages/viewer-charts/src/ts/charts/heatmap/heatmap-render.ts @@ -20,6 +20,7 @@ import { 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 { @@ -236,21 +237,18 @@ function ensureProgram( } const gl = glManager.gl; - const program = glManager.shaders.getOrCreate( + 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 = program; - chart._locations = { - u_projection: gl.getUniformLocation(program, "u_projection"), - u_cell_inset: gl.getUniformLocation(program, "u_cell_inset"), - u_cell_size: gl.getUniformLocation(program, "u_cell_size"), - u_gradient_lut: gl.getUniformLocation(program, "u_gradient_lut"), - a_corner: gl.getAttribLocation(program, "a_corner"), - a_cell: gl.getAttribLocation(program, "a_cell"), - a_color_t: gl.getAttribLocation(program, "a_color_t"), - }; + chart._program = compiled.program; + chart._locations = compiled; const cornerBuffer = gl.createBuffer()!; gl.bindBuffer(gl.ARRAY_BUFFER, cornerBuffer); diff --git a/packages/viewer-charts/src/ts/charts/series/glyphs/draw-areas.ts b/packages/viewer-charts/src/ts/charts/series/glyphs/draw-areas.ts index 2e97f5d3c5..7a2d8608ff 100644 --- a/packages/viewer-charts/src/ts/charts/series/glyphs/draw-areas.ts +++ b/packages/viewer-charts/src/ts/charts/series/glyphs/draw-areas.ts @@ -13,6 +13,7 @@ import type { WebGLContextManager } from "../../../webgl/context-manager"; import type { SeriesChart } from "../series"; import type { SeriesInfo } from "../series-build"; +import type { InterpolateMode } from "../series-type"; import { compileProgram } from "../../../webgl/program-cache"; import areaVert from "../../../shaders/area.vert.glsl"; import areaFrag from "../../../shaders/area.frag.glsl"; @@ -144,6 +145,7 @@ export class AreaGlyph { const entries: AreaSeriesEntry[] = []; for (const s of areaSeries) { + const seriesInfo = chart._series[s.seriesId]; const strips = collectAreaStrips( s, N, @@ -155,6 +157,9 @@ export class AreaGlyph { bars.y1, positions, xOrigin, + seriesInfo.start, + seriesInfo.end, + seriesInfo.interpolateMode, ); if (strips.totalVertices === 0) { continue; @@ -254,6 +259,21 @@ interface CollectedStrips { * Reads stacked y0/y1 from the pre-built `barIndex` (cached on the * chart at data load) so this hot path doesn't rebuild the map each * call. + * + * The "present" predicate is mode-aware for the unstacked branch: + * + * - `mode = "solid"` (also coerced from `"transparent"` for area): + * Pass 2 has populated every cell in `[start, end]`, including + * leading/trailing zero-fills. Treat `c in [start, end]` as + * present and ignore `sampleValid` (the bit is still 0 at + * synthesized cells). + * - `mode = "skip"`: Pass 2 didn't run for this series. Interior + * nulls inside `[start, end]` remain and the strip must break at + * them — use `sampleValid` as the "is present" check, matching + * the pre-feature behavior. + * + * The stacked branch is unchanged: the `barIndex` lookup already + * encodes presence post-stacking. */ function collectAreaStrips( s: SeriesInfo, @@ -266,10 +286,14 @@ function collectAreaStrips( barY1: Float64Array, positions: Float64Array | null, xOrigin: number, + seriesStart: number, + seriesEnd: number, + interpolateMode: InterpolateMode, ): CollectedStrips { const scratch = ensureStripScratch(N * 4); const descriptors: AreaStrip[] = []; const seriesBase = s.seriesId * 1_000_000_000; + const trustRange = interpolateMode !== "skip"; let write = 0; let runStart = 0; @@ -288,7 +312,12 @@ function collectAreaStrips( } } else { const idx = c * S + s.seriesId; - if ((valid[idx >> 3] >> (idx & 7)) & 1) { + if (trustRange) { + if (c >= seriesStart && c <= seriesEnd) { + top = samples[idx]; + present = true; + } + } else if ((valid[idx >> 3] >> (idx & 7)) & 1) { top = samples[idx]; present = true; } diff --git a/packages/viewer-charts/src/ts/charts/series/glyphs/draw-lines.ts b/packages/viewer-charts/src/ts/charts/series/glyphs/draw-lines.ts index d35c64de13..cc99a3891a 100644 --- a/packages/viewer-charts/src/ts/charts/series/glyphs/draw-lines.ts +++ b/packages/viewer-charts/src/ts/charts/series/glyphs/draw-lines.ts @@ -19,6 +19,7 @@ import { import { compileProgram } from "../../../webgl/program-cache"; import lineVert from "../../../shaders/line-uniform.vert.glsl"; import lineFrag from "../../../shaders/line-uniform.frag.glsl"; +import type { InterpolateMode } from "../series-type"; type GL = WebGL2RenderingContext | WebGLRenderingContext; @@ -29,21 +30,12 @@ interface LineProgramCache { u_color: WebGLUniformLocation | null; u_resolution: WebGLUniformLocation | null; u_line_width: WebGLUniformLocation | null; + u_interp_alpha: WebGLUniformLocation | null; a_start: number; a_end: number; a_corner: number; -} - -interface LineRun { - /** - * Byte offset into the per-series GPU buffer at the start of this run. - */ - offsetBytes: number; - - /** - * Number of points in this run; the run draws `count - 1` segments. - */ - count: number; + a_real_start: number; + a_real_end: number; } interface LineSeriesEntry { @@ -52,14 +44,31 @@ interface LineSeriesEntry { color: [number, number, number]; /** - * GPU buffer holding `[x0,y0,x1,y1,...]` for every run in the series. + * GPU buffer holding `[x0,y0,x1,y1,...]` for cats `[start, end]`. */ gpuBuffer: WebGLBuffer; /** - * Run offsets into `gpuBuffer`. Empty when the series has no segments. + * GPU buffer of per-vertex real-flag bytes (1 = real, 0 = synthesized). + * Bound twice as `a_real_start` / `a_real_end` with overlapping + * byte offsets so the segment shader sees both endpoints' flags. + */ + gpuRealBuffer: WebGLBuffer; + + /** + * Number of points = `end - start + 1`. Series draws `count - 1` + * segments. The renderer always emits a single contiguous run; + * gap rendering for skip mode happens in the shader via + * `u_interp_alpha`. */ - runs: LineRun[]; + count: number; + + /** + * Interpolation mode for this series. Drives `u_interp_alpha` at + * draw time. Same value the build pipeline resolved via + * `resolveInterpolate`. + */ + interpolateMode: InterpolateMode; } /** @@ -82,6 +91,7 @@ interface LineBuffers { * pattern. */ let _lineScratch: Float32Array = new Float32Array(0); +let _realScratch: Uint8Array = new Uint8Array(0); function ensureLineScratch(n: number): Float32Array { if (_lineScratch.length >= n) { @@ -92,6 +102,27 @@ function ensureLineScratch(n: number): Float32Array { return _lineScratch; } +function ensureRealScratch(n: number): Uint8Array { + if (_realScratch.length >= n) { + return _realScratch; + } + + _realScratch = new Uint8Array(Math.max(n, _realScratch.length * 2)); + return _realScratch; +} + +function alphaForMode(mode: InterpolateMode): number { + if (mode === "solid") { + return 1.0; + } + + if (mode === "transparent") { + return 0.5; + } + + return 0.0; +} + /** * Line glyph for {@link SeriesChart}. Owns its program + per-series * GPU buffers privately; chart routes lifecycle through @@ -112,8 +143,14 @@ export class LineGlyph { "bar-line", lineVert, lineFrag, - ["u_projection", "u_color", "u_resolution", "u_line_width"], - ["a_start", "a_end", "a_corner"], + [ + "u_projection", + "u_color", + "u_resolution", + "u_line_width", + "u_interp_alpha", + ], + ["a_start", "a_end", "a_corner", "a_real_start", "a_real_end"], ); this._program = { ...partial, cornerBuffer }; return this._program; @@ -133,6 +170,7 @@ export class LineGlyph { const gl = chart._glManager.gl; for (const s of buf.series) { gl.deleteBuffer(s.gpuBuffer); + gl.deleteBuffer(s.gpuRealBuffer); } this._buffers = null; @@ -142,9 +180,14 @@ export class LineGlyph { * Rebuild the per-series GPU buffers for line glyphs. Called once * per data load (and once after `restyle()` because palette colors * are captured on the {@link LineSeriesEntry}). The buffer contents - * encode `[x,y]` points in run-major order; one `bufferData` per - * series. After this, every `draw` call rebinds + dispatches with - * no further uploads until the next data load. + * encode `[x,y]` points for every cat in `[start, end]`; one + * `bufferData` per series. After this, every `draw` call rebinds + + * dispatches with no further uploads until the next data load. + * + * Gap behavior at synthesized cells is handled in the shader via + * `u_interp_alpha` (set per draw based on the series' + * `interpolateMode`): `skip` → 0 (invisible segments touching a + * synthesized endpoint), `solid` → 1, `transparent` → 0.5. */ rebuildBuffers(chart: SeriesChart, glManager: WebGLContextManager): void { const lineSeries = chart._lineSeries; @@ -169,46 +212,44 @@ export class LineGlyph { const entries: LineSeriesEntry[] = []; for (const s of lineSeries) { - // Walk the per-category sample grid for this series, breaking - // into contiguous valid runs. Write directly into a pre-sized - // Float32 scratch — no boxed JS arrays, no `Float32Array.from`. - const scratch = ensureLineScratch(N * 2); - const runs: LineRun[] = []; - let write = 0; - let runStart = 0; - for (let c = 0; c < N; c++) { - const idx = c * S + s.seriesId; - const ok = (valid[idx >> 3] >> (idx & 7)) & 1; - if (ok) { - const x = positions ? positions[c] - xOrigin : c; - scratch[write++] = x; - scratch[write++] = samples[idx]; - } else if (write > runStart) { - const count = (write - runStart) / 2; - if (count >= 2) { - runs.push({ offsetBytes: runStart * 4, count }); - } - - runStart = write; - } + const seriesInfo = chart._series[s.seriesId]; + const start = seriesInfo.start; + const end = seriesInfo.end; + if (start < 0 || end < start) { + continue; } - if (write > runStart) { - const count = (write - runStart) / 2; - if (count >= 2) { - runs.push({ offsetBytes: runStart * 4, count }); - } + const count = end - start + 1; + if (count < 2) { + // A 1-point "line" has no segments to draw. + continue; } - if (runs.length === 0) { - continue; + const posScratch = ensureLineScratch(count * 2); + const realScratch = ensureRealScratch(count); + let write = 0; + for (let c = start; c <= end; c++) { + const x = positions ? positions[c] - xOrigin : c; + const idx = c * S + s.seriesId; + posScratch[write * 2] = x; + posScratch[write * 2 + 1] = samples[idx]; + realScratch[write] = (valid[idx >> 3] >> (idx & 7)) & 1; + write++; } - const buf = gl.createBuffer()!; - gl.bindBuffer(gl.ARRAY_BUFFER, buf); + const posBuf = gl.createBuffer()!; + gl.bindBuffer(gl.ARRAY_BUFFER, posBuf); gl.bufferData( gl.ARRAY_BUFFER, - scratch.subarray(0, write), + posScratch.subarray(0, write * 2), + gl.STATIC_DRAW, + ); + + const realBuf = gl.createBuffer()!; + gl.bindBuffer(gl.ARRAY_BUFFER, realBuf); + gl.bufferData( + gl.ARRAY_BUFFER, + realScratch.subarray(0, write), gl.STATIC_DRAW, ); @@ -216,8 +257,10 @@ export class LineGlyph { seriesId: s.seriesId, axis: s.axis, color: [s.color[0], s.color[1], s.color[2]], - gpuBuffer: buf, - runs, + gpuBuffer: posBuf, + gpuRealBuffer: realBuf, + count, + interpolateMode: seriesInfo.interpolateMode, }); } @@ -226,7 +269,9 @@ export class LineGlyph { /** * Bind the persistent vertex buffers and dispatch one instanced draw - * per (series, run). Skips hidden series via `_hiddenSeries`. + * per series. Skips hidden series via `_hiddenSeries`. Gap / + * transparency rendering is governed by `u_interp_alpha`, set per + * series. */ draw( chart: SeriesChart, @@ -257,14 +302,14 @@ export class LineGlyph { gl.vertexAttribPointer(cache.a_corner, 1, gl.FLOAT, false, 0, 0); setDivisor(cache.a_corner, 0); - const stride = 2 * Float32Array.BYTES_PER_ELEMENT; + const posStride = 2 * Float32Array.BYTES_PER_ELEMENT; + const realStride = Uint8Array.BYTES_PER_ELEMENT; const hidden = chart._hiddenSeries; for (const s of buf.series) { if (hidden.has(s.seriesId)) { continue; } - gl.bindBuffer(gl.ARRAY_BUFFER, s.gpuBuffer); gl.uniformMatrix4fv( cache.u_projection, false, @@ -273,35 +318,65 @@ export class LineGlyph { const color = chart._series[s.seriesId].color; gl.uniform4f(cache.u_color, color[0], color[1], color[2], 1.0); + gl.uniform1f(cache.u_interp_alpha, alphaForMode(s.interpolateMode)); gl.enableVertexAttribArray(cache.a_start); setDivisor(cache.a_start, 1); gl.enableVertexAttribArray(cache.a_end); setDivisor(cache.a_end, 1); + gl.enableVertexAttribArray(cache.a_real_start); + setDivisor(cache.a_real_start, 1); + gl.enableVertexAttribArray(cache.a_real_end); + setDivisor(cache.a_real_end, 1); - for (const run of s.runs) { - gl.vertexAttribPointer( - cache.a_start, - 2, - gl.FLOAT, - false, - stride, - run.offsetBytes, - ); - gl.vertexAttribPointer( - cache.a_end, - 2, - gl.FLOAT, - false, - stride, - run.offsetBytes + stride, - ); - drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, run.count - 1); - } + gl.bindBuffer(gl.ARRAY_BUFFER, s.gpuBuffer); + gl.vertexAttribPointer( + cache.a_start, + 2, + gl.FLOAT, + false, + posStride, + 0, + ); + gl.vertexAttribPointer( + cache.a_end, + 2, + gl.FLOAT, + false, + posStride, + posStride, + ); + + // Bind the real-flag buffer twice with offsets 0 and 1 byte + // — same overlap trick as the position buffer. `normalized + // = false` makes the byte value cast directly to float + // (0 → 0.0, 1 → 1.0) so the shader's `step(0.5, bothReal)` + // cleanly discriminates real vs synthesized endpoints. + gl.bindBuffer(gl.ARRAY_BUFFER, s.gpuRealBuffer); + gl.vertexAttribPointer( + cache.a_real_start, + 1, + gl.UNSIGNED_BYTE, + false, + realStride, + 0, + ); + gl.vertexAttribPointer( + cache.a_real_end, + 1, + gl.UNSIGNED_BYTE, + false, + realStride, + realStride, + ); + + drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, s.count - 1); } setDivisor(cache.a_start, 0); setDivisor(cache.a_end, 0); + setDivisor(cache.a_real_start, 0); + setDivisor(cache.a_real_end, 0); } destroy(chart: SeriesChart): void { diff --git a/packages/viewer-charts/src/ts/charts/series/series-build.ts b/packages/viewer-charts/src/ts/charts/series/series-build.ts index 1bc342f20e..c5332a3c64 100644 --- a/packages/viewer-charts/src/ts/charts/series/series-build.ts +++ b/packages/viewer-charts/src/ts/charts/series/series-build.ts @@ -12,21 +12,28 @@ import type { ColumnDataMap } from "../../data/view-reader"; import { buildSplitGroups } from "../../data/split-groups"; -import type { CategoricalLevel } from "../../axis/categorical-axis"; +import type { + CategoricalDomain, + CategoricalLevel, +} from "../../axis/categorical-axis"; import { resolveAxisMode, resolveCategoryAxis, resolveNumericCategoryDomain, + resolveValueCategoryDomain, type AxisMode, type NumericCategoryDomain, + type ValueCategoryColumn, } from "../common/category-axis-resolver"; import { computeSlotGeometry } from "../common/band-layout"; import { resolveChartType, resolveStack, resolveAltAxis, + resolveInterpolate, type ChartType, type ColumnChartConfig, + type InterpolateMode, } from "./series-type"; const DUAL_Y_RATIO_THRESHOLD = 50; @@ -42,6 +49,26 @@ export interface SeriesInfo { axis: 0 | 1; chartType: ChartType; stack: boolean; + + /** + * First / last category index this series contributes data to, in + * the post-Pass-2 sample grid. For line+any mode and area+solid: + * every cell in `[start, end]` has a value (real or synthesized). + * For area+skip: `[start, end]` is the real-data extent; interior + * cells with `sampleValid=0` are gaps. `start = -1` (with `end = -1`) + * means the series has no real samples — downstream skips it. + */ + start: number; + end: number; + + /** + * Resolved interpolation mode for this aggregate. The build + * pipeline reads it to decide whether Pass 2 runs for area + * (and which fills to apply); the line glyph reads it at draw + * time to set `u_interp_alpha`. Always one of the three modes; + * never the legacy boolean form. + */ + interpolateMode: InterpolateMode; } /** @@ -326,6 +353,26 @@ export interface SeriesPipelineResult { leftDomain: { min: number; max: number }; rightDomain: { min: number; max: number } | null; hasRightAxis: boolean; + + /** + * Per-axis-side value mode discriminator. `"category"` fires when + * every aggregate on that side is post-aggregation `string`-typed + * (all-or-nothing rule). Bar y0/y1 then hold dictionary slot + * indices and the chrome overlay paints a categorical axis on + * that side. `null` for the alt side when there are no series + * pinned to alt. + */ + leftValueAxisMode: "numeric" | "category"; + rightValueAxisMode: "numeric" | "category" | null; + + /** + * Single-level `CategoricalDomain` shared across every aggregate + * on the corresponding side. Set only when that side's mode is + * `"category"`; the chrome renderer in `series-render` materializes + * the side's `BarCategoryAxis` from this. + */ + leftValueCategoryDomain: CategoricalDomain | null; + rightValueCategoryDomain: CategoricalDomain | null; } function setValidBit(valid: Uint8Array, idx: number): void { @@ -381,6 +428,10 @@ export function buildSeriesPipeline( leftDomain: { min: 0, max: 0 }, rightDomain: null, hasRightAxis: false, + leftValueAxisMode: "numeric", + rightValueAxisMode: null, + leftValueCategoryDomain: null, + rightValueCategoryDomain: null, }; const aggregates = columnSlots.filter((s): s is string => !!s); @@ -438,6 +489,11 @@ export function buildSeriesPipeline( defaultChartType, ); const stack = resolveStack(aggName, chartType, columnsConfig); + const interpolateMode = resolveInterpolate( + aggName, + chartType, + columnsConfig, + ); series.push({ seriesId: k * P + p, aggIdx: k, @@ -449,6 +505,9 @@ export function buildSeriesPipeline( axis: 0, chartType, stack, + start: -1, + end: -1, + interpolateMode, }); } } @@ -539,6 +598,102 @@ export function buildSeriesPipeline( } } + // Per-aggregate string-ness flag, plus default axis side from the + // `columns_config.alt_axis` pin. `series[].axis` may still flip + // again via the auto-alt heuristic below, but we suppress that + // heuristic entirely once a string aggregate is present (numeric + // extent ratios are not defined on categorical data). + const aggIsString = new Array(M); + const defaultAxisSide = new Array(M); + let anyStringAgg = false; + for (let k = 0; k < M; k++) { + const aggName = aggregates[k]; + const splitKey = splitPrefixes[0]; + const colName = splitKey === "" ? aggName : `${splitKey}|${aggName}`; + aggIsString[k] = columns.get(colName)?.type === "string"; + defaultAxisSide[k] = resolveAltAxis(aggName, columnsConfig) ? 1 : 0; + if (aggIsString[k]) { + anyStringAgg = true; + } + } + + // Per-side categorical resolution. Apply the all-or-nothing rule: + // a side becomes categorical only if every aggregate currently + // assigned to it (by `defaultAxisSide` — the alt_axis pin) is + // string-typed. Auto-alt-axis can't re-assign across modes since + // we disable it whenever any string aggregate exists. + const primaryAggs: ValueCategoryColumn[] = []; + const altAggs: ValueCategoryColumn[] = []; + const primaryAggColIdx: number[] = []; + const altAggColIdx: number[] = []; + for (let k = 0; k < M; k++) { + const aggName = aggregates[k]; + for (let p = 0; p < P; p++) { + const splitKey = splitPrefixes[p]; + const colName = + splitKey === "" ? aggName : `${splitKey}|${aggName}`; + const colIdx = k * P + p; + const entry: ValueCategoryColumn = { + name: colName, + type: aggIsString[k] ? "string" : "numeric", + data: columns.get(colName), + }; + if (defaultAxisSide[k] === 0) { + primaryAggs.push(entry); + primaryAggColIdx.push(colIdx); + } else { + altAggs.push(entry); + altAggColIdx.push(colIdx); + } + } + } + + const primaryValueAxisLabel = primaryAggs + .map((c) => c.name) + .filter((s, i, arr) => arr.indexOf(s) === i) + .join(", "); + const altValueAxisLabel = altAggs + .map((c) => c.name) + .filter((s, i, arr) => arr.indexOf(s) === i) + .join(", "); + + const primaryCategorical = + primaryAggs.length > 0 + ? resolveValueCategoryDomain( + primaryAggs, + numRows, + rowOffset, + primaryValueAxisLabel, + ) + : null; + const altCategorical = + altAggs.length > 0 + ? resolveValueCategoryDomain( + altAggs, + numRows, + rowOffset, + altValueAxisLabel, + ) + : null; + + // Per-column slot buffers indexed in the same `colIdx = k * P + p` + // space as `colValues`. `colSlots[colIdx]` is non-null exactly when + // the side `defaultAxisSide[k]` is categorical and that side's + // resolver returned slot buffers. + const colSlots: (Int32Array | null)[] = new Array(M * P).fill(null); + if (primaryCategorical) { + for (let i = 0; i < primaryAggColIdx.length; i++) { + colSlots[primaryAggColIdx[i]] = + primaryCategorical.perColumnSlots[i]; + } + } + + if (altCategorical) { + for (let i = 0; i < altAggColIdx.length; i++) { + colSlots[altAggColIdx[i]] = altCategorical.perColumnSlots[i]; + } + } + // Pre-allocate columnar bar storage at N*M*P upper bound. The // pipeline emits at most one record per (cat, agg, split) cell; // `bars.count` tracks the active prefix. @@ -546,22 +701,32 @@ export function buildSeriesPipeline( const bars = ensureBarColumnsCapacity(scratchBars ?? null, barCap); let barWrite = 0; + // Pass 1 — populate the raw sample grid + valid bitset. Stacking + // and bar-record emission run in pass 3 (below) so that pass 2 + // can interpolate interior nulls for line/area series before the + // stack accumulator sees them; otherwise an interpolated cell in + // a stacked area would not contribute to the running y0/y1 of + // subsequent series at the same catIdx. for (let catI = 0; catI < N; catI++) { const row = catI + rowOffset; - - // Hoist the category center — same value across all (k, p) for - // the current catI. - const catCenter = categoryPositions ? categoryPositions[catI] : catI; - for (let k = 0; k < M; k++) { - const slotOffset = slotOffsets[k]; - const xCenter = catCenter + slotOffset; - const ext = aggExtents[k]; - for (let p = 0; p < P; p++) { - const seriesId = k * P + p; - const s = series[seriesId]; const colIdx = k * P + p; + + // Categorical value-axis branch: `colSlots[colIdx]` is + // a per-catI Int32Array of dictionary slot indices, with + // `(null)` already routed to its own slot — every row + // is a valid sample and stack/extent logic just runs + // against the slot integer. + const slots = colSlots[colIdx]; + if (slots) { + const seriesId = k * P + p; + const sampleIdx = catI * S + seriesId; + samples[sampleIdx] = slots[catI]; + setValidBit(sampleValid, sampleIdx); + continue; + } + const values = colValues[colIdx]; if (!values) { continue; @@ -580,11 +745,155 @@ export function buildSeriesPipeline( continue; } - // Record the raw value in the unstacked grid for every - // glyph that needs it (line, scatter, non-stacking bar/area). + const seriesId = k * P + p; const sampleIdx = catI * S + seriesId; samples[sampleIdx] = v; setValidBit(sampleValid, sampleIdx); + } + } + } + + // Compute per-series [start, end] from sampleValid (post-Pass 1). + // Drives Pass 2's interpolation range, Pass 3's stack/bar emission, + // axis-extent calc, and downstream rendering. Series with no real + // samples keep start = end = -1 and are skipped everywhere. + for (let seriesId = 0; seriesId < S; seriesId++) { + let first = -1; + let last = -1; + for (let c = 0; c < N; c++) { + const idx = c * S + seriesId; + if ((sampleValid[idx >> 3] >> (idx & 7)) & 1) { + if (first === -1) { + first = c; + } + + last = c; + } + } + + series[seriesId].start = first; + series[seriesId].end = last; + } + + // Pass 2 — synthesize values for nulls covered by interpolation. + // Writes `samples[c]` but deliberately does NOT touch `sampleValid`: + // the renderer derives "synthesized cell" from + // `c in [start, end] && sampleValid[c] === 0`, so the bit must stay + // 0 at synthesized cells. Per-series gating: + // + // - line, solid / transparent: interior linear interpolation. + // - area, solid: every synthesized cell (interior null, + // leading/trailing null) gets value 0. Stacked areas above the + // null sit on the unchanged baseline — interpolating to a + // non-zero value here would phantom-lift the upper series at + // the gap. Range collapses to [0, N-1]. + // - any series with mode = "skip": skipped (the renderer's + // [start, end] iteration treats interior nulls correctly via + // the sampleValid lookup for area, and shader alpha=0 for line). + // - other chart types: skipped. + // + // X-axis units for line interpolation match the rendering: numeric + // mode uses `categoryPositions[c]`, category mode uses the cat + // index. `samples` is freshly allocated each build (Float32Array + // zero-init), so interior null cells already hold 0 — area's + // "zero-fill interior" is implicit and needs no explicit writes + // there; only the leading/trailing range extension is written. + for (let seriesId = 0; seriesId < S; seriesId++) { + const s = series[seriesId]; + if (s.start < 0) { + continue; + } + + if (s.interpolateMode === "skip") { + continue; + } + + if (s.chartType === "line") { + let lastValid = s.start; + for (let c = s.start + 1; c <= s.end; c++) { + const idx = c * S + seriesId; + const ok = (sampleValid[idx >> 3] >> (idx & 7)) & 1; + if (!ok) { + continue; + } + + if (c - lastValid > 1) { + const startIdx = lastValid * S + seriesId; + const startV = samples[startIdx]; + const endV = samples[idx]; + const xStart = categoryPositions + ? categoryPositions[lastValid] + : lastValid; + const xEnd = categoryPositions ? categoryPositions[c] : c; + const dx = xEnd - xStart; + for (let g = 1; g < c - lastValid; g++) { + const cc = lastValid + g; + const xMid = categoryPositions + ? categoryPositions[cc] + : cc; + const t = dx === 0 ? 0 : (xMid - xStart) / dx; + samples[cc * S + seriesId] = + startV + (endV - startV) * t; + } + } + + lastValid = c; + } + } else if (s.chartType === "area") { + // Leading / trailing zero-fill. Interior nulls already sit + // at 0 from Float32Array zero-init; no per-cell write + // needed in (s.start, s.end). Range collapses to [0, N-1] + // so Pass 3 and the area glyph treat the whole span as + // renderable (continuous strip resting on the baseline at + // synthesized cells). + for (let c = 0; c < s.start; c++) { + samples[c * S + seriesId] = 0; + } + + for (let c = s.end + 1; c < N; c++) { + samples[c * S + seriesId] = 0; + } + + s.start = 0; + s.end = N - 1; + } + } + + // Pass 3 — emit stack/bar records and update per-aggregate extents + // from the (possibly synthesized) samples grid. The cell-validity + // predicate is mode-aware: for line (any mode) and area+solid, + // Pass 2 has guaranteed every cell in `[start, end]` carries a + // meaningful value (real or synthesized) — `sampleValid` is the + // "is real" mask, not the "has value" mask, so we trust the range + // alone. For area+skip and bar/scatter, fall back to the original + // per-cell `sampleValid` check. + for (let catI = 0; catI < N; catI++) { + const catCenter = categoryPositions ? categoryPositions[catI] : catI; + for (let k = 0; k < M; k++) { + const slotOffset = slotOffsets[k]; + const xCenter = catCenter + slotOffset; + const ext = aggExtents[k]; + + for (let p = 0; p < P; p++) { + const seriesId = k * P + p; + const s = series[seriesId]; + if (catI < s.start || catI > s.end) { + continue; + } + + const treatRangeAsValid = + s.chartType === "line" || + (s.chartType === "area" && s.interpolateMode !== "skip"); + const sampleIdx = catI * S + seriesId; + if (!treatRangeAsValid) { + if ( + !((sampleValid[sampleIdx >> 3] >> (sampleIdx & 7)) & 1) + ) { + continue; + } + } + + const v = samples[sampleIdx]; // Stacking-glyph path: emit a record with running y0/y1. if ( @@ -592,7 +901,25 @@ export function buildSeriesPipeline( s.stack ) { if (v === 0) { - continue; + // Non-area, or area+skip: a zero-value record + // is degenerate (zero-height bar / invisible + // strip wedge) and just costs allocation — + // drop it. + // + // Area + non-skip: keep the record so the + // stacked strip stays continuous through + // synthesized cells (interior zero-fill + + // leading / trailing zero-fill). `y1 = y0` + // makes it a zero-height vertex pair in the + // strip; posStack doesn't increment, so the + // series above stacks on the unchanged + // baseline. + if ( + s.chartType !== "area" || + s.interpolateMode === "skip" + ) { + continue; + } } const stackIdx = catI * M + k; @@ -684,7 +1011,12 @@ export function buildSeriesPipeline( bars.count = barWrite; let hasRightAxis = false; - if (autoAltYAxis && M >= 2) { + // Auto-alt-axis compares numeric magnitudes; a string aggregate + // contributes no extent and would always land on the smaller side. + // Skip the heuristic when any aggregate is string and let the + // user's explicit `columns_config.alt_axis` pin (resolved below) + // be the only axis-side override. + if (autoAltYAxis && M >= 2 && !anyStringAgg) { const extents: number[] = new Array(M); let maxExt = 0; let minExt = Infinity; @@ -784,11 +1116,20 @@ export function buildSeriesPipeline( continue; // already counted via bars } + if (s.start < 0) { + continue; + } + + const treatRangeAsValid = + s.chartType === "line" || + (s.chartType === "area" && s.interpolateMode !== "skip"); const ext = s.axis === 0 ? leftExtent : rightExtent; - for (let catI = 0; catI < N; catI++) { + for (let catI = s.start; catI <= s.end; catI++) { const sampleIdx = catI * S + seriesId; - if (!((sampleValid[sampleIdx >> 3] >> (sampleIdx & 7)) & 1)) { - continue; + if (!treatRangeAsValid) { + if (!((sampleValid[sampleIdx >> 3] >> (sampleIdx & 7)) & 1)) { + continue; + } } const v = samples[sampleIdx]; @@ -826,6 +1167,34 @@ export function buildSeriesPipeline( : rightExtent : null; + // Categorical value-axis: override the numeric extent with the + // slot-index range `[0, dictLen-1]`. The chrome renderer paints + // the dictionary; the numeric domain we surface is only used by + // the projection matrix and pixel mapping, both of which work on + // raw slot indices when `*ValueAxisMode === "category"`. + const leftValueAxisMode: "numeric" | "category" = primaryCategorical + ? "category" + : "numeric"; + const rightValueAxisMode: "numeric" | "category" | null = hasRightAxis + ? altCategorical + ? "category" + : "numeric" + : null; + const finalLeftDomain = + primaryCategorical && primaryCategorical.domain.numRows > 0 + ? { + min: 0, + max: Math.max(0, primaryCategorical.domain.numRows - 1), + } + : leftExtent; + const finalRightDomain = + altCategorical && altCategorical.domain.numRows > 0 && hasRightAxis + ? { + min: 0, + max: Math.max(0, altCategorical.domain.numRows - 1), + } + : rightDomain; + return { aggregates, splitPrefixes, @@ -841,8 +1210,12 @@ export function buildSeriesPipeline( negStack, samples, sampleValid, - leftDomain: leftExtent, - rightDomain, + leftDomain: finalLeftDomain, + rightDomain: finalRightDomain, hasRightAxis, + leftValueAxisMode, + rightValueAxisMode, + leftValueCategoryDomain: primaryCategorical?.domain ?? null, + rightValueCategoryDomain: altCategorical?.domain ?? null, }; } diff --git a/packages/viewer-charts/src/ts/charts/series/series-render.ts b/packages/viewer-charts/src/ts/charts/series/series-render.ts index 961813764b..9c1b7405bb 100644 --- a/packages/viewer-charts/src/ts/charts/series/series-render.ts +++ b/packages/viewer-charts/src/ts/charts/series/series-render.ts @@ -29,6 +29,7 @@ import { renderBarAxesChrome, renderBarGridlines, type BarCategoryAxis, + type BarValueAxis, } from "../../axis/bar-axis"; import { measureCategoricalAxisHeight, @@ -38,10 +39,7 @@ import { import { buildBarTooltipLines } from "./series-interact"; /** - * Reusable scratch for bar instance uploads. Sized lazily at the first - * use; grown on demand. Avoids `new Float32Array(n)` × 7 buffers per - * legend-toggle / data-load; size is bounded by the bar-typed subset - * of `_bars.count`. + * Reusable scratch for bar instance uploads. */ interface BarInstanceScratch { xCenters: Float32Array; @@ -78,10 +76,7 @@ function ensureBarInstanceScratch(n: number): BarInstanceScratch { } /** - * Upload bar instance buffers from the columnar `_bars` storage. Filters - * to bar-typed records only (areas draw as triangle strips). Skips - * hidden series. Re-called from data-load and legend-toggle paths; the - * scratch buffers and `_visibleBarIndices` are reused across calls. + * Upload bar instance buffers from the columnar `_bars` storage. */ export function uploadBarInstances( chart: SeriesChart, @@ -103,12 +98,6 @@ export function uploadBarInstances( const indices = chart._visibleBarIndices; // Rebase each xCenter by `_categoryOrigin` before f32 narrowing. - // For datetime numeric category axes the absolute xCenter is - // ~1.7e12 and f32 narrowing collapses adjacent bars onto the - // same value; subtracting the origin brings every value into - // the seconds range where f32 has full precision. The matching - // projection matrix is built with the same origin so the shader - // math is consistent. const xOrigin = chart._categoryOrigin; const series = chart._series; const hidden = chart._hiddenSeries; @@ -406,32 +395,58 @@ export function renderBarFrame( 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) { - // Numeric category axis on the Y side: the gutter just needs - // standard numeric tick width (~55px), no per-row label - // measurement. + // 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) { - // Numeric category axis on the X side: bottom gutter is a - // fixed numeric-axis row (~24px), no leaf-rotation measurement. + // 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 { - const estLeft = 55 + 16; + // 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( @@ -443,6 +458,7 @@ export function renderBarFrame( hasYLabel: true, hasLegend, bottomExtra, + leftExtra: valueCatActive ? leftExtraBase : undefined, }); } @@ -636,16 +652,44 @@ export function renderBarChromeOverlay(chart: SeriesChart): void { 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, - chart._lastYDomain, - chart._lastYTicks, + valueAxis, chart._lastLayout, theme, chart._glManager?.dpr ?? 1, - chart._lastAltYDomain ?? undefined, - chart._lastAltYTicks ?? undefined, + altAxis, chart._isHorizontal, { value: chart.getColumnFormatter( diff --git a/packages/viewer-charts/src/ts/charts/series/series-type.ts b/packages/viewer-charts/src/ts/charts/series/series-type.ts index 3c8f6d0b40..ec189c99c9 100644 --- a/packages/viewer-charts/src/ts/charts/series/series-type.ts +++ b/packages/viewer-charts/src/ts/charts/series/series-type.ts @@ -13,10 +13,12 @@ export type ChartType = "bar" | "line" | "scatter" | "area"; /** - * Per-column entry inside the viewer's `columns_config` map. The map itself - * is typed as `Record` at the plugin boundary because - * `columns_config` is shared across plugins; this interface documents the - * keys the Y-bar glyph router consumes. + * Per-column interpolation mode for line / area glyphs. + */ +export type InterpolateMode = "skip" | "solid" | "transparent"; + +/** + * Per-column entry inside the viewer's `columns_config` map. */ export interface ColumnChartConfig { /** @@ -25,18 +27,24 @@ export interface ColumnChartConfig { chart_type?: string; /** - * Explicit stack override. If omitted: bar / area stack by default, - * line / scatter do not. + * Explicit stack override. */ stack?: boolean; /** * Force this aggregate onto the secondary (right) Y axis, * independent of `autoAltYAxis` and the dual-axis ratio - * heuristic. Missing / false → axis assignment is driven by - * `autoAltYAxis` alone. + * heuristic. */ alt_axis?: boolean; + + /** + * Interpolation mode for line / area glyphs. See + * {@link InterpolateMode}. Legacy values `true` / `false` are also + * accepted by {@link resolveInterpolate} (mapped to `"solid"` / + * `"skip"`). Default `"skip"`. No effect on bar / scatter. + */ + interpolate?: InterpolateMode; } /** @@ -44,10 +52,6 @@ export interface ColumnChartConfig { * *base* (e.g. `"Sales"`); composite arrow columns like `"North|Sales"` * should strip the prefix before calling — the bar pipeline already * tracks aggregates as base names, so call sites pass the base directly. - * - * `fallback` is the plugin's default glyph (e.g. `"line"` for Y Line), - * supplied by the plugin element via `setDefaultChartType`. Falls back to - * `"bar"` when the plugin never set one. */ export function resolveChartType( aggName: string, @@ -69,8 +73,6 @@ export function resolveChartType( /** * Resolve whether a series stacks with its aggregate siblings. - * Default: `true` for bar/area, `false` for line/scatter. Overridable - * per column via `columns_config[aggName].stack`. */ export function resolveStack( aggName: string, @@ -87,9 +89,7 @@ export function resolveStack( /** * Resolve whether a column is pinned to the secondary Y axis via - * `columns_config[aggName].alt_axis`. Independent of `autoAltYAxis`: - * when `true`, the per-column override forces axis 1 regardless of - * the auto-split heuristic. + * `columns_config[aggName].alt_axis`. */ export function resolveAltAxis( aggName: string, @@ -97,3 +97,23 @@ export function resolveAltAxis( ): boolean { return cfg?.[aggName]?.alt_axis === true; } + +/** + * Resolve the interpolation mode for this aggregate. + */ +export function resolveInterpolate( + aggName: string, + chartType: ChartType, + cfg: Record | undefined, +): InterpolateMode { + if (chartType !== "line" && chartType !== "area") { + return "skip"; + } + + const mode = cfg?.[aggName]?.interpolate; + if (mode === undefined || chartType === "area") { + return "solid"; + } + + return mode; +} diff --git a/packages/viewer-charts/src/ts/charts/series/series.ts b/packages/viewer-charts/src/ts/charts/series/series.ts index 0c6998fa4f..cc5be6d823 100644 --- a/packages/viewer-charts/src/ts/charts/series/series.ts +++ b/packages/viewer-charts/src/ts/charts/series/series.ts @@ -16,6 +16,7 @@ import type { ZoomConfig } from "../../interaction/zoom-controller"; import { CategoricalYChart } from "../common/categorical-y-chart"; import { type PlotRect } from "../../layout/plot-layout"; import { type AxisDomain } from "../../axis/numeric-axis"; +import type { CategoricalDomain } from "../../axis/categorical-axis"; import { buildSeriesPipeline, readBarRecord, @@ -42,6 +43,7 @@ import { LineGlyph } from "./glyphs/draw-lines"; import { ScatterGlyph } from "./glyphs/draw-scatter"; import { AreaGlyph } from "./glyphs/draw-areas"; import { createQuadCornerBuffer } from "../../webgl/instanced-attrs"; +import { compileProgram } from "../../webgl/program-cache"; import { expandDomainInPlace } from "../common/expand-domain"; import barVert from "../../shaders/bar.vert.glsl"; import barFrag from "../../shaders/bar.frag.glsl"; @@ -147,6 +149,22 @@ export class SeriesChart extends CategoricalYChart { _primaryValueLabel = ""; _altValueLabel = ""; + /** + * Per-side value-axis mode. `"category"` fires when every + * aggregate on that side is post-aggregation `string`-typed + * (all-or-nothing rule, evaluated independently for primary and + * alt). When set, `_bars[].y0`/`y1` carry dictionary slot indices + * instead of numeric values, and the chrome overlay paints a + * categorical axis on that side. + * + * Read by `series-render.ts` to construct the `BarCategoryAxis` + * descriptor for the value-axis sides. + */ + _leftValueAxisMode: "numeric" | "category" = "numeric"; + _rightValueAxisMode: "numeric" | "category" | null = null; + _leftValueCategoryDomain: CategoricalDomain | null = null; + _rightValueCategoryDomain: CategoricalDomain | null = null; + /** * (seriesId * 1e9 + catIdx) → bar-record index in `_bars`. Built once * per pipeline run for area-strip lookups; rebuilt on hidden-toggle @@ -432,27 +450,32 @@ export class SeriesChart extends CategoricalYChart { } if (!this._program) { - this._program = glManager.shaders.getOrCreate( + const compiled = compileProgram< + { program: WebGLProgram } & CachedLocations + >( + glManager, "bar", barVert, barFrag, + [ + "u_proj_left", + "u_proj_right", + "u_hover_series", + "u_horizontal", + ], + [ + "a_corner", + "a_x_center", + "a_half_width", + "a_y0", + "a_y1", + "a_color", + "a_series_id", + "a_axis", + ], ); - const p = this._program; - this._locations = { - u_proj_left: gl.getUniformLocation(p, "u_proj_left"), - u_proj_right: gl.getUniformLocation(p, "u_proj_right"), - u_hover_series: gl.getUniformLocation(p, "u_hover_series"), - u_horizontal: gl.getUniformLocation(p, "u_horizontal"), - a_corner: gl.getAttribLocation(p, "a_corner"), - a_x_center: gl.getAttribLocation(p, "a_x_center"), - a_half_width: gl.getAttribLocation(p, "a_half_width"), - a_y0: gl.getAttribLocation(p, "a_y0"), - a_y1: gl.getAttribLocation(p, "a_y1"), - a_color: gl.getAttribLocation(p, "a_color"), - a_series_id: gl.getAttribLocation(p, "a_series_id"), - a_axis: gl.getAttribLocation(p, "a_axis"), - }; - + this._program = compiled.program; + this._locations = compiled; this._cornerBuffer = createQuadCornerBuffer(gl); } @@ -585,6 +608,10 @@ export class SeriesChart extends CategoricalYChart { this._leftDomain = result.leftDomain; this._rightDomain = result.rightDomain; this._hasRightAxis = result.hasRightAxis; + this._leftValueAxisMode = result.leftValueAxisMode; + this._rightValueAxisMode = result.rightValueAxisMode; + this._leftValueCategoryDomain = result.leftValueCategoryDomain; + this._rightValueCategoryDomain = result.rightValueCategoryDomain; // Resolve the palette eagerly. Both `uploadBarInstances` (color // attribute) and `rebuildGlyphBuffers` (per-series RGB capture) diff --git a/packages/viewer-charts/src/ts/charts/sunburst/sunburst-interact.ts b/packages/viewer-charts/src/ts/charts/sunburst/sunburst-interact.ts index ee7fd7d4eb..fa18387b7c 100644 --- a/packages/viewer-charts/src/ts/charts/sunburst/sunburst-interact.ts +++ b/packages/viewer-charts/src/ts/charts/sunburst/sunburst-interact.ts @@ -11,13 +11,19 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import type { SunburstChart } from "./sunburst"; -import { NULL_NODE, ancestorNames } from "../common/node-store"; -import { rebuildBreadcrumbs } from "../common/tree-data"; +import { NULL_NODE } from "../common/node-store"; import { renderSunburstFrame, renderSunburstChromeOverlay, facetCenterForNode, } from "./sunburst-render"; +import { + buildTreeTooltipLines, + dismissTreePinnedTooltip, + emitTreeNodeEvent, + showTreePinnedTooltip, + treeDrillTo, +} from "../common/tree-interact"; export type { BreadcrumbRegion as SunburstBreadcrumbRegion } from "../common/tree-chrome"; @@ -194,7 +200,7 @@ export function handleSunburstHover( chart._hoveredNodeId = hit; if (hit !== NULL_NODE) { const serial = chart._lazyTooltip.beginHover(hit); - buildSunburstTooltipLines(chart, hit).then((lines) => { + buildTreeTooltipLines(chart, hit).then((lines) => { if (chart._lazyTooltip.commitHover(serial, lines)) { renderSunburstChromeOverlay(chart); } @@ -273,188 +279,38 @@ export function handleSunburstClick( if (store.firstChild[hit] !== NULL_NODE) { drillTo(chart, hit); - void emitSunburstNodeEvent(chart, hit, "branch"); + void emitTreeNodeEvent(chart, hit, "branch"); } else { showSunburstPinnedTooltip(chart, hit); - void emitSunburstNodeEvent(chart, hit, "leaf"); + void emitTreeNodeEvent(chart, hit, "leaf"); } } -/** - * Counterpart to `emitTreemapNodeEvent` for sunburst. Same path-walk - * semantics — split-by prefix in faceted mode, group-by levels - * afterward, leaf row idx from `_nodeStore.leafRowIdx`. - */ -async function emitSunburstNodeEvent( - chart: SunburstChart, - nodeId: number, - kind: "leaf" | "branch", -): Promise { - const store = chart._nodeStore; - const path = ancestorNames(store, nodeId); - const isFaceted = - chart._splitBy.length > 0 && chart._facetConfig.facet_mode === "grid"; - const splitByValues: (string | null)[] = isFaceted - ? path.slice(0, chart._splitBy.length) - : []; - const groupByValues: (string | null)[] = isFaceted - ? path.slice( - chart._splitBy.length, - chart._splitBy.length + chart._groupBy.length, - ) - : path.slice(0, chart._groupBy.length); - - const rowIdx = kind === "leaf" ? (store.leafRowIdx[nodeId] ?? null) : null; - - await chart.emitClickAndSelect({ - rowIdx: rowIdx != null && rowIdx >= 0 ? rowIdx : null, - columnName: chart._sizeName, - groupByValues, - splitByValues, - }); -} - -/** - * Drill the clicked facet (or the whole chart in non-facet mode). - * Faceted drill walks up to the facet root (top-level child of - * `_rootId`), records the new drill node under that facet's label, - * and re-renders. - */ function drillTo(chart: SunburstChart, nodeId: number): void { - const store = chart._nodeStore; - if (chart._splitBy.length > 0 && chart._facetConfig.facet_mode === "grid") { - let p = nodeId; - while (p !== NULL_NODE && store.parent[p] !== chart._rootId) { - p = store.parent[p]; - } - - if (p !== NULL_NODE) { - chart._facetDrillRoots.set(store.name[p], nodeId); - } - - chart._hoveredNodeId = NULL_NODE; + treeDrillTo(chart, nodeId, () => { if (chart._glManager) { renderSunburstFrame(chart, chart._glManager); } - - return; - } - - chart._currentRootId = nodeId; - rebuildBreadcrumbs(chart, nodeId); - chart._hoveredNodeId = NULL_NODE; - if (chart._glManager) { - renderSunburstFrame(chart, chart._glManager); - } + }); } export function showSunburstPinnedTooltip( chart: SunburstChart, nodeId: number, ): void { - chart._tooltip.dismiss(); - chart._pinnedNodeId = nodeId; - const store = chart._nodeStore; const midA = (store.a0[nodeId] + store.a1[nodeId]) / 2; const midR = (store.r0[nodeId] + store.r1[nodeId]) / 2; const { centerX, centerY } = facetCenterForNode(chart, nodeId); const cx = centerX + Math.cos(midA) * midR; const cy = centerY + Math.sin(midA) * midR; - - // CSS bounds: prefer `glManager` (works in both local and worker - // modes, since the worker constructs its own context manager). - const cssWidth = chart._glManager?.cssWidth ?? 0; - const cssHeight = chart._glManager?.cssHeight ?? 0; - - // Tooltip columns are fetched lazily from the view — the tree - // itself only retains ancestor names + aggregated value + color. - // Stale resolutions are discarded via the `_pinnedNodeId` check. - buildSunburstTooltipLines(chart, nodeId).then((lines) => { - if (chart._pinnedNodeId !== nodeId) { - return; - } - - if (lines.length === 0) { - return; - } - - chart._tooltip.pin(lines, { px: cx, py: cy }, { cssWidth, cssHeight }); - }); - - chart._hoveredNodeId = NULL_NODE; - renderSunburstChromeOverlay(chart); + showTreePinnedTooltip(chart, nodeId, { cx, cy }, () => + renderSunburstChromeOverlay(chart), + ); } export function dismissSunburstPinnedTooltip(chart: SunburstChart): void { - chart._tooltip.dismiss(); - chart._pinnedNodeId = NULL_NODE; + dismissTreePinnedTooltip(chart); } -export async function buildSunburstTooltipLines( - chart: SunburstChart, - nodeId: number, -): Promise { - const store = chart._nodeStore; - const lines: string[] = []; - - // Ancestor path. - const pathNames: string[] = []; - let p = nodeId; - while (store.parent[p] !== NULL_NODE) { - pathNames.push(store.name[p]); - p = store.parent[p]; - } - - pathNames.reverse(); - if (pathNames.length > 0) { - lines.push(pathNames.join(" › ")); - } else { - lines.push(store.name[nodeId]); - } - - const sizeFmt = chart.getColumnFormatter(chart._sizeName, "value"); - lines.push(`Value: ${sizeFmt(store.value[nodeId])}`); - - // Color value (numeric branch): stored on the node at insert - // time, so it's always available without a view fetch. - if (chart._colorName && !isNaN(store.colorValue[nodeId])) { - const colorFmt = chart.getColumnFormatter(chart._colorName, "value"); - lines.push( - `${chart._colorName}: ${colorFmt(store.colorValue[nodeId])}`, - ); - } - - const rowIdx = store.leafRowIdx[nodeId]; - const isLeaf = - store.firstChild[nodeId] === NULL_NODE && rowIdx !== NULL_NODE; - - // Extra tooltip columns fetched on demand — see the treemap - // counterpart for the same pattern. - if (isLeaf && chart._lazyRows) { - const row = await chart._lazyRows.fetchRow(rowIdx); - for (const [name, value] of row) { - if (value === null || value === undefined) { - continue; - } - - if (name === chart._colorName && !isNaN(store.colorValue[nodeId])) { - continue; - } - - if (typeof value === "number") { - lines.push( - `${name}: ${chart.getColumnFormatter(name, "value")(value)}`, - ); - } else { - lines.push(`${name}: ${value}`); - } - } - } - - if (store.firstChild[nodeId] !== NULL_NODE) { - lines.push(`Children: ${store.childCount[nodeId]}`); - } - - return lines; -} +export { buildTreeTooltipLines as buildSunburstTooltipLines } from "../common/tree-interact"; diff --git a/packages/viewer-charts/src/ts/charts/sunburst/sunburst-render.ts b/packages/viewer-charts/src/ts/charts/sunburst/sunburst-render.ts index 4fef2b540c..fb7489c7f1 100644 --- a/packages/viewer-charts/src/ts/charts/sunburst/sunburst-render.ts +++ b/packages/viewer-charts/src/ts/charts/sunburst/sunburst-render.ts @@ -20,6 +20,7 @@ import { leafColor, leafRGBA, luminance } from "../common/leaf-color"; import arcVert from "../../shaders/sunburst-arc.vert.glsl"; import arcFrag from "../../shaders/sunburst-arc.frag.glsl"; import { getInstancing } from "../../webgl/instanced-attrs"; +import { compileProgram } from "../../webgl/program-cache"; import { partitionSunburst, collectVisibleArcs, @@ -292,22 +293,18 @@ function ensureProgram( } const gl = glManager.gl; - const prog = glManager.shaders.getOrCreate( + const compiled = compileProgram< + { program: WebGLProgram } & NonNullable + >( + glManager, "sunburst-arc", arcVert, arcFrag, + ["u_center", "u_resolution", "u_border_px"], + ["a_strip_t", "a_side", "a_angles", "a_radii", "a_color"], ); - chart._program = prog; - chart._locations = { - u_center: gl.getUniformLocation(prog, "u_center"), - u_resolution: gl.getUniformLocation(prog, "u_resolution"), - u_border_px: gl.getUniformLocation(prog, "u_border_px"), - a_strip_t: gl.getAttribLocation(prog, "a_strip_t"), - a_side: gl.getAttribLocation(prog, "a_side"), - a_angles: gl.getAttribLocation(prog, "a_angles"), - a_radii: gl.getAttribLocation(prog, "a_radii"), - a_color: gl.getAttribLocation(prog, "a_color"), - }; + chart._program = compiled.program; + chart._locations = compiled; // Build the static triangle-strip template once. Layout: // pairs of (strip_t, side) for each of the 2*(N_STEPS+1) vertices. diff --git a/packages/viewer-charts/src/ts/charts/treemap/treemap-interact.ts b/packages/viewer-charts/src/ts/charts/treemap/treemap-interact.ts index eecf59912d..84c36ca454 100644 --- a/packages/viewer-charts/src/ts/charts/treemap/treemap-interact.ts +++ b/packages/viewer-charts/src/ts/charts/treemap/treemap-interact.ts @@ -11,12 +11,19 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import type { TreemapChart } from "./treemap"; -import { NULL_NODE, ancestorNames } from "../common/node-store"; -import { PADDING_LABEL, rebuildBreadcrumbs } from "./treemap-layout"; +import { NULL_NODE } from "../common/node-store"; +import { PADDING_LABEL } from "./treemap-layout"; import { renderTreemapFrame, renderTreemapChromeOverlay, } from "./treemap-render"; +import { + buildTreeTooltipLines, + dismissTreePinnedTooltip, + emitTreeNodeEvent, + showTreePinnedTooltip, + treeDrillTo, +} from "../common/tree-interact"; interface HitResult { leafId: number; @@ -152,7 +159,7 @@ export function handleTreemapHover( // (mouse moved elsewhere, new view) are dropped by the // controller's serial gate. const serial = chart._lazyTooltip.beginHover(best); - buildTreemapTooltipLines(chart, best).then((lines) => { + buildTreeTooltipLines(chart, best).then((lines) => { if (chart._lazyTooltip.commitHover(serial, lines)) { renderTreemapChromeOverlay(chart); } @@ -199,59 +206,16 @@ export function handleTreemapClick( if (branchId !== NULL_NODE && inHeader) { drillTo(chart, branchId); - void emitTreemapNodeEvent(chart, branchId, "branch"); + void emitTreeNodeEvent(chart, branchId, "branch"); } else if (leafId !== NULL_NODE) { showTreemapPinnedTooltip(chart, leafId); - void emitTreemapNodeEvent(chart, leafId, "leaf"); + void emitTreeNodeEvent(chart, leafId, "leaf"); } else if (branchId !== NULL_NODE) { drillTo(chart, branchId); - void emitTreemapNodeEvent(chart, branchId, "branch"); + void emitTreeNodeEvent(chart, branchId, "branch"); } } -/** - * Build a click detail from a treemap node id and emit both - * `perspective-click` and `perspective-global-filter selected:true`. - * - * For leaves, the source-view row index is `store.leafRowIdx[id]` and - * the row payload is populated via `_lazyRows`. For branches, no - * source row exists (the branch is a rollup), so `rowIdx: null` and - * the row payload is `{}` — only the filter path is meaningful. - * - * The path is walked via `ancestorNames` and split into split-by - * prefix + group-by levels using `_splitBy.length` as the boundary. - * Faceted mode (`facet_mode === "grid"` with non-empty `_splitBy`) - * keeps the depth-0 ancestor name as the split prefix. - */ -async function emitTreemapNodeEvent( - chart: TreemapChart, - nodeId: number, - kind: "leaf" | "branch", -): Promise { - const store = chart._nodeStore; - const path = ancestorNames(store, nodeId); - const isFaceted = - chart._splitBy.length > 0 && chart._facetConfig.facet_mode === "grid"; - const splitByValues: (string | null)[] = isFaceted - ? path.slice(0, chart._splitBy.length) - : []; - const groupByValues: (string | null)[] = isFaceted - ? path.slice( - chart._splitBy.length, - chart._splitBy.length + chart._groupBy.length, - ) - : path.slice(0, chart._groupBy.length); - - const rowIdx = kind === "leaf" ? (store.leafRowIdx[nodeId] ?? null) : null; - - await chart.emitClickAndSelect({ - rowIdx: rowIdx != null && rowIdx >= 0 ? rowIdx : null, - columnName: chart._sizeName, - groupByValues, - splitByValues, - }); -} - export function handleTreemapDblClick( chart: TreemapChart, mx: number, @@ -279,167 +243,36 @@ export function handleTreemapDblClick( store.firstChild[target] !== NULL_NODE ) { drillTo(chart, target); - void emitTreemapNodeEvent(chart, target, "branch"); + void emitTreeNodeEvent(chart, target, "branch"); if (leafId !== NULL_NODE && store.firstChild[leafId] === NULL_NODE) { showTreemapPinnedTooltip(chart, leafId); - void emitTreemapNodeEvent(chart, leafId, "leaf"); + void emitTreeNodeEvent(chart, leafId, "leaf"); } } } -/** - * Drill the current facet (or the whole chart in non-facet mode). - * - * In faceted mode, walks up the ancestor chain of `nodeId` until the - * facet root (a top-level child of `_rootId`) is found, then sets - * `_facetDrillRoots[facetLabel] = nodeId` so only that facet's - * subtree re-layouts. Non-facet mode keeps the existing single- - * `_currentRootId` behavior and rebuilds the breadcrumb trail. - */ function drillTo(chart: TreemapChart, nodeId: number): void { - const store = chart._nodeStore; - if (chart._splitBy.length > 0 && chart._facetConfig.facet_mode === "grid") { - // Walk up to find the facet-root ancestor (top-level child of - // `_rootId`). Guard against drills that target the synthetic - // root or a facet root itself — those would un-drill the facet. - let p = nodeId; - while (p !== NULL_NODE && store.parent[p] !== chart._rootId) { - p = store.parent[p]; - } - - if (p !== NULL_NODE) { - const label = store.name[p]; - chart._facetDrillRoots.set(label, nodeId); - } - - chart._hoveredNodeId = NULL_NODE; + treeDrillTo(chart, nodeId, () => { if (chart._glManager) { renderTreemapFrame(chart, chart._glManager); } - - return; - } - - chart._currentRootId = nodeId; - rebuildBreadcrumbs(chart, nodeId); - chart._hoveredNodeId = NULL_NODE; - if (chart._glManager) { - renderTreemapFrame(chart, chart._glManager); - } + }); } export function showTreemapPinnedTooltip( chart: TreemapChart, nodeId: number, ): void { - chart._tooltip.dismiss(); - chart._pinnedNodeId = nodeId; - const store = chart._nodeStore; const cx = (store.x0[nodeId] + store.x1[nodeId]) / 2; const cy = (store.y0[nodeId] + store.y1[nodeId]) / 2; - - // CSS bounds: prefer `glManager` (works in both local and worker - // modes, since the worker constructs its own context manager). - const cssWidth = chart._glManager?.cssWidth ?? 0; - const cssHeight = chart._glManager?.cssHeight ?? 0; - - // Tooltip columns are fetched lazily from the view — the tree - // itself only retains ancestor names + aggregated value + color. - // If the user dismisses or re-pins between click and resolve, the - // `_pinnedNodeId` check discards the stale result. - buildTreemapTooltipLines(chart, nodeId).then((lines) => { - if (chart._pinnedNodeId !== nodeId) { - return; - } - - if (lines.length === 0) { - return; - } - - chart._tooltip.pin(lines, { px: cx, py: cy }, { cssWidth, cssHeight }); - }); - - chart._hoveredNodeId = NULL_NODE; - renderTreemapChromeOverlay(chart); + showTreePinnedTooltip(chart, nodeId, { cx, cy }, () => + renderTreemapChromeOverlay(chart), + ); } export function dismissTreemapPinnedTooltip(chart: TreemapChart): void { - chart._tooltip.dismiss(); - chart._pinnedNodeId = NULL_NODE; + dismissTreePinnedTooltip(chart); } -/** - * Build the tooltip for `nodeId`. The node's own name path + aggregate - * value are derived from the tree; per-row tooltip columns come from - * the `leafRowIdx` → column-buffer lookup (no per-node `Map`). - */ -export async function buildTreemapTooltipLines( - chart: TreemapChart, - nodeId: number, -): Promise { - const store = chart._nodeStore; - const lines: string[] = []; - - // Name path (ancestors, topmost first, excluding synthetic root). - const pathNames: string[] = []; - let p = nodeId; - while (store.parent[p] !== NULL_NODE) { - pathNames.push(store.name[p]); - p = store.parent[p]; - } - - pathNames.reverse(); - if (pathNames.length > 0) { - lines.push(pathNames.join(" \u203A ")); - } else { - lines.push(store.name[nodeId]); - } - - const sizeFmt = chart.getColumnFormatter(chart._sizeName, "value"); - lines.push(`Value: ${sizeFmt(store.value[nodeId])}`); - - // Color value (numeric branch): stored on the node at insert - // time, so it's always available without a view fetch. - if (chart._colorName && !isNaN(store.colorValue[nodeId])) { - const colorFmt = chart.getColumnFormatter(chart._colorName, "value"); - lines.push( - `${chart._colorName}: ${colorFmt(store.colorValue[nodeId])}`, - ); - } - - const rowIdx = store.leafRowIdx[nodeId]; - const isLeaf = - store.firstChild[nodeId] === NULL_NODE && rowIdx !== NULL_NODE; - - // Extra tooltip columns come from the source view row, fetched on - // demand via `_lazyRows`. Only leaves correspond to a single view - // row; branch nodes aggregate rows and don't carry extra columns. - if (isLeaf && chart._lazyRows) { - const row = await chart._lazyRows.fetchRow(rowIdx); - for (const [name, value] of row) { - if (value === null || value === undefined) { - continue; - } - - if (name === chart._colorName && !isNaN(store.colorValue[nodeId])) { - // Already emitted from the retained tree state above. - continue; - } - - if (typeof value === "number") { - lines.push( - `${name}: ${chart.getColumnFormatter(name, "value")(value)}`, - ); - } else { - lines.push(`${name}: ${value}`); - } - } - } - - if (store.firstChild[nodeId] !== NULL_NODE) { - lines.push(`Children: ${store.childCount[nodeId]}`); - } - - return lines; -} +export { buildTreeTooltipLines as buildTreemapTooltipLines } from "../common/tree-interact"; diff --git a/packages/viewer-charts/src/ts/charts/treemap/treemap-render.ts b/packages/viewer-charts/src/ts/charts/treemap/treemap-render.ts index d6c0828fd4..7c505e80d1 100644 --- a/packages/viewer-charts/src/ts/charts/treemap/treemap-render.ts +++ b/packages/viewer-charts/src/ts/charts/treemap/treemap-render.ts @@ -26,6 +26,7 @@ import { buildFacetGrid } from "../../layout/facet-grid"; import { leafColor, leafRGBA, luminance } from "../common/leaf-color"; import treemapVert from "../../shaders/treemap.vert.glsl"; import treemapFrag from "../../shaders/treemap.frag.glsl"; +import { compileProgram } from "../../webgl/program-cache"; import { withChromeCache } from "../common/chrome-cache"; import { wrapLabel } from "../../axis/label-geometry"; import { @@ -110,16 +111,18 @@ export function renderTreemapFrame( } if (!chart._program) { - chart._program = glManager.shaders.getOrCreate( + const compiled = compileProgram< + { program: WebGLProgram } & NonNullable + >( + glManager, "treemap", treemapVert, treemapFrag, + ["u_resolution"], + ["a_position", "a_color"], ); - chart._locations = { - u_resolution: gl.getUniformLocation(chart._program, "u_resolution"), - a_position: gl.getAttribLocation(chart._program, "a_position"), - a_color: gl.getAttribLocation(chart._program, "a_color"), - }; + chart._program = compiled.program; + chart._locations = compiled; } const theme = chart._resolveTheme(); diff --git a/packages/viewer-charts/src/ts/map/tile-layer.ts b/packages/viewer-charts/src/ts/map/tile-layer.ts index 330655f3dd..0e49c7d7b0 100644 --- a/packages/viewer-charts/src/ts/map/tile-layer.ts +++ b/packages/viewer-charts/src/ts/map/tile-layer.ts @@ -18,6 +18,7 @@ import { TileLoader, tileKey } from "./tile-loader"; import type { TileSource } from "./tile-source"; import tileVert from "../shaders/tile.vert.glsl"; import tileFrag from "../shaders/tile.frag.glsl"; +import { compileProgram } from "../webgl/program-cache"; type GL = WebGL2RenderingContext | WebGLRenderingContext; @@ -344,24 +345,23 @@ export class TileLayer { } const gl = glManager.gl; - const program = glManager.shaders.getOrCreate( + this._program = compileProgram( + glManager, "map-tile", tileVert, tileFrag, + [ + "u_projection", + "u_extent_min", + "u_extent_max", + "u_uv_min", + "u_uv_max", + "u_tile", + "u_alpha", + ], + ["a_corner"], ); - this._program = { - program, - u_projection: gl.getUniformLocation(program, "u_projection"), - u_extent_min: gl.getUniformLocation(program, "u_extent_min"), - u_extent_max: gl.getUniformLocation(program, "u_extent_max"), - u_uv_min: gl.getUniformLocation(program, "u_uv_min"), - u_uv_max: gl.getUniformLocation(program, "u_uv_max"), - u_tile: gl.getUniformLocation(program, "u_tile"), - u_alpha: gl.getUniformLocation(program, "u_alpha"), - a_corner: gl.getAttribLocation(program, "a_corner"), - }; - const buf = gl.createBuffer(); if (!buf) { return; diff --git a/packages/viewer-charts/src/ts/plugin/plugin.ts b/packages/viewer-charts/src/ts/plugin/plugin.ts index b42a8b41c7..81d30ca74e 100644 --- a/packages/viewer-charts/src/ts/plugin/plugin.ts +++ b/packages/viewer-charts/src/ts/plugin/plugin.ts @@ -431,6 +431,32 @@ export class HTMLPerspectiveViewerWebGLPluginElement default: false, }); } + + // Line / area glyphs can bridge interior nulls by linear + // interpolation. Bar / scatter ignore the flag. + const supports_interpolate = + effective_chart_type === "line" || + effective_chart_type === "area"; + + if (supports_interpolate) { + const variants = + effective_chart_type === "area" + ? [ + { value: "skip", label: "Skip" }, + { value: "solid", label: "Solid" }, + ] + : [ + { value: "skip", label: "Skip" }, + { value: "solid", label: "Solid" }, + { value: "transparent", label: "Transparent" }, + ]; + fields.push({ + kind: "Enum", + key: "interpolate", + default: "solid", + variants, + }); + } } // Per-column formatter widgets. Surfaced for every chart type so diff --git a/packages/viewer-charts/src/ts/shaders/line-uniform.frag.glsl b/packages/viewer-charts/src/ts/shaders/line-uniform.frag.glsl index 7108da28d7..e494238250 100644 --- a/packages/viewer-charts/src/ts/shaders/line-uniform.frag.glsl +++ b/packages/viewer-charts/src/ts/shaders/line-uniform.frag.glsl @@ -16,11 +16,12 @@ uniform vec4 u_color; uniform float u_line_width; varying float v_edge_dist; +varying float v_seg_alpha; void main() { float dist = abs(v_edge_dist); float coreEdge = u_line_width / (u_line_width + 1.5); float alpha = 1.0 - smoothstep(coreEdge, 1.0, dist); - gl_FragColor = vec4(u_color.rgb, u_color.a * alpha); + gl_FragColor = vec4(u_color.rgb, u_color.a * alpha * v_seg_alpha); } diff --git a/packages/viewer-charts/src/ts/shaders/line-uniform.vert.glsl b/packages/viewer-charts/src/ts/shaders/line-uniform.vert.glsl index daf718aa30..083904386b 100644 --- a/packages/viewer-charts/src/ts/shaders/line-uniform.vert.glsl +++ b/packages/viewer-charts/src/ts/shaders/line-uniform.vert.glsl @@ -22,11 +22,27 @@ attribute vec2 a_end; // 0 = start+left, 1 = start+right, 2 = end+left, 3 = end+right attribute float a_corner; +// Per-segment "is endpoint a real source-data cell" flags. Both vertices +// of a segment's quad see the same instance values, so `v_seg_alpha` +// below is constant across the quad — no gradient fade. Read from the +// per-cell real-flag buffer with offset 0 / 1 (same overlap trick as +// the bar-line glyph's segment-position attributes). +attribute float a_real_start; +attribute float a_real_end; + uniform mat4 u_projection; uniform vec2 u_resolution; uniform float u_line_width; +// Alpha multiplier applied to any segment whose endpoints are not both +// real. Set per draw based on the series' interpolate mode: +// 0.0 = skip (gaps at synthesized cells) +// 1.0 = solid (no visible difference; default for non-line) +// 0.5 = transparent (50% opacity on segments touching synthesized cells) +uniform float u_interp_alpha; + varying float v_edge_dist; +varying float v_seg_alpha; void main() { vec4 clipStart = u_projection * vec4(a_start, 0.0, 1.0); @@ -51,4 +67,7 @@ void main() { gl_Position = clipPos + vec4(clipOffset, 0.0, 0.0); v_edge_dist = side; + + float bothReal = a_real_start * a_real_end; + v_seg_alpha = mix(u_interp_alpha, 1.0, step(0.5, bothReal)); } diff --git a/packages/viewer-charts/test/ts/snapshot/categorical-value-axis.spec.ts b/packages/viewer-charts/test/ts/snapshot/categorical-value-axis.spec.ts new file mode 100644 index 0000000000..8d5ad30295 --- /dev/null +++ b/packages/viewer-charts/test/ts/snapshot/categorical-value-axis.spec.ts @@ -0,0 +1,75 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test } from "@perspective-dev/test"; +import { gotoBasic, renderAndCapture } from "../helpers"; + +test.describe("Categorical value axis", () => { + test.beforeEach(async ({ page }) => { + await gotoBasic(page); + }); + + // X/Y Scatter with a `string` X column — the X column type triggers + // categorical-X dispatch in `cartesian-build` / `cartesian-render`. + // Y stays numeric. + test("cartesian categorical X", async ({ page }) => { + await renderAndCapture(page, { + plugin: "X/Y Scatter", + columns: ["Category", "Profit"], + }); + }); + + // Mirror of the above with the `string` column on Y instead. + test("cartesian categorical Y", async ({ page }) => { + await renderAndCapture(page, { + plugin: "X/Y Scatter", + columns: ["Profit", "Category"], + }); + }); + + // Both X and Y are `string`-typed: build pipeline writes slot + // indices into both axes, render pass dispatches the categorical + // painter on both sides. Per the locked decision, points stack at + // each (catX, catY) cell center — no jitter / no aggregation. + test("cartesian categorical X and Y", async ({ page }) => { + await renderAndCapture(page, { + plugin: "X/Y Scatter", + columns: ["Category", "Region"], + }); + }); + + // Y Bar with a string-typed value aggregate (`last(Category)`): + // `_leftValueAxisMode` switches to `"category"`, bar `y0`/`y1` + // carry dictionary slot indices, and the value-axis chrome paints + // the categorical Y axis via the broadened `renderBarAxesChrome`. + test("y-bar categorical value axis", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Y Bar", + columns: ["Category"], + group_by: ["State"], + aggregates: { Category: "last" }, + }); + }); + + // X Bar mirror: categorical value axis lands on the bottom (X) + // side and the chart uses the horizontal projection. Verifies the + // `_isHorizontal` branch of `renderBarAxesChrome` dispatches the + // value side correctly. + test("x-bar categorical value axis", async ({ page }) => { + await renderAndCapture(page, { + plugin: "X Bar", + columns: ["Category"], + group_by: ["State"], + aggregates: { Category: "last" }, + }); + }); +}); diff --git a/packages/viewer-charts/test/ts/snapshot/gradient-heatmap.spec.ts b/packages/viewer-charts/test/ts/snapshot/gradient-heatmap.spec.ts index 6d98427584..29291ca06f 100644 --- a/packages/viewer-charts/test/ts/snapshot/gradient-heatmap.spec.ts +++ b/packages/viewer-charts/test/ts/snapshot/gradient-heatmap.spec.ts @@ -55,7 +55,6 @@ test.describe("Density", () => { await renderAndCapture(page, { plugin: "Density", columns: ["Quantity", "Profit", "Sales"], - settings: true, plugin_config: { gradient_color_mode: "mean" }, }); }); @@ -64,7 +63,6 @@ test.describe("Density", () => { await renderAndCapture(page, { plugin: "Density", columns: ["Quantity", "Profit", "Sales"], - settings: true, plugin_config: { gradient_color_mode: "density" }, }); }); @@ -73,7 +71,6 @@ test.describe("Density", () => { await renderAndCapture(page, { plugin: "Density", columns: ["Quantity", "Profit", "Profit"], - settings: true, plugin_config: { gradient_color_mode: "extreme" }, }); }); @@ -82,7 +79,6 @@ test.describe("Density", () => { await renderAndCapture(page, { plugin: "Density", columns: ["Quantity", "Profit", "Profit"], - settings: true, plugin_config: { gradient_color_mode: "signed" }, }); }); diff --git a/packages/viewer-datagrid/src/css/regular_table.css b/packages/viewer-datagrid/src/css/regular_table.css index 68bd689c27..9434927e52 100644 --- a/packages/viewer-datagrid/src/css/regular_table.css +++ b/packages/viewer-datagrid/src/css/regular_table.css @@ -244,6 +244,42 @@ perspective-viewer.dragging, mask-image: var(--psp-icon--sort-abs-col-asc--mask-image); } +/* Shift-key affordance: while the user holds Shift on the host + * ``, sort-arrow mask-image icons and tree + * expand/collapse carets recolor to advertise their Shift-modified actions + * (abs-sort and depth-level expand/collapse). Both `:host-context` (shadow + * render target) and the bare selector (light render target) are emitted to + * cover the runtime branch in datagrid.ts. */ +:host-context(perspective-viewer.shift-active) + :is( + .psp-header-sort-asc, + .psp-header-sort-desc, + .psp-header-sort-col-asc, + .psp-header-sort-col-desc + ):after, +perspective-viewer.shift-active + :is( + .psp-header-sort-asc, + .psp-header-sort-desc, + .psp-header-sort-col-asc, + .psp-header-sort-col-desc + ):after { + background-color: var( + --shift-active--color, + var(--psp-datagrid--pos-cell--color, #1078d1) + ); +} + +:host-context(perspective-viewer.shift-active) + :is(.psp-tree-label-expand, .psp-tree-label-collapse):before, +perspective-viewer.shift-active + :is(.psp-tree-label-expand, .psp-tree-label-collapse):before { + color: var( + --shift-active--color, + var(--psp-datagrid--pos-cell--color, #1078d1) + ); +} + tbody th:last-of-type { border-right: 1px solid var(--psp-inactive--border-color, #8b868045); overflow: hidden; diff --git a/rust/perspective-js/test/js/to_format/to_format_regressions.spec.js b/rust/perspective-js/test/js/to_format/to_format_regressions.spec.js new file mode 100644 index 0000000000..d326e8fe04 --- /dev/null +++ b/rust/perspective-js/test/js/to_format/to_format_regressions.spec.js @@ -0,0 +1,62 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import perspective from "../perspective_client"; + +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); + +import * as fs from "node:fs"; + +const superstore_uncompressed = fs.readFileSync( + require.resolve("superstore-arrow/superstore.arrow"), +).buffer; + +const superstore_lz4 = fs.readFileSync( + require.resolve("superstore-arrow/superstore.lz4.arrow"), +).buffer; + +test.describe("to_format regressions", function () { + test("start_col is respected", async () => { + let table = await perspective.table(superstore_uncompressed.slice()); + let view = await table.view({ + group_by: ["State"], + split_by: ["Sub-Category"], + // sort: [["Customer Name", "desc"]], + group_rollup_mode: "rollup", + columns: ["Sales", "Quantity", "Discount", "Profit"], + }); + + const result1 = await view.to_columns({ start_col: 4, end_row: 1 }); + const result2 = await view.to_columns({ start_col: 5, end_row: 1 }); + + expect(result1).not.toEqual(result2); + }); + + test("start_col is respected with sort", async () => { + let table = await perspective.table(superstore_uncompressed.slice()); + let view = await table.view({ + group_by: ["State"], + split_by: ["Sub-Category"], + sort: [["Customer Name", "desc"]], + group_rollup_mode: "rollup", + columns: ["Sales", "Quantity", "Discount", "Profit"], + }); + + const result1 = await view.to_columns({ start_col: 4, end_row: 1 }); + const result2 = await view.to_columns({ start_col: 5, end_row: 1 }); + + expect(result1).not.toEqual(result2); + }); +}); diff --git a/rust/perspective-js/test/js/updates.spec.js b/rust/perspective-js/test/js/updates.spec.js index 7afb036f22..ed94b56b49 100644 --- a/rust/perspective-js/test/js/updates.spec.js +++ b/rust/perspective-js/test/js/updates.spec.js @@ -3404,4 +3404,128 @@ async function match_delta(perspective, delta, expected) { await tbl.delete(); }); }); + + // Regression coverage for the `t_ftrav::step_end` fast-path leak: a + // pass containing only `update_row` calls (every pkey already exists) + // used to take the fast path because `m_step_inserts` and + // `m_step_deletes` are both zero — leaving `m_updated` flags stamped + // on `m_index` and dropping the staged replacements from + // `m_new_elems`. The next pass that *did* trigger a rebuild (any + // `add_row` or `delete_row`) skipped the flagged entries with no + // counterpart to reinstate them, and the rows silently disappeared + // from the sorted view. + test.describe("Update under sorted view", function () { + const d1 = +new Date("2024-01-01T00:00:00Z"); + const d2 = +new Date("2024-01-02T00:00:00Z"); + const d3 = +new Date("2024-01-03T00:00:00Z"); + const d4 = +new Date("2024-01-04T00:00:00Z"); + const d5 = +new Date("2024-01-05T00:00:00Z"); + + async function seed(perspective) { + const table = await perspective.table( + { id: "integer", ts: "datetime", payload: "string" }, + { index: "id" }, + ); + await table.update({ + id: [1, 2, 3, 4], + ts: [d1, d2, d3, d4], + payload: ["a", "b", "c", "d"], + }); + return table; + } + + test("baseline: update existing rows then append a new row", async function () { + const table = await seed(perspective); + const view = await table.view({ sort: [["ts", "asc"]] }); + await table.update({ id: [2, 3], payload: ["bb", "cc"] }); + await table.update({ id: [5], ts: [d5], payload: ["e"] }); + + const cols = await view.to_columns(); + expect(await view.num_rows()).toEqual(5); + expect(cols.id).toEqual([1, 2, 3, 4, 5]); + expect(cols.payload).toEqual(["a", "bb", "cc", "d", "e"]); + expect(cols.ts).toEqual([d1, d2, d3, d4, d5]); + + await view.delete(); + await table.delete(); + }); + + test("update-only pass is correct on its own", async function () { + const table = await seed(perspective); + const view = await table.view({ sort: [["ts", "asc"]] }); + await table.update({ id: [2, 3], payload: ["bb", "cc"] }); + + const cols = await view.to_columns(); + expect(await view.num_rows()).toEqual(4); + expect(cols.id).toEqual([1, 2, 3, 4]); + expect(cols.payload).toEqual(["a", "bb", "cc", "d"]); + + await view.delete(); + await table.delete(); + }); + + test("multiple update-only passes before an insert", async function () { + const table = await seed(perspective); + const view = await table.view({ sort: [["ts", "asc"]] }); + await table.update({ id: [2], payload: ["b1"] }); + await table.update({ id: [3], payload: ["c1"] }); + await table.update({ id: [2], payload: ["b2"] }); + + await table.update({ id: [5], ts: [d5], payload: ["e"] }); + + const cols = await view.to_columns(); + expect(await view.num_rows()).toEqual(5); + expect(cols.id).toEqual([1, 2, 3, 4, 5]); + expect(cols.payload).toEqual(["a", "b2", "c1", "d", "e"]); + + await view.delete(); + await table.delete(); + }); + + test("update that changes the sort key, then append", async function () { + const table = await seed(perspective); + const view = await table.view({ sort: [["ts", "asc"]] }); + const d_late = +new Date("2024-01-10T00:00:00Z"); + await table.update({ id: [2], ts: [d_late] }); + + await table.update({ id: [5], ts: [d5], payload: ["e"] }); + + const cols = await view.to_columns(); + expect(await view.num_rows()).toEqual(5); + expect(cols.id).toEqual([1, 3, 4, 5, 2]); + expect(cols.ts).toEqual([d1, d3, d4, d5, d_late]); + + await view.delete(); + await table.delete(); + }); + + test("update-only pass followed by a delete", async function () { + const table = await seed(perspective); + const view = await table.view({ sort: [["ts", "asc"]] }); + + await table.update({ id: [2, 3], payload: ["bb", "cc"] }); + await table.remove([4]); + + const cols = await view.to_columns(); + expect(await view.num_rows()).toEqual(3); + expect(cols.id).toEqual([1, 2, 3]); + expect(cols.payload).toEqual(["a", "bb", "cc"]); + + await view.delete(); + await table.delete(); + }); + + test("no sort: update-only pass + append is unaffected", async function () { + const table = await seed(perspective); + const view = await table.view(); + + await table.update({ id: [2, 3], payload: ["bb", "cc"] }); + await table.update({ id: [5], ts: [d5], payload: ["e"] }); + + expect(await view.num_rows()).toEqual(5); + + await view.delete(); + await table.delete(); + }); + }); })(perspective); diff --git a/rust/perspective-server/cpp/perspective/src/cpp/flat_traversal.cpp b/rust/perspective-server/cpp/perspective/src/cpp/flat_traversal.cpp index 2906ae0ec5..4aed03acc9 100644 --- a/rust/perspective-server/cpp/perspective/src/cpp/flat_traversal.cpp +++ b/rust/perspective-server/cpp/perspective/src/cpp/flat_traversal.cpp @@ -284,8 +284,7 @@ t_ftrav::step_end() { // Fast path: if no incremental work happened this step, `m_index` and // `m_pkeyidx` are already in their final shape (either unchanged, or // populated directly via `bulk_load_append`). Skip the O(N) rebuild. - if (m_step_inserts == 0 && m_step_deletes == 0) { - m_new_elems.clear(); + if (m_step_inserts == 0 && m_step_deletes == 0 && m_new_elems.empty()) { return; } diff --git a/rust/perspective-server/cpp/perspective/src/cpp/server.cpp b/rust/perspective-server/cpp/perspective/src/cpp/server.cpp index 54c67960b8..0ea0410110 100644 --- a/rust/perspective-server/cpp/perspective/src/cpp/server.cpp +++ b/rust/perspective-server/cpp/perspective/src/cpp/server.cpp @@ -1016,7 +1016,6 @@ parse_format_options( std::uint32_t max_cols = num_columns + (sides == 0 ? 0 : 1); std::uint32_t max_rows = num_rows; std::uint32_t psp_offset = sides > 0 || column_only ? 1 : 0; - std::uint32_t hidden = num_hidden; out.end_row = std::min( max_rows, @@ -1025,12 +1024,12 @@ parse_format_options( : (viewport_height != 0 ? out.start_row + viewport_height : max_rows ) ); + out.end_col = std::min( max_cols, - (viewport.has_end_col() - ? viewport.end_col() + psp_offset - : (viewport_width != 0 ? out.start_col + viewport_width : max_cols) - ) * (hidden + 1) + viewport.has_end_col() + ? viewport.end_col() + psp_offset + : (viewport_width != 0 ? out.start_col + viewport_width : max_cols) ); return out; @@ -2375,7 +2374,12 @@ ProtoServer::_handle_request(std::uint32_t client_id, Request&& req) { auto num_view_columns = 0; const auto real_size = config->get_columns().size(); - if (ncols > 0 && real_size > 0) { + if (view->sides() == 2) { + // ctx2's `num_columns()` already excludes hidden sort + // columns (option B): visible-only is the value we want + // to report. + num_view_columns = ncols; + } else if (ncols > 0 && real_size > 0) { num_view_columns = ncols - (ncols / (config->get_columns().size() + num_hidden)) * num_hidden; diff --git a/rust/perspective-server/cpp/perspective/src/cpp/view.cpp b/rust/perspective-server/cpp/perspective/src/cpp/view.cpp index 12422ca96b..efdc6396c7 100644 --- a/rust/perspective-server/cpp/perspective/src/cpp/view.cpp +++ b/rust/perspective-server/cpp/perspective/src/cpp/view.cpp @@ -154,11 +154,36 @@ View::num_columns() const { if (!m_sort.empty()) { auto depth = m_column_pivots.size(); auto col_length = m_ctx->unity_get_column_count(); + + // Hidden sort columns (sort keys not in `m_columns`) are + // interleaved into unity at the same depth as visible leaves; + // filter them out so `num_columns` reports the user-visible + // count. The `start_col`/`end_col` viewport math in + // `parse_format_options` and the `to_*` writers all index in + // this visible-only space. + const auto aggs = m_ctx->get_aggregates(); + std::vector aggregate_names(aggs.size()); + for (auto i = 0; i < aggs.size(); ++i) { + aggregate_names[i] = aggs[i].name(); + } + auto count = 0; for (t_uindex i = 0; i < col_length; ++i) { - if (m_ctx->unity_get_column_path(i + 1).size() == depth) { - count++; + if (m_ctx->unity_get_column_path(i + 1).size() != depth) { + continue; + } + + if (!m_hidden_sort.empty()) { + const std::string& agg_name = + aggregate_names[i % aggregate_names.size()]; + if (std::find( + m_hidden_sort.begin(), m_hidden_sort.end(), agg_name + ) != m_hidden_sort.end()) { + continue; + } } + + count++; } return count; } @@ -672,6 +697,13 @@ View::get_data( /** * Perspective generates headers for sorted columns, so we have to * skip them in the underlying slice. + * + * Hidden sort columns (sort keys absent from `m_columns`) are also + * interleaved into unity at the same depth as visible leaves — + * filter them out here so `column_indices`, `cols`, and the packed + * `slice` all index in visible-only space. `start_col`/`end_col` + * are user-visible column indices, and downstream `to_*` writers + * iterate `slice` without re-applying any hidden-skip modulo. */ t_uindex start_col_index = start_col; t_uindex end_col_index = end_col; @@ -682,14 +714,44 @@ View::get_data( if (start_col < end_col) { auto depth = m_column_pivots.size(); auto col_length = m_ctx->unity_get_column_count(); + + const auto aggs = m_ctx->get_aggregates(); + std::vector aggregate_names(aggs.size()); + for (auto i = 0; i < aggs.size(); ++i) { + aggregate_names[i] = aggs[i].name(); + } + column_indices.push_back(0); for (t_uindex i = 0; i < col_length; ++i) { - if (m_ctx->unity_get_column_path(i + 1).size() == depth) { - column_indices.push_back(i + 1); + auto col_path = m_ctx->unity_get_column_path(i + 1); + if (col_path.size() != depth) { + continue; } - } - cols = column_names(true, depth); + if (!m_hidden_sort.empty()) { + const std::string& agg_name = + aggregate_names[i % aggregate_names.size()]; + if (std::find( + m_hidden_sort.begin(), + m_hidden_sort.end(), + agg_name + ) != m_hidden_sort.end()) { + continue; + } + } + + column_indices.push_back(i + 1); + + std::vector new_path; + for (auto path = col_path.rbegin(); path != col_path.rend(); + ++path) { + new_path.push_back(*path); + } + new_path.push_back( + m_ctx->get_aggregate_name(i % aggregate_names.size()) + ); + cols.push_back(new_path); + } // Filter down column indices by user-provided start/end columns column_indices = std::vector( @@ -1123,6 +1185,16 @@ View::data_slice_to_batches( // the number of hidden sorts, so we can skip hidden sorts. // t_uindex num_view_columns = num_columns - m_hidden_sort.size(); t_uindex num_view_columns = m_columns.size(); + + // The modulo skip below is for layouts (currently ctx1 sorted with a + // hidden sort key) where the slice still carries hidden columns and + // the iteration `tidx + start_col` walks the interleaved/appended + // hidden positions. ctx2 sorted views pre-filter hidden columns in + // `get_data` and signal that by populating `column_indices` — when + // that's the case the slice already contains only visible data and + // the modulo would erroneously drop legitimate visible columns. + bool slice_is_visible_only = !data_slice->get_column_indices().empty(); + std::vector indices; for (auto tidx = 0; tidx < end_col - start_col; ++tidx) { auto cidx = tidx + start_col; @@ -1132,7 +1204,8 @@ View::data_slice_to_batches( // Do not output hidden sort columns - they are always at the end // of the columns list. - if ((num_view_columns + m_hidden_sort.size()) > 0 + if (!slice_is_visible_only + && (num_view_columns + m_hidden_sort.size()) > 0 && ((cidx - (num_sides > 0 ? 1 : 0)) % (num_view_columns + m_hidden_sort.size())) >= num_view_columns) { @@ -1144,13 +1217,24 @@ View::data_slice_to_batches( // TODO For some reason, this parallel call doesn't benefit from // parallelism. + // When the slice was pre-filtered to visible-only by ctx2's + // `get_data` (option B), `cidx` indexes into visible-space + // (matching `names` and the flat `slice` layout), but + // `get_column_dtype` still expects a unity-column position. + // `column_indices` maps visible → unity for that case. + const std::vector& slice_col_indices = + data_slice->get_column_indices(); + parallel_for(int(indices.size()), [&](auto iidx) { // for (auto iidx = 0; iidx < indices.size(); iidx++) { auto ccidx = iidx + num_output_row_paths; auto cidx = indices[iidx] + start_col; std::vector col_path = names.at(cidx); - t_dtype dtype = get_column_dtype(cidx); + t_uindex dtype_cidx = + slice_is_visible_only ? slice_col_indices.at(cidx - start_col) + : cidx; + t_dtype dtype = get_column_dtype(dtype_cidx); // mean and weighted mean uses DTYPE_F64PAIR on the aggtable, which // is the dtype returned by get_column_dtype. However, in the output @@ -1621,8 +1705,6 @@ View::get_row_delta() const { std::vector> paths = column_names(true, m_column_pivots.size()); - // num_columns needs to include __ROW_PATH__ for all pivoted contexts - t_uindex ncols = num_columns() + m_col_offset; t_uindex num_sides = sides(); // Add __ROW_PATH__ to the beginning for column only or for 2-sided @@ -1634,6 +1716,14 @@ View::get_row_delta() const { paths.insert(paths.begin(), std::vector{row_path}); } + // `delta.data` is packed against the full unity column layout + // (including hidden sort columns), so `ncols`/stride must match + // `paths` — *not* `num_columns()`, which for ctx2 sorted views + // collapses hidden columns out and would produce a wrong stride. + // `data_slice_to_batches`' modulo skip strips hidden columns from + // the eventual Arrow output for this path. + t_uindex ncols = paths.size(); + return std::make_shared>( m_ctx, 0, @@ -2139,12 +2229,15 @@ View::to_rows( writer.EndArray(); } - // Columns + // Columns. ctx2 row-sorted views pre-filter hidden columns in + // `get_data` (signalled by non-empty `column_indices`); for + // column-sort-only views the slice still carries them and the + // modulo below drops them on the way out. for (auto c = start_col + 1; c < end_col; ++c) { - if (((c - 1) % (columns_length + hidden)) >= columns_length) { + if (slice->get_column_indices().empty() + && ((c - 1) % (columns_length + hidden)) >= columns_length) { continue; } - writer.Key(column_names[c - (start_col + 1)].c_str()); auto scalar = slice->get(r, c); write_scalar(scalar, is_formatted, writer); @@ -2477,12 +2570,15 @@ View::to_ndjson( writer.EndArray(); } - // Columns + // Columns. ctx2 row-sorted views pre-filter hidden columns in + // `get_data` (signalled by non-empty `column_indices`); for + // column-sort-only views the slice still carries them and the + // modulo below drops them on the way out. for (auto c = start_col + 1; c < end_col; ++c) { - if (((c - 1) % (columns_length + hidden)) >= columns_length) { + if (slice->get_column_indices().empty() + && ((c - 1) % (columns_length + hidden)) >= columns_length) { continue; } - writer.Key(column_names[c - (start_col + 1)].c_str()); auto scalar = slice->get(r, c); write_scalar(scalar, is_formatted, writer); @@ -2678,10 +2774,17 @@ View::to_columns( LOG_DEBUG("Using ctx2 to_columns"); + // ctx2 *row-sorted* views go through `get_data`'s sorted branch + // which pre-filters hidden sort columns from both `col_names` and + // the packed slice (signalled by a non-empty `column_indices`). + // Column-sort-only views (no row sort) skip that branch — the slice + // still carries hidden columns interleaved at every + // `(columns_length + hidden)`-th unity position, and the modulo + // below drops them from the output. + bool slice_is_visible_only = !slice->get_column_indices().empty(); for (auto c = start_col + 1; c < end_col; ++c) { - // Hidden columns are always at the end of the column names - // list, and we need to skip them from the output. - if (((c - 1) % (columns_length + hidden)) >= columns_length) { + if (!slice_is_visible_only + && ((c - 1) % (columns_length + hidden)) >= columns_length) { LOG_DEBUG("Skipping column {}" << col_path_to_legacy(col_names[c])); continue; } diff --git a/rust/perspective-viewer/src/css/column-settings-panel.css b/rust/perspective-viewer/src/css/column-settings-panel.css index bff6a82f41..e91d6241cd 100644 --- a/rust/perspective-viewer/src/css/column-settings-panel.css +++ b/rust/perspective-viewer/src/css/column-settings-panel.css @@ -75,20 +75,22 @@ border-radius: 3px; outline-width: 1px; outline-color: var(--psp-inactive--color); + padding-left: 4px; &.editable { + outline-style: dashed; &:hover { outline-style: solid; cursor: text; } } - &:focus { + &.editable:focus { outline-style: solid; background: var(--psp--background-color); } - &.edited { + /* &.edited { outline-style: dashed; - } + } */ &.invalid { outline-color: var(--psp-error--color); } @@ -164,6 +166,10 @@ content: var(--psp-label--stack--content, "Stack"); } + label#interpolate-label:before { + content: var(--psp-label--interpolate--content, "Interpolate null"); + } + label#series-label:before { content: var(--psp-label--series--content, "Series"); } diff --git a/rust/perspective-viewer/src/css/column-style.css b/rust/perspective-viewer/src/css/column-style.css index 4265cbeed6..49ca734d88 100644 --- a/rust/perspective-viewer/src/css/column-style.css +++ b/rust/perspective-viewer/src/css/column-style.css @@ -27,14 +27,22 @@ margin: 0px; } + .is-default-value .bool-field-container { + background-color: var(--psp--background-color); + border: 1px solid var(--psp-inactive--color); + } + .bool-field-container { display: flex; - border: 1px solid var(--psp-inactive--color); + border: 1px dashed var(--psp-inactive--color); border-radius: 3px; align-items: center; padding: 0 6px; width: 100%; cursor: pointer; + label { + cursor: pointer; + } } &.no-style { diff --git a/rust/perspective-viewer/src/css/dom/checkbox.css b/rust/perspective-viewer/src/css/dom/checkbox.css index 379cbd911b..57a73a4495 100644 --- a/rust/perspective-viewer/src/css/dom/checkbox.css +++ b/rust/perspective-viewer/src/css/dom/checkbox.css @@ -19,7 +19,7 @@ padding: 0px; cursor: pointer; outline: none; - margin: 0 5px; + margin: 0; display: inline-block; background-repeat: no-repeat; background-color: var(--psp--color, #ccc); @@ -86,7 +86,7 @@ opacity: 0.2s; } - &:hover { + &:not(.alternate):hover { -webkit-mask-image: var(--psp-icon--checkbox-hover--mask-image); mask-image: var(--psp-icon--checkbox-hover--mask-image); } diff --git a/rust/perspective-viewer/src/css/viewer.css b/rust/perspective-viewer/src/css/viewer.css index cad5e14677..1ef023397b 100644 --- a/rust/perspective-viewer/src/css/viewer.css +++ b/rust/perspective-viewer/src/css/viewer.css @@ -16,10 +16,88 @@ --psp--color: #ff0000; } +/* Shift-key affordance: while the user holds Shift, any mask-image icon + * tagged `.shift-alt-icon` recolors to advertise that a Shift-modified + * action is available on the underlying control. `!important` is required + * to beat per-icon ID-scoped `background-color` rules. */ +:host(.shift-active) .shift-alt-icon { + background-color: var( + --shift-active--color, + var(--psp-datagrid--pos-cell--color, #1078d1) + ) !important; +} + +:host(.shift-active) { + #sub-columns { + .is_column_active.toggle-mode { + -webkit-mask-image: var(--psp-icon--radio-off--mask-image); + mask-image: var(--psp-icon--radio-off--mask-image); + + &:before { + content: var(--psp-icon--radio-off--mask-image); + } + + &:hover { + -webkit-mask-image: var(--psp-icon--radio-hover--mask-image); + mask-image: var(--psp-icon--radio-hover--mask-image); + } + } + + .is_column_active.select-mode { + -webkit-mask-image: var(--psp-icon--checkbox-off--mask-image); + mask-image: var(--psp-icon--checkbox-off--mask-image); + + &:before { + content: var(--psp-icon--checkbox-off--mask-image); + } + + &:hover { + -webkit-mask-image: var(--psp-icon--checkbox-hover--mask-image); + mask-image: var(--psp-icon--checkbox-hover--mask-image); + } + } + } + + #active-columns { + .is_column_active.toggle-mode { + -webkit-mask-image: var(--psp-icon--radio-on--mask-image); + mask-image: var(--psp-icon--radio-on--mask-image); + + &:before { + content: var(--psp-icon--radio-on--mask-image); + } + + &:not(.required):hover { + -webkit-mask-image: var(--psp-icon--radio-hover--mask-image); + mask-image: var(--psp-icon--radio-hover--mask-image); + } + } + + .is_column_active.select-mode { + -webkit-mask-image: var(--psp-icon--checkbox-on--mask-image); + mask-image: var(--psp-icon--checkbox-on--mask-image); + + &:before { + content: var(--psp-icon--checkbox-on--mask-image); + } + + &:not(.required):hover { + -webkit-mask-image: var(--psp-icon--checkbox-hover--mask-image); + mask-image: var(--psp-icon--checkbox-hover--mask-image); + } + } + } +} + ::slotted(*) { pointer-events: var(--override-content-pointer-events); } +:host input[type="number"]::-webkit-inner-spin-button { + transform: scale(1.5); /* Makes the arrows 50% larger */ + filter: invert(1); +} + :host .sidebar_close_button { position: absolute; top: 0; @@ -398,13 +476,13 @@ cursor: pointer; padding: 6px 8px; font-size: var(--label--font-size, 0.75em); + text-transform: uppercase; flex: 0 1 100px; background-color: #00000020; border-bottom: 1px solid var(--psp-inactive--color); color: var(--psp-inactive--color); margin-left: -1px; border-left: 1px solid var(--psp-inactive--color); - &:hover { color: inherit; } diff --git a/rust/perspective-viewer/src/rust/components/column_selector/active_column.rs b/rust/perspective-viewer/src/rust/components/column_selector/active_column.rs index 43c89aa490..f8944966da 100644 --- a/rust/perspective-viewer/src/rust/components/column_selector/active_column.rs +++ b/rust/perspective-viewer/src/rust/components/column_selector/active_column.rs @@ -355,6 +355,9 @@ impl Component for ActiveColumn { if is_required { class.push("required"); }; + if !is_required { + class.push("shift-alt-icon"); + } html! {
Html {
() + { + let _ = elem.dataset().set("safaridragleave", "true"); + } link.send_message(DragDropListMsg::Freeze(true)); V::dragenter(idx) } diff --git a/rust/perspective-viewer/src/rust/components/editable_header.rs b/rust/perspective-viewer/src/rust/components/editable_header.rs index 29f974799a..4ca10746cb 100644 --- a/rust/perspective-viewer/src/rust/components/editable_header.rs +++ b/rust/perspective-viewer/src/rust/components/editable_header.rs @@ -28,9 +28,13 @@ pub struct EditableHeaderProps { pub initial_value: Option, pub placeholder: Rc, + // TODO remove this pattern #[prop_or_default] pub reset_count: u8, + #[prop_or_default] + pub update_on_input: bool, + /// Session metadata snapshot — threaded from `SessionProps`. pub metadata: SessionMetadataRc, @@ -163,6 +167,16 @@ impl Component for EditableHeader { EditableHeaderMsg::SetNewValue(value) }); + let update_on_input = ctx.props().update_on_input; + let oninput = ctx.link().batch_callback(move |e: yew::InputEvent| { + if update_on_input { + let value = e.target_unchecked_into::().value(); + vec![EditableHeaderMsg::SetNewValue(value)] + } else { + vec![] + } + }); + html! {
if let Some(icon) = ctx.props().icon_type { } @@ -173,6 +187,7 @@ impl Component for EditableHeader { disabled={!ctx.props().editable} {onblur} {onkeyup} + {oninput} value={self.value.clone()} placeholder={self.placeholder.clone()} /> diff --git a/rust/perspective-viewer/src/rust/components/status_bar.rs b/rust/perspective-viewer/src/rust/components/status_bar.rs index 759a7092d8..91f20ba4cf 100644 --- a/rust/perspective-viewer/src/rust/components/status_bar.rs +++ b/rust/perspective-viewer/src/rust/components/status_bar.rs @@ -377,7 +377,7 @@ impl Component for StatusBar {
- + diff --git a/rust/perspective-viewer/src/rust/components/viewer.rs b/rust/perspective-viewer/src/rust/components/viewer.rs index a26c3b4064..6d402459a9 100644 --- a/rust/perspective-viewer/src/rust/components/viewer.rs +++ b/rust/perspective-viewer/src/rust/components/viewer.rs @@ -14,7 +14,9 @@ use std::rc::Rc; use futures::channel::oneshot::*; use perspective_js::utils::*; +use wasm_bindgen::JsCast; use wasm_bindgen::prelude::*; +use web_sys::{FocusEvent, KeyboardEvent}; use yew::prelude::*; use super::containers::split_panel::SplitPanel; @@ -121,6 +123,70 @@ pub struct PerspectiveViewer { /// Counts in-flight renders (incremented on `view_config_changed`, /// decremented on `view_created`). Threaded to `StatusIndicator`. update_count: u32, + + /// Window listeners that toggle the `.shift-active` class on the host + /// element while the Shift key is held, making Shift-modified affordances + /// (e.g. inactive column add, active column remove, status-bar reset) + /// visually discoverable. Stored so the closures outlive `create`. + _shift_listeners: ShiftListeners, +} + +struct ShiftListeners { + elem: web_sys::HtmlElement, + keydown: Closure, + keyup: Closure, + blur: Closure, +} + +impl Drop for ShiftListeners { + fn drop(&mut self) { + let win = global::window(); + let _ = win + .remove_event_listener_with_callback("keydown", self.keydown.as_ref().unchecked_ref()); + let _ = + win.remove_event_listener_with_callback("keyup", self.keyup.as_ref().unchecked_ref()); + let _ = win.remove_event_listener_with_callback("blur", self.blur.as_ref().unchecked_ref()); + let _ = self.elem.class_list().remove_1("shift-active"); + } +} + +fn install_shift_listeners(elem: web_sys::HtmlElement) -> ShiftListeners { + let keydown = { + let elem = elem.clone(); + Closure::wrap(Box::new(move |event: KeyboardEvent| { + if event.key() == "Shift" { + let _ = elem.class_list().add_1("shift-active"); + } + }) as Box) + }; + + let keyup = { + let elem = elem.clone(); + Closure::wrap(Box::new(move |event: KeyboardEvent| { + if event.key() == "Shift" { + let _ = elem.class_list().remove_1("shift-active"); + } + }) as Box) + }; + + let blur = { + let elem = elem.clone(); + Closure::wrap(Box::new(move |_: FocusEvent| { + let _ = elem.class_list().remove_1("shift-active"); + }) as Box) + }; + + let win = global::window(); + let _ = win.add_event_listener_with_callback("keydown", keydown.as_ref().unchecked_ref()); + let _ = win.add_event_listener_with_callback("keyup", keyup.as_ref().unchecked_ref()); + let _ = win.add_event_listener_with_callback("blur", blur.as_ref().unchecked_ref()); + + ShiftListeners { + elem, + keydown, + keyup, + blur, + } } impl Component for PerspectiveViewer { @@ -160,6 +226,8 @@ impl Component for PerspectiveViewer { }); } + let shift_listeners = install_shift_listeners(elem); + Self { _subscriptions: subscriptions, column_settings_panel_width_override: None, @@ -178,6 +246,7 @@ impl Component for PerspectiveViewer { presentation_props, dragdrop_props: DragDropProps::default(), update_count: 0, + _shift_listeners: shift_listeners, } } diff --git a/rust/perspective-viewer/src/rust/renderer.rs b/rust/perspective-viewer/src/rust/renderer.rs index 5dd0a6f555..634e9b6184 100644 --- a/rust/perspective-viewer/src/rust/renderer.rs +++ b/rust/perspective-viewer/src/rust/renderer.rs @@ -35,7 +35,6 @@ use futures::future::{join_all, select_all}; use perspective_client::config::ViewConfig; use perspective_client::utils::*; use perspective_client::{View, ViewWindow}; -use perspective_js::json; use perspective_js::utils::{ApiResult, JsValueSerdeExt, ResultTApiErrorExt}; use serde_json::Value; use wasm_bindgen::prelude::*; @@ -170,15 +169,6 @@ impl Renderer { })) } - pub async fn reset(&self, columns_config: Option<&ColumnConfigMap>) -> ApiResult<()> { - self.0.borrow_mut().plugins_idx = None; - if let Ok(plugin) = self.get_active_plugin() { - plugin.restore(&json!({}), columns_config)?; - } - - Ok(()) - } - pub fn delete(&self) -> ApiResult<()> { self.get_active_plugin().map(|x| x.delete()).unwrap_or_log(); self.plugin_data.borrow().viewer_elem.set_inner_text(""); diff --git a/rust/perspective-viewer/src/rust/tasks/reset_all.rs b/rust/perspective-viewer/src/rust/tasks/reset_all.rs index 92f26bcfe1..a4c3c14bc0 100644 --- a/rust/perspective-viewer/src/rust/tasks/reset_all.rs +++ b/rust/perspective-viewer/src/rust/tasks/reset_all.rs @@ -11,13 +11,17 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ //! Cross-engine reset orchestration: reset session config, optionally clear -//! presentation columns config / theme, reset the renderer plugin state, and -//! redraw. +//! presentation columns config / theme, then delegate to `restore_and_render` +//! to switch back to the default plugin and redraw. use futures::channel::oneshot; use perspective_client::clone; use perspective_js::utils::ApiFuture; +use super::restore_and_render; +use crate::config::{ + ColumnConfigUpdate, OptionalUpdate, PluginConfigUpdate, PluginUpdate, ViewerConfigUpdate, +}; use crate::presentation::Presentation; use crate::renderer::Renderer; use crate::session::{ResetOptions, Session}; @@ -30,6 +34,12 @@ use crate::session::{ResetOptions, Session}; /// /// Optionally signals `sender` once the reset+redraw round-trip completes, /// then emits `renderer.reset_changed`. +/// +/// Delegates plugin selection + draw to [`restore_and_render`], whose +/// two-pass restore guarantees the default plugin sees materialized +/// `columns_config` / `plugin_config` on its first draw — fixing a race +/// where the raw post-reset bucket would reach the plugin before +/// stats-dependent `include: true` defaults were resolved. pub fn reset_all( session: &Session, renderer: &Renderer, @@ -47,32 +57,42 @@ pub fn reset_all( ..ResetOptions::default() }) .await?; - let columns_config = if all { - renderer.reset_columns_configs(); - renderer.reset_plugin_config(); - // Mirror the per-plugin bucket clear on the event bus so - // `PluginTab` re-pulls (its props are interior-mutable - // handles whose identity doesn't change on the reset). - renderer - .plugin_config_changed - .emit(renderer.get_plugin_config()); - None - } else { - Some(renderer.all_columns_configs()) - }; - renderer.reset(columns_config.as_ref()).await?; presentation.reset_available_themes(None).await; if all { presentation.reset_theme().await?; } - let result = renderer.draw(session.validate().await?.create_view()).await; + // For `all = true`, route the bucket clears through `restore_and_render`'s + // `update_*` paths as `SetDefault`. This guarantees the materialized + // restore fires even when the user is already on the default plugin + // (no plugin_swap signal), since `SetDefault` reports the bucket as + // `changed` when it was non-empty. The per-plugin bucket model means + // only the (post-swap) default plugin's bucket is cleared; other + // plugins' buckets persist with their per-plugin state. + let (columns_config, plugin_config) = if all { + ( + ColumnConfigUpdate::SetDefault, + PluginConfigUpdate::SetDefault, + ) + } else { + (OptionalUpdate::Missing, OptionalUpdate::Missing) + }; + + let update = ViewerConfigUpdate { + plugin: PluginUpdate::SetDefault, + plugin_config, + columns_config, + ..Default::default() + }; + + restore_and_render(&session, &renderer, &presentation, update, async { Ok(()) }).await?; + if let Some(sender) = sender { sender.send(()).unwrap(); } renderer.reset_changed.emit(()); - result + Ok(()) }) } diff --git a/rust/perspective-viewer/src/svg/checkbox-checked-icon.svg b/rust/perspective-viewer/src/svg/checkbox-checked-icon.svg index 04226e7cc4..ea6dac9127 100644 --- a/rust/perspective-viewer/src/svg/checkbox-checked-icon.svg +++ b/rust/perspective-viewer/src/svg/checkbox-checked-icon.svg @@ -2,6 +2,6 @@ + fill="#000000"> \ No newline at end of file diff --git a/rust/perspective-viewer/src/svg/checkbox-unchecked-icon.svg b/rust/perspective-viewer/src/svg/checkbox-unchecked-icon.svg index d4e55be50d..93b4f55d97 100644 --- a/rust/perspective-viewer/src/svg/checkbox-unchecked-icon.svg +++ b/rust/perspective-viewer/src/svg/checkbox-unchecked-icon.svg @@ -1,3 +1,3 @@ - + \ No newline at end of file diff --git a/rust/perspective-viewer/src/themes/icons.css b/rust/perspective-viewer/src/themes/icons.css index a52d726990..9dc6b711e8 100644 --- a/rust/perspective-viewer/src/themes/icons.css +++ b/rust/perspective-viewer/src/themes/icons.css @@ -34,6 +34,8 @@ perspective-string-column-style { --psp-icon--radio-on--mask-image: url("../svg/radio-on.svg"); --psp-icon--radio-hover--mask-image: url("../svg/radio-hover.svg"); --psp-icon--radio-off--mask-image: url("../svg/radio-off.svg"); + /* --psp-icon--checkbox-checked--mask-image: url("../svg/checkbox-checked-icon.svg"); + --psp-icon--checkbox-unchecked--mask-image: url("../svg/checkbox-unchecked-icon.svg"); */ --psp-icon--checkbox-on--mask-image: url("../svg/checkbox-on.svg"); --psp-icon--checkbox-hover--mask-image: url("../svg/checkbox-hover.svg"); --psp-icon--checkbox-off--mask-image: url("../svg/checkbox-off.svg"); diff --git a/rust/perspective-viewer/src/themes/intl.css b/rust/perspective-viewer/src/themes/intl.css index 2d3feb3a31..d9669c025d 100644 --- a/rust/perspective-viewer/src/themes/intl.css +++ b/rust/perspective-viewer/src/themes/intl.css @@ -132,4 +132,5 @@ perspective-dropdown { --psp-label--gradient-heat-max--content: "Heat max"; --psp-label--map-tile-provider--content: "Map provider"; --psp-label--map-tile-alpha--content: "Map opacity"; + --psp-label--interpolate--content: "Interpolate null"; } diff --git a/rust/perspective-viewer/src/themes/intl/de.css b/rust/perspective-viewer/src/themes/intl/de.css index 83e545fe23..2e4adcdc9e 100644 --- a/rust/perspective-viewer/src/themes/intl/de.css +++ b/rust/perspective-viewer/src/themes/intl/de.css @@ -80,6 +80,7 @@ perspective-dropdown { --psp-label--style--content: "Stil"; --psp-label--stack--content: "Stapel"; --psp-label--alt-axis--content: "Zweite Achse"; + --psp-label--interpolate--content: "Interpolieren"; --psp-label--minimum-integer-digits--content: "Mindestanzahl ganzzahliger Ziffern"; --psp-label--rounding-increment--content: "Rundungsinkrement"; --psp-label--notation--content: "Notation"; diff --git a/rust/perspective-viewer/src/themes/intl/es.css b/rust/perspective-viewer/src/themes/intl/es.css index 28e5dad4c6..35c4e47c7f 100644 --- a/rust/perspective-viewer/src/themes/intl/es.css +++ b/rust/perspective-viewer/src/themes/intl/es.css @@ -80,6 +80,7 @@ perspective-dropdown { --psp-label--style--content: "Estilo"; --psp-label--stack--content: "Apilar"; --psp-label--alt-axis--content: "Eje Alterno"; + --psp-label--interpolate--content: "Interpolar"; --psp-label--minimum-integer-digits--content: "Dígitos enteros mínimos"; --psp-label--rounding-increment--content: "Incremento de redondeo"; --psp-label--notation--content: "Notación"; diff --git a/rust/perspective-viewer/src/themes/intl/fr.css b/rust/perspective-viewer/src/themes/intl/fr.css index e1587df867..331b43435f 100644 --- a/rust/perspective-viewer/src/themes/intl/fr.css +++ b/rust/perspective-viewer/src/themes/intl/fr.css @@ -80,6 +80,7 @@ perspective-dropdown { --psp-label--style--content: "Style"; --psp-label--stack--content: "Empiler"; --psp-label--alt-axis--content: "Axe alternatif"; + --psp-label--interpolate--content: "Interpoler"; --psp-label--minimum-integer-digits--content: "Chiffres entiers minimaux"; --psp-label--rounding-increment--content: "Incrément d'arrondi"; --psp-label--notation--content: "Notation"; diff --git a/rust/perspective-viewer/src/themes/intl/ja.css b/rust/perspective-viewer/src/themes/intl/ja.css index a2bbcf7290..a3b988dddb 100644 --- a/rust/perspective-viewer/src/themes/intl/ja.css +++ b/rust/perspective-viewer/src/themes/intl/ja.css @@ -81,6 +81,7 @@ perspective-dropdown { --psp-label--style--content: "スタイル"; --psp-label--stack--content: "スタック"; --psp-label--alt-axis--content: "副軸"; + --psp-label--interpolate--content: "補間"; --psp-label--minimum-integer-digits--content: "整数の最小桁数"; --psp-label--rounding-increment--content: "丸め増分"; --psp-label--notation--content: "表記"; diff --git a/rust/perspective-viewer/src/themes/intl/pt.css b/rust/perspective-viewer/src/themes/intl/pt.css index 210aa16391..51f96b1fb7 100644 --- a/rust/perspective-viewer/src/themes/intl/pt.css +++ b/rust/perspective-viewer/src/themes/intl/pt.css @@ -80,6 +80,7 @@ perspective-dropdown { --psp-label--style--content: "Estilo"; --psp-label--stack--content: "Empilhar"; --psp-label--alt-axis--content: "Eixo Alternativo"; + --psp-label--interpolate--content: "Interpolar"; --psp-label--minimum-integer-digits--content: "Dígitos inteiros mínimos"; --psp-label--rounding-increment--content: "Incremento de arredondamento"; --psp-label--notation--content: "Notação"; diff --git a/rust/perspective-viewer/src/themes/intl/zh.css b/rust/perspective-viewer/src/themes/intl/zh.css index 102af95bd2..7bc87c0fed 100644 --- a/rust/perspective-viewer/src/themes/intl/zh.css +++ b/rust/perspective-viewer/src/themes/intl/zh.css @@ -80,6 +80,7 @@ perspective-dropdown { --psp-label--style--content: "风格"; --psp-label--stack--content: "堆叠"; --psp-label--alt-axis--content: "副轴"; + --psp-label--interpolate--content: "插值"; --psp-label--minimum-integer-digits--content: "最小整数位数"; --psp-label--rounding-increment--content: "舍入增量"; --psp-label--notation--content: "符号"; diff --git a/rust/perspective-viewer/test/js/viewer_api/reset.spec.ts b/rust/perspective-viewer/test/js/viewer_api/reset.spec.ts new file mode 100644 index 0000000000..c65ca5b1c3 --- /dev/null +++ b/rust/perspective-viewer/test/js/viewer_api/reset.spec.ts @@ -0,0 +1,61 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, compareContentsToSnapshot } from "../helpers.ts"; + +import { DataGrid } from "@perspective-dev/test/src/js/models/plugins/datagrid.ts"; + +test.beforeEach(async ({ page }) => { + await page.goto("/tools/test/src/html/superstore-test.html"); + await page.evaluate(async () => { + while (!window["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); +}); + +test.describe("Reset", () => { + // Regression: a soft reset after a plugin swap used to leave the + // restored default plugin without its preserved `columns_config`. + // The old `reset_all` snapshotted the *active* plugin's bucket + // (Y Bar — empty), then `plugin.restore({}, Some(empty))` overwrote + // the per-plugin bucket that `commit_plugin_idx` had just restored + // on the default (Datagrid) plugin. The first (and only) post-reset + // draw rendered Profit cells unformatted. Routing reset through + // `restore_and_render`'s two-pass materialized restore fixes it. + test("soft reset restores columns_config after plugin swap", async ({ + page, + }) => { + await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer")!; + await viewer.getTable(); + await viewer.restore({ + plugin: "Datagrid", + columns: ["Profit"], + columns_config: { + Profit: { + number_format: { + style: "currency", + currency: "USD", + }, + }, + }, + }); + await viewer.restore({ plugin: "Y Bar" }); + await viewer.reset(); + }); + + const datagrid = new DataGrid(page); + const contents = await datagrid.regularTable.table.innerHTML(); + await compareContentsToSnapshot(contents); + }); +});