diff --git a/packages/viewer-charts/src/ts/axis/categorical-axis-core.ts b/packages/viewer-charts/src/ts/axis/categorical-axis-core.ts index a12938f765..9481022312 100644 --- a/packages/viewer-charts/src/ts/axis/categorical-axis-core.ts +++ b/packages/viewer-charts/src/ts/axis/categorical-axis-core.ts @@ -123,3 +123,71 @@ export function runsInRange( return out; } + +/** + * Inclusive `[first, last]` index range of the ascending `positions[0..n)` entries inside `[lo, hi]`, empty when `first > last`. + */ +export function positionsInRange( + positions: ArrayLike, + n: number, + lo: number, + hi: number, +): [number, number] { + let l = 0; + let r = n; + while (l < r) { + const m = (l + r) >>> 1; + if (positions[m] < lo) { + l = m + 1; + } else { + r = m; + } + } + + const first = l; + r = n; + while (l < r) { + const m = (l + r) >>> 1; + if (positions[m] <= hi) { + l = m + 1; + } else { + r = m; + } + } + + return [first, l - 1]; +} + +/** + * Index of the ascending `positions[0..n)` entry nearest to `x`, or `-1` when `n` is 0. + */ +export function nearestPosition( + positions: ArrayLike, + n: number, + x: number, +): number { + if (n === 0) { + return -1; + } + + let l = 0; + let r = n; + while (l < r) { + const m = (l + r) >>> 1; + if (positions[m] < x) { + l = m + 1; + } else { + r = m; + } + } + + if (l === 0) { + return 0; + } + + if (l === n) { + return n - 1; + } + + return x - positions[l - 1] <= positions[l] - x ? l - 1 : l; +} diff --git a/packages/viewer-charts/src/ts/axis/categorical-axis.ts b/packages/viewer-charts/src/ts/axis/categorical-axis.ts index f2cfccd7c7..e5d5a32110 100644 --- a/packages/viewer-charts/src/ts/axis/categorical-axis.ts +++ b/packages/viewer-charts/src/ts/axis/categorical-axis.ts @@ -19,7 +19,11 @@ import { rotatedLabelsOverlap, truncateLabel, } from "./label-geometry"; -import { type GroupRun, runsInRange } from "./categorical-axis-core"; +import { + type GroupRun, + positionsInRange, + runsInRange, +} from "./categorical-axis-core"; import type { Theme } from "../theme/theme"; export interface CategoricalLevel { @@ -32,6 +36,11 @@ export interface CategoricalDomain { levels: CategoricalLevel[]; numRows: number; levelLabels: string[]; + + /** + * Ascending per-row axis coordinate; absent means row `i` sits at `i`. + */ + positions?: ArrayLike; } interface LevelTickLayout { @@ -48,12 +57,27 @@ const LEAF_LEVEL_WIDTH_MIN = 55; const OUTER_LEVEL_WIDTH = 60; const LEAF_LABEL_PADDING = 10; -function categoryIndexToPixelX(layout: PlotLayout, index: number): number { - return layout.dataToPixel(index, 0).px; +function categoryCoord( + positions: ArrayLike | undefined, + index: number, +): number { + return positions ? positions[index] : index; } -function categoryIndexToPixelY(layout: PlotLayout, index: number): number { - return layout.dataToPixel(0, index).py; +function categoryIndexToPixelX( + layout: PlotLayout, + positions: ArrayLike | undefined, + index: number, +): number { + return layout.dataToPixel(categoryCoord(positions, index), 0).px; +} + +function categoryIndexToPixelY( + layout: PlotLayout, + positions: ArrayLike | undefined, + index: number, +): number { + return layout.dataToPixel(0, categoryCoord(positions, index)).py; } function leafLevelLayout( @@ -190,9 +214,15 @@ function visibleRowWindow( a: number, b: number, flip: boolean, + positions: ArrayLike | undefined, ): [number, number] | null { const lo = flip ? Math.min(a, b) : a; const hi = flip ? Math.max(a, b) : b; + if (positions) { + const [first, last] = positionsInRange(positions, numRows, lo, hi); + return last < first ? null : [first, last]; + } + const visMin = Math.max(0, Math.ceil(lo)); const visMax = Math.min(numRows - 1, Math.floor(hi)); return visMax < visMin ? null : [visMin, visMax]; @@ -206,7 +236,8 @@ function visibleRowWindow( */ function clippedRuns( runs: GroupRun[], - pixelOf: (idx: number) => number, + positions: ArrayLike | undefined, + pixelOf: (coord: number) => number, mainStart: number, mainEnd: number, ): Array<{ @@ -218,8 +249,8 @@ function clippedRuns( }> { const out = []; for (const run of runs) { - const nearEdge = pixelOf(run.startIdx - 0.5); - const farEdge = pixelOf(run.endIdx + 0.5); + const nearEdge = pixelOf(categoryCoord(positions, run.startIdx) - 0.5); + const farEdge = pixelOf(categoryCoord(positions, run.endIdx) + 0.5); const nearClip = Math.max(mainStart, Math.min(nearEdge, farEdge)); const farClip = Math.min(mainEnd, Math.max(nearEdge, farEdge)); if (farClip > nearClip) { @@ -255,6 +286,7 @@ export function renderCategoricalXTicks( layout.paddedXMin, layout.paddedXMax, false, + domain.positions, ); if (!win) { return; @@ -275,6 +307,7 @@ export function renderCategoricalXTicks( ctx, layout, level, + domain.positions, visMin, visMax, rowTop, @@ -287,6 +320,7 @@ export function renderCategoricalXTicks( ctx, layout, level, + domain.positions, visMin, visMax, rowTop, @@ -310,6 +344,7 @@ function renderLeafLevel( ctx: Context2D, layout: PlotLayout, level: CategoricalLevel, + positions: ArrayLike | undefined, visMin: number, visMax: number, rowTop: number, @@ -334,7 +369,7 @@ function renderLeafLevel( ctx.fillStyle = tickColor; ctx.beginPath(); for (const r of tickRows) { - const px = categoryIndexToPixelX(layout, r); + const px = categoryIndexToPixelX(layout, positions, r); if (px < plot.x - 1 || px > plot.x + plot.width + 1) { continue; } @@ -360,7 +395,7 @@ function renderLeafLevel( height: number; }[] = []; for (const r of tickRows) { - const px = categoryIndexToPixelX(layout, r); + const px = categoryIndexToPixelX(layout, positions, r); if (px < plot.x - 1 || px > plot.x + plot.width + 1) { continue; } @@ -402,6 +437,7 @@ function renderOuterLevel( ctx: Context2D, layout: PlotLayout, level: CategoricalLevel, + positions: ArrayLike | undefined, visMin: number, visMax: number, rowTop: number, @@ -416,7 +452,8 @@ function renderOuterLevel( const clipped = clippedRuns( runs, - (idx) => categoryIndexToPixelX(layout, idx), + positions, + (coord) => layout.dataToPixel(coord, 0).px, plot.x, plot.x + plot.width, ); @@ -518,6 +555,7 @@ export function renderCategoricalYTicks( layout.paddedYMin, layout.paddedYMax, true, + domain.positions, ); if (!win) { return; @@ -538,6 +576,7 @@ export function renderCategoricalYTicks( ctx, layout, level, + domain.positions, visMin, visMax, colRight, @@ -549,6 +588,7 @@ export function renderCategoricalYTicks( ctx, layout, level, + domain.positions, visMin, visMax, colRight, @@ -577,6 +617,7 @@ function renderLeafLevelY( ctx: Context2D, layout: PlotLayout, level: CategoricalLevel, + positions: ArrayLike | undefined, visMin: number, visMax: number, colRight: number, @@ -594,7 +635,7 @@ function renderLeafLevelY( ctx.fillStyle = tickColor; ctx.beginPath(); for (let r = visMin; r <= visMax; r += step) { - const py = categoryIndexToPixelY(layout, r); + const py = categoryIndexToPixelY(layout, positions, r); if (py < plot.y - 1 || py > plot.y + plot.height + 1) { continue; } @@ -609,7 +650,7 @@ function renderLeafLevelY( ctx.textAlign = "right"; ctx.textBaseline = "middle"; for (let r = visMin; r <= visMax; r += step) { - const py = categoryIndexToPixelY(layout, r); + const py = categoryIndexToPixelY(layout, positions, r); if (py < plot.y - 1 || py > plot.y + plot.height + 1) { continue; } @@ -627,6 +668,7 @@ function renderOuterLevelY( ctx: Context2D, layout: PlotLayout, level: CategoricalLevel, + positions: ArrayLike | undefined, visMin: number, visMax: number, colRight: number, @@ -642,7 +684,8 @@ function renderOuterLevelY( const clipped = clippedRuns( runs, - (idx) => categoryIndexToPixelY(layout, idx), + positions, + (coord) => layout.dataToPixel(0, coord).py, plot.y, plot.y + plot.height, ); diff --git a/packages/viewer-charts/src/ts/charts/chart-base.ts b/packages/viewer-charts/src/ts/charts/chart-base.ts index d9e384d915..5145f35faf 100644 --- a/packages/viewer-charts/src/ts/charts/chart-base.ts +++ b/packages/viewer-charts/src/ts/charts/chart-base.ts @@ -221,9 +221,9 @@ export abstract class AbstractChart implements ChartImplementation { * (driven from the host's `plugin.restore()`) and read by render- * path glyphs (`line_width_px`, `point_size_px`, etc.) and by the * build pipelines (`auto_alt_y_axis`, `band_inner_frac`, - * `bar_inner_pad`). Defaults preserve the previous compile-time - * constants so first-frame rendering before `restore()` matches - * the pre-refactor output. + * `bar_inner_pad`, `group_pad`). Defaults preserve the previous + * compile-time constants so first-frame rendering before `restore()` + * matches the pre-refactor output. */ _pluginConfig: PluginConfig = { ...DEFAULT_PLUGIN_CONFIG }; @@ -601,8 +601,9 @@ export abstract class AbstractChart implements ChartImplementation { * `wick_width_px`, `ohlc_line_width_px`) are read directly from * `_pluginConfig` by their respective glyphs on each draw — no * sync needed. Build-time fields (`auto_alt_y_axis`, - * `band_inner_frac`, `bar_inner_pad`) are read by the pipeline - * inputs in `uploadAndRender`; they take effect on next data load. + * `band_inner_frac`, `bar_inner_pad`, `group_pad`) are read by + * the pipeline inputs in `uploadAndRender`; they take effect on next + * data load. */ setPluginConfig(cfg: PluginConfig): void { // Persistence echo vs. real change: a restore whose legend diff --git a/packages/viewer-charts/src/ts/charts/chart.ts b/packages/viewer-charts/src/ts/charts/chart.ts index 8146314b9d..b3591e6900 100644 --- a/packages/viewer-charts/src/ts/charts/chart.ts +++ b/packages/viewer-charts/src/ts/charts/chart.ts @@ -368,6 +368,11 @@ export interface PluginConfig { */ bar_inner_pad: number; + /** + * Extra category gap, in leaf-band units, added per outer `group_by` level that changes across a category boundary. + */ + group_pad: number; + /** * Candlestick wick stroke width in CSS pixels. Replaces * `WICK_WIDTH_PX`. @@ -547,6 +552,7 @@ export const DEFAULT_PLUGIN_CONFIG: PluginConfig = { point_size_px: 8.0, band_inner_frac: 0.5, bar_inner_pad: 0.1, + group_pad: 0, wick_width_px: 1.0, ohlc_line_width_px: 1.0, gradient_radius_px: 32.0, 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 2490b4e6c5..f2701f4f32 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 @@ -127,6 +127,36 @@ export function resolveNumericCategoryDomain( }; } +/** + * Category-mode axis coordinates that widen each boundary by `pad` per outer `group_by` level starting a new run there, or `null` when no gap applies. + */ +export function resolveGroupedCategoryPositions( + rowPaths: CategoricalLevel[], + numCategories: number, + pad: number, +): Float64Array | null { + if (!(pad > 0) || rowPaths.length < 2 || numCategories === 0) { + return null; + } + + const positions = new Float64Array(numCategories); + for (let d = 0; d < rowPaths.length - 1; d++) { + for (const run of rowPaths[d].runs) { + if (run.startIdx > 0 && run.startIdx < numCategories) { + positions[run.startIdx] += pad; + } + } + } + + let acc = 0; + for (let i = 0; i < numCategories; i++) { + acc += positions[i]; + positions[i] = i + acc; + } + + return positions; +} + /** * Decide whether the categorical axis should render as a stringified * category axis or a true numeric axis. Numeric mode is only used when 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 9ce8fc9328..db2fa8a246 100644 --- a/packages/viewer-charts/src/ts/charts/series/series-build.ts +++ b/packages/viewer-charts/src/ts/charts/series/series-build.ts @@ -20,6 +20,7 @@ import { resolveAxisMode, resolveCategoryAxis, resolveNumericCategoryDomain, + resolveGroupedCategoryPositions, resolveValueCategoryDomain, type AxisMode, type NumericCategoryDomain, @@ -270,10 +271,13 @@ export interface SeriesPipelineInput { * - `bandInnerFrac` / `barInnerPad` — band-slot geometry forwarded * to `computeSlotGeometry`. Replace the `BAND_INNER_FRAC` / * `BAR_INNER_PAD` constants. + * - `groupPad` — extra category gap per outer `group_by` level + * changing across a boundary (category mode only). */ autoAltYAxis: boolean; bandInnerFrac: number; barInnerPad: number; + groupPad: number; /** * Anchor value-axis extents to zero. When `true` (bar / area @@ -331,9 +335,10 @@ export interface SeriesPipelineResult { numericCategoryDomain: NumericCategoryDomain | null; /** - * Per-category X coordinate in real data units. Populated only in - * numeric axis mode — `null` in category mode where catIdx itself - * is the position. Indexed by `catIdx` (0..numCategories-1). + * Per-category X coordinate in real data units. Populated in + * numeric axis mode, and in category mode when `groupPad` + * widens group boundaries; otherwise `null` and catIdx itself is + * the position. Indexed by `catIdx` (0..numCategories-1). */ categoryPositions: Float64Array | null; @@ -423,6 +428,7 @@ export function buildSeriesPipeline( autoAltYAxis, bandInnerFrac, barInnerPad, + groupPad, includeZero, facetSplits, scratchBars, @@ -589,6 +595,12 @@ export function buildSeriesPipeline( numericCategoryDomain = resolved.numericCategoryDomain; numericBandWidth = resolved.numericCategoryDomain.bandWidth; } + } else if (axisMode.mode === "category") { + categoryPositions = resolveGroupedCategoryPositions( + rowPaths, + N, + groupPad, + ); } // Per-band slot geometry — `computeSlotGeometry` returns values in diff --git a/packages/viewer-charts/src/ts/charts/series/series-interact.ts b/packages/viewer-charts/src/ts/charts/series/series-interact.ts index 23d943c5a0..73d231e9a5 100644 --- a/packages/viewer-charts/src/ts/charts/series/series-interact.ts +++ b/packages/viewer-charts/src/ts/charts/series/series-interact.ts @@ -24,6 +24,10 @@ import { layoutForRecord, } from "./series-render"; import { tooltipStyleOf } from "../../interaction/tooltip-grid"; +import { + nearestPosition, + positionsInRange, +} from "../../axis/categorical-axis-core"; const POINT_HIT_RADIUS_PX = 10; @@ -308,6 +312,24 @@ function hitTestPoints( let best: SeriesChartRecord | null = null; const positions = chart._categoryPositions; + const radiusData = POINT_HIT_RADIUS_PX / pxPerDataX; + let catMin: number; + let catMax: number; + if (!positions) { + catMin = Math.max(0, Math.floor(dataX - radiusData)); + catMax = Math.min(N - 1, Math.ceil(dataX + radiusData)); + } else if (chart._categoryAxisMode === "category") { + [catMin, catMax] = positionsInRange( + positions, + N, + dataX - radiusData, + dataX + radiusData, + ); + } else { + catMin = 0; + catMax = N - 1; + } + for (const s of chart._series) { if (s.chartType !== chartType) { continue; @@ -331,19 +353,6 @@ function hitTestPoints( const dataY = s.axis === 1 ? dataYRight : dataYLeft; const pyPerData = s.axis === 1 ? pxPerDataYRight : pxPerDataYLeft; - // In numeric mode the per-category X positions aren't dense so - // the catIdx-based narrowing doesn't apply — fall back to a - // full sweep. In category mode, narrow to ±radius around dataX. - const catMin = positions - ? 0 - : Math.max(0, Math.floor(dataX - POINT_HIT_RADIUS_PX / pxPerDataX)); - const catMax = positions - ? N - 1 - : Math.min( - N - 1, - Math.ceil(dataX + POINT_HIT_RADIUS_PX / pxPerDataX), - ); - for (let c = catMin; c <= catMax; c++) { const idx = c * S + s.seriesId; if (!((valid[idx >> 3] >> (idx & 7)) & 1)) { @@ -388,13 +397,20 @@ function hitTestAreas( aggRange?: { start: number; end: number }, ): { idx: number; bar: SeriesChartRecord | null } | null { // Closest category to the mouse; an area covers every [cat - 0.5, cat + 0.5] - // slot, so use `round(dataX)` as the candidate index. - const cat = Math.round(dataX); + // slot, so use the nearest category center as the candidate index. + const grouped = + chart._categoryAxisMode === "category" + ? chart._categoryPositions + : null; + const cat = grouped + ? nearestPosition(grouped, chart._numCategories, dataX) + : Math.round(dataX); if (cat < 0 || cat >= chart._numCategories) { return null; } - if (Math.abs(dataX - cat) > 0.5) { + const catX = grouped ? grouped[cat] : cat; + if (Math.abs(dataX - catX) > 0.5) { return null; } @@ -483,7 +499,7 @@ function hitTestAreas( aggIdx: s.aggIdx, splitIdx: s.splitIdx, seriesId: s.seriesId, - xCenter: cat, + xCenter: catX, halfWidth: 0.5, y0: 0, y1: v, 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 1ac65bcf55..986d34e4fd 100644 --- a/packages/viewer-charts/src/ts/charts/series/series-render.ts +++ b/packages/viewer-charts/src/ts/charts/series/series-render.ts @@ -52,6 +52,7 @@ import { renderCategoricalYTicks, type CategoricalDomain, } from "../../axis/categorical-axis"; +import { positionsInRange } from "../../axis/categorical-axis-core"; import { bottomRowLayouts, buildFacetGrid, @@ -410,10 +411,17 @@ export function renderBarFrame( // units; numeric mode reads min/max from the data-unit // `_numericCategoryDomain`. Horizontal mode flips the Y domain so // catIdx=0 sits at the top (handled below in the projection call). - const catMin = numericCat ? chart._numericCategoryDomain!.min : -0.5; + const catPositions = chart._categoryPositions; + const catMin = numericCat + ? chart._numericCategoryDomain!.min + : catPositions + ? catPositions[0] - 0.5 + : -0.5; const catMax = numericCat ? chart._numericCategoryDomain!.max - : chart._numCategories - 0.5; + : catPositions + ? catPositions[chart._numCategories - 1] + 0.5 + : chart._numCategories - 0.5; const valMin = chart._leftDomain.min; const valMax = chart._leftDomain.max; @@ -525,6 +533,7 @@ export function renderBarFrame( levels: chart._rowPaths, numRows: chart._numCategories, levelLabels: chart._groupBy.slice(), + positions: groupedCategoryPositions(chart), }; // Categorical value-axis sizing. Y Bar puts the value axis on the @@ -1056,6 +1065,7 @@ function renderFacetedBarFrame( levels: chart._rowPaths, numRows: chart._numCategories, levelLabels: chart._groupBy.slice(), + positions: groupedCategoryPositions(chart), }; chart._lastYDomain = { min: visValMin, @@ -1933,37 +1943,19 @@ function mapDomainToCatRange( return { lo: 0, hi: -1 }; } - // Lower bound: smallest idx where positions[idx] >= visMin. - let l = 0; - let r = n; - while (l < r) { - const m = (l + r) >>> 1; - if (positions[m] < visMin) { - l = m + 1; - } else { - r = m; - } - } - - const lo = Math.max(0, l - 1); - - // Upper bound: smallest idx where positions[idx] > visMax (`l` after - // loop). `l` itself is one past the last in-range catIdx, so the - // inclusive `hi` for an exactly-overlapping band is `l - 1`; the - // `+1`-then-clamp expands by one to capture partial-overlap bands. - l = 0; - r = n; - while (l < r) { - const m = (l + r) >>> 1; - if (positions[m] <= visMax) { - l = m + 1; - } else { - r = m; - } - } + const [first, last] = positionsInRange(positions, n, visMin, visMax); + return { lo: Math.max(0, first - 1), hi: Math.min(n - 1, last + 1) }; +} - const hi = Math.min(n - 1, l); - return { lo, hi }; +/** + * The `group_pad`-widened category coordinates, or `undefined` when the category axis is unit-spaced or numeric. + */ +function groupedCategoryPositions( + chart: SeriesChart, +): Float64Array | undefined { + return chart._categoryAxisMode === "category" + ? (chart._categoryPositions ?? undefined) + : undefined; } function newSeriesAutoFitCache(): SeriesAutoFitCache { diff --git a/packages/viewer-charts/src/ts/charts/series/series.ts b/packages/viewer-charts/src/ts/charts/series/series.ts index d4475b226c..793935d4d8 100644 --- a/packages/viewer-charts/src/ts/charts/series/series.ts +++ b/packages/viewer-charts/src/ts/charts/series/series.ts @@ -344,9 +344,10 @@ export class SeriesChart extends CategoricalYChart { _lastCatTicks: number[] | null = null; /** - * Per-category X coordinate in real data units (numeric axis mode - * only). `null` in category mode — line/scatter/area glyphs fall - * back to using `catIdx` directly as the X coordinate. + * Per-category X coordinate in real data units (numeric axis mode), + * or the `group_pad`-widened coordinate in category mode. + * `null` otherwise — glyphs fall back to using `catIdx` directly as + * the X coordinate. */ _categoryPositions: Float64Array | null = null; @@ -611,6 +612,7 @@ export class SeriesChart extends CategoricalYChart { autoAltYAxis: this._pluginConfig.auto_alt_y_axis, bandInnerFrac: this._pluginConfig.band_inner_frac, barInnerPad: this._pluginConfig.bar_inner_pad, + groupPad: this._pluginConfig.group_pad, includeZero: this._pluginConfig.include_zero, facetSplits, scratchBars: this._bars, diff --git a/packages/viewer-charts/src/ts/plugin/charts.ts b/packages/viewer-charts/src/ts/plugin/charts.ts index 054fbaa3e0..ef97ca6760 100644 --- a/packages/viewer-charts/src/ts/plugin/charts.ts +++ b/packages/viewer-charts/src/ts/plugin/charts.ts @@ -129,6 +129,7 @@ const SERIES_FIELDS: readonly PluginConfigField[] = [ "point_size_px", "band_inner_frac", "bar_inner_pad", + "group_pad", ...LEGEND_FIELDS, ...TOOLTIP_FIELDS, ]; diff --git a/packages/viewer-charts/src/ts/plugin/plugin.ts b/packages/viewer-charts/src/ts/plugin/plugin.ts index 4889517bc9..7bdd5fa0e3 100644 --- a/packages/viewer-charts/src/ts/plugin/plugin.ts +++ b/packages/viewer-charts/src/ts/plugin/plugin.ts @@ -132,6 +132,7 @@ const FIELD_SCHEMAS: Record FieldSpec)> = point_size_px: { kind: "Number", min: 1, max: 32 }, band_inner_frac: { kind: "Number", min: 0.1, max: 1, step: 0.01 }, bar_inner_pad: { kind: "Number", min: 0, max: 0.9, step: 0.01 }, + group_pad: { kind: "Number", min: 0, max: 2, step: 0.01 }, wick_width_px: { kind: "Number", min: 0.5, step: 0.5, max: 8 }, ohlc_line_width_px: { kind: "Number", min: 0.5, step: 0.5, max: 8 }, gradient_radius_px: { kind: "Number", min: 2, step: 1, max: 256 }, @@ -215,6 +216,7 @@ const PLUGIN_FIELD_GROUPS: ReadonlyArray<{ "point_size_px", "band_inner_frac", "bar_inner_pad", + "group_pad", "wick_width_px", "ohlc_line_width_px", ], diff --git a/packages/viewer-charts/src/ts/transport/protocol.ts b/packages/viewer-charts/src/ts/transport/protocol.ts index c6d4f0a8f1..48c13cdd00 100644 --- a/packages/viewer-charts/src/ts/transport/protocol.ts +++ b/packages/viewer-charts/src/ts/transport/protocol.ts @@ -170,10 +170,11 @@ export interface InitMsg { /** * Initial plugin-scoped global config. Seeds the chart impl's * `_pluginConfig` before the first `loadAndRender` so the build - * pipeline (`auto_alt_y_axis`, `band_inner_frac`, `bar_inner_pad`) - * and render-path uniforms see correct values on the very first - * frame. The host's later `restore({ plugin_config })` arrives as - * a `setPluginConfig` control msg. + * pipeline (`auto_alt_y_axis`, `band_inner_frac`, `bar_inner_pad`, + * `group_pad`) and render-path uniforms see correct values on + * the very first frame. The host's later + * `restore({ plugin_config })` arrives as a `setPluginConfig` + * control msg. */ pluginConfig: PluginConfig; @@ -278,8 +279,8 @@ export interface SetColumnsConfigMsg { * `_facetConfig.zoom_mode`, `_autoFitValue`) in `setPluginConfig` and * the host posts a `redraw` so render-path uniform changes (line * widths, point size) take effect on the next frame. Build-time - * fields (`auto_alt_y_axis`, `band_inner_frac`, `bar_inner_pad`) take - * effect on the next `loadAndRender`. + * fields (`auto_alt_y_axis`, `band_inner_frac`, `bar_inner_pad`, + * `group_pad`) take effect on the next `loadAndRender`. */ export interface SetPluginConfigMsg { kind: "setPluginConfig"; diff --git a/packages/viewer-charts/test/ts/group-pad.spec.ts b/packages/viewer-charts/test/ts/group-pad.spec.ts new file mode 100644 index 0000000000..bd238eb576 --- /dev/null +++ b/packages/viewer-charts/test/ts/group-pad.spec.ts @@ -0,0 +1,36 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { expect, test } from "@perspective-dev/test"; +import { gotoBasic, restoreChart } from "./helpers"; + +test.describe("group_pad", () => { + test.beforeEach(async ({ page }) => { + await gotoBasic(page); + }); + + test("save() echoes group_pad", async ({ page }) => { + await restoreChart(page, { + plugin: "Y Bar", + columns: ["Sales"], + group_by: ["Region", "Category"], + plugin_config: { group_pad: 0.25 }, + }); + + const config = await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer")!; + return (await viewer.save()) as any; + }); + + expect(config.plugin_config.group_pad).toEqual(0.25); + }); +}); diff --git a/packages/viewer-charts/test/ts/snapshot/group-pad.spec.ts b/packages/viewer-charts/test/ts/snapshot/group-pad.spec.ts new file mode 100644 index 0000000000..c962cd32a8 --- /dev/null +++ b/packages/viewer-charts/test/ts/snapshot/group-pad.spec.ts @@ -0,0 +1,92 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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("group_pad", () => { + test.beforeEach(async ({ page }) => { + await gotoBasic(page); + }); + + test("X Bar two-level group_by", async ({ page }) => { + await renderAndCapture(page, { + plugin: "X Bar", + columns: ["Sales"], + group_by: ["Region", "Category"], + plugin_config: { band_inner_frac: 0.9, group_pad: 0.5 }, + }); + }); + + test("Y Bar two-level group_by", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Y Bar", + columns: ["Sales"], + group_by: ["Region", "Category"], + plugin_config: { band_inner_frac: 0.9, group_pad: 0.5 }, + }); + }); + + test("Y Bar three-level group_by", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Y Bar", + columns: ["Sales"], + group_by: ["Region", "Segment", "Category"], + plugin_config: { band_inner_frac: 0.9, group_pad: 0.5 }, + }); + }); + + test("Y Bar multiple columns", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Y Bar", + columns: ["Sales", "Profit"], + group_by: ["Region", "Category"], + plugin_config: { group_pad: 1 }, + }); + }); + + test("Y Line two-level group_by", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Y Line", + columns: ["Sales"], + group_by: ["Region", "Category"], + plugin_config: { group_pad: 1 }, + }); + }); + + test("Y Area two-level group_by", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Y Area", + columns: ["Sales"], + group_by: ["Region", "Category"], + plugin_config: { group_pad: 1 }, + }); + }); + + test("Y Scatter two-level group_by", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Y Scatter", + columns: ["Sales"], + group_by: ["Region", "Category"], + plugin_config: { group_pad: 1 }, + }); + }); + + test("Y Bar single-level group_by is unaffected", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Y Bar", + columns: ["Sales"], + group_by: ["Category"], + plugin_config: { group_pad: 1 }, + }); + }); +}); diff --git a/rust/perspective-viewer/src/themes/intl.css b/rust/perspective-viewer/src/themes/intl.css index 19a3044c15..18104957f6 100644 --- a/rust/perspective-viewer/src/themes/intl.css +++ b/rust/perspective-viewer/src/themes/intl.css @@ -186,6 +186,7 @@ perspective-viewer { --psp-label--point-size-px--content: "Point size"; --psp-label--band-inner-frac--content: "Band width"; --psp-label--bar-inner-pad--content: "Bar padding"; + --psp-label--group-pad--content: "Group padding"; --psp-label--wick-width-px--content: "Wick width"; --psp-label--ohlc-line-width-px--content: "OHLC line width"; --psp-label--include-zero--content: "Include zero"; diff --git a/rust/perspective-viewer/src/themes/intl/ar.css b/rust/perspective-viewer/src/themes/intl/ar.css index 29e56bff88..cb64e2fb0a 100644 --- a/rust/perspective-viewer/src/themes/intl/ar.css +++ b/rust/perspective-viewer/src/themes/intl/ar.css @@ -175,6 +175,7 @@ perspective-viewer { --psp-label--point-size-px--content: "حجم النقطة"; --psp-label--band-inner-frac--content: "عرض النطاق"; --psp-label--bar-inner-pad--content: "تباعد الأعمدة"; + --psp-label--group-pad--content: "تباعد المجموعات"; --psp-label--wick-width-px--content: "عرض الفتيل"; --psp-label--ohlc-line-width-px--content: "عرض خط OHLC"; --psp-label--include-zero--content: "تضمين الصفر"; diff --git a/rust/perspective-viewer/src/themes/intl/de.css b/rust/perspective-viewer/src/themes/intl/de.css index e872c874ea..31c94363b0 100644 --- a/rust/perspective-viewer/src/themes/intl/de.css +++ b/rust/perspective-viewer/src/themes/intl/de.css @@ -173,6 +173,7 @@ perspective-viewer { --psp-label--point-size-px--content: "Punktgröße"; --psp-label--band-inner-frac--content: "Bandbreite"; --psp-label--bar-inner-pad--content: "Balkenabstand"; + --psp-label--group-pad--content: "Gruppenabstand"; --psp-label--wick-width-px--content: "Dochtbreite"; --psp-label--ohlc-line-width-px--content: "OHLC-Linienbreite"; --psp-label--include-zero--content: "Null einschließen"; diff --git a/rust/perspective-viewer/src/themes/intl/es.css b/rust/perspective-viewer/src/themes/intl/es.css index 7ed9245c62..98dfc25813 100644 --- a/rust/perspective-viewer/src/themes/intl/es.css +++ b/rust/perspective-viewer/src/themes/intl/es.css @@ -173,6 +173,7 @@ perspective-viewer { --psp-label--point-size-px--content: "Tamaño del punto"; --psp-label--band-inner-frac--content: "Ancho de banda"; --psp-label--bar-inner-pad--content: "Separación de barras"; + --psp-label--group-pad--content: "Separación de grupos"; --psp-label--wick-width-px--content: "Ancho de mecha"; --psp-label--ohlc-line-width-px--content: "Ancho de línea OHLC"; --psp-label--include-zero--content: "Incluir cero"; diff --git a/rust/perspective-viewer/src/themes/intl/fr.css b/rust/perspective-viewer/src/themes/intl/fr.css index 509c544658..795e1d50f7 100644 --- a/rust/perspective-viewer/src/themes/intl/fr.css +++ b/rust/perspective-viewer/src/themes/intl/fr.css @@ -173,6 +173,7 @@ perspective-viewer { --psp-label--point-size-px--content: "Taille du point"; --psp-label--band-inner-frac--content: "Largeur de bande"; --psp-label--bar-inner-pad--content: "Espacement des barres"; + --psp-label--group-pad--content: "Espacement des groupes"; --psp-label--wick-width-px--content: "Largeur de mèche"; --psp-label--ohlc-line-width-px--content: "Épaisseur ligne OHLC"; --psp-label--include-zero--content: "Inclure zéro"; diff --git a/rust/perspective-viewer/src/themes/intl/ja.css b/rust/perspective-viewer/src/themes/intl/ja.css index 930b3117c1..541f060ecb 100644 --- a/rust/perspective-viewer/src/themes/intl/ja.css +++ b/rust/perspective-viewer/src/themes/intl/ja.css @@ -173,6 +173,7 @@ perspective-viewer { --psp-label--point-size-px--content: "点のサイズ"; --psp-label--band-inner-frac--content: "バンド幅"; --psp-label--bar-inner-pad--content: "バー間隔"; + --psp-label--group-pad--content: "グループ間隔"; --psp-label--wick-width-px--content: "ヒゲの幅"; --psp-label--ohlc-line-width-px--content: "OHLC線の幅"; --psp-label--include-zero--content: "ゼロを含める"; diff --git a/rust/perspective-viewer/src/themes/intl/pt.css b/rust/perspective-viewer/src/themes/intl/pt.css index b6afe0c8c3..a7042290e8 100644 --- a/rust/perspective-viewer/src/themes/intl/pt.css +++ b/rust/perspective-viewer/src/themes/intl/pt.css @@ -173,6 +173,7 @@ perspective-viewer { --psp-label--point-size-px--content: "Tamanho do ponto"; --psp-label--band-inner-frac--content: "Largura da banda"; --psp-label--bar-inner-pad--content: "Espaçamento das barras"; + --psp-label--group-pad--content: "Espaçamento dos grupos"; --psp-label--wick-width-px--content: "Largura do pavio"; --psp-label--ohlc-line-width-px--content: "Largura da linha OHLC"; --psp-label--include-zero--content: "Incluir zero"; diff --git a/rust/perspective-viewer/src/themes/intl/zh.css b/rust/perspective-viewer/src/themes/intl/zh.css index 13dfc4fff7..b5f059acdd 100644 --- a/rust/perspective-viewer/src/themes/intl/zh.css +++ b/rust/perspective-viewer/src/themes/intl/zh.css @@ -173,6 +173,7 @@ perspective-viewer { --psp-label--point-size-px--content: "点大小"; --psp-label--band-inner-frac--content: "带宽"; --psp-label--bar-inner-pad--content: "条形间距"; + --psp-label--group-pad--content: "分组间距"; --psp-label--wick-width-px--content: "影线宽度"; --psp-label--ohlc-line-width-px--content: "OHLC线宽"; --psp-label--include-zero--content: "包含零";