@perspective-dev/viewer-charts 5.2.0 → 5.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/cdn/perspective-viewer-charts.js +2 -2
  2. package/dist/cdn/perspective-viewer-charts.js.map +4 -4
  3. package/dist/esm/charts/candlestick/candlestick-render.d.ts +3 -0
  4. package/dist/esm/charts/cartesian/cartesian.d.ts +1 -0
  5. package/dist/esm/charts/chart-base.d.ts +22 -9
  6. package/dist/esm/charts/chart.d.ts +4 -0
  7. package/dist/esm/charts/common/category-axis-resolver.d.ts +1 -1
  8. package/dist/esm/charts/common/tree-chart.d.ts +1 -0
  9. package/dist/esm/charts/heatmap/heatmap.d.ts +1 -0
  10. package/dist/esm/charts/series/series-type.d.ts +18 -0
  11. package/dist/esm/charts/series/series.d.ts +4 -1
  12. package/dist/esm/charts/sunburst/sunburst-render.d.ts +4 -0
  13. package/dist/esm/perspective-viewer-charts.js +2 -2
  14. package/dist/esm/perspective-viewer-charts.js.map +4 -4
  15. package/dist/esm/plugin/plugin.d.ts +8 -1
  16. package/dist/esm/theme/gradient.d.ts +31 -0
  17. package/dist/esm/theme/overrides.d.ts +19 -0
  18. package/dist/esm/transport/protocol.d.ts +13 -2
  19. package/dist/esm/utils/css.d.ts +4 -0
  20. package/dist/esm/worker/renderer.worker.d.ts +27 -0
  21. package/package.json +1 -1
  22. package/src/ts/charts/candlestick/candlestick-build.ts +1 -0
  23. package/src/ts/charts/candlestick/candlestick-render.ts +8 -0
  24. package/src/ts/charts/candlestick/candlestick.ts +4 -3
  25. package/src/ts/charts/cartesian/cartesian-render.ts +5 -0
  26. package/src/ts/charts/cartesian/cartesian.ts +4 -0
  27. package/src/ts/charts/chart-base.ts +42 -10
  28. package/src/ts/charts/chart.ts +5 -0
  29. package/src/ts/charts/common/category-axis-resolver.ts +44 -5
  30. package/src/ts/charts/common/tree-chart.ts +4 -0
  31. package/src/ts/charts/heatmap/heatmap-build.ts +7 -1
  32. package/src/ts/charts/heatmap/heatmap-interact.ts +10 -1
  33. package/src/ts/charts/heatmap/heatmap-render.ts +5 -0
  34. package/src/ts/charts/heatmap/heatmap.ts +8 -3
  35. package/src/ts/charts/series/series-build.ts +1 -0
  36. package/src/ts/charts/series/series-interact.ts +2 -13
  37. package/src/ts/charts/series/series-render.ts +5 -0
  38. package/src/ts/charts/series/series-type.ts +65 -0
  39. package/src/ts/charts/series/series.ts +21 -8
  40. package/src/ts/charts/sunburst/sunburst-render.ts +9 -0
  41. package/src/ts/charts/treemap/treemap-render.ts +5 -0
  42. package/src/ts/layout/ticks.ts +36 -12
  43. package/src/ts/plugin/plugin.ts +95 -10
  44. package/src/ts/theme/gradient.ts +241 -1
  45. package/src/ts/theme/overrides.ts +76 -0
  46. package/src/ts/transport/protocol.ts +14 -2
  47. package/src/ts/transport/renderer-transport.ts +24 -10
  48. package/src/ts/utils/css.ts +12 -0
  49. package/src/ts/worker/renderer.worker.ts +113 -5
@@ -87,8 +87,10 @@ export declare class HTMLPerspectiveViewerWebGLPluginElement extends HTMLElement
87
87
  */
88
88
  static setBlitMode(mode: "direct" | "blit"): void;
89
89
  get_static_config(): PluginStaticConfig;
90
- column_config_schema(column_type: string, _group: string | undefined, _column_name: string, current_value: Record<string, unknown> | null, _viewer_config?: {
90
+ column_config_schema(column_type: string, group: string | undefined, column_name: string, current_value: Record<string, unknown> | null, viewer_config?: {
91
+ columns?: (string | null)[];
91
92
  group_by?: string[];
93
+ split_by?: string[];
92
94
  group_rollup_mode?: string;
93
95
  }): {
94
96
  fields: (Record<string, unknown> & {
@@ -103,6 +105,11 @@ export declare class HTMLPerspectiveViewerWebGLPluginElement extends HTMLElement
103
105
  kind: string;
104
106
  })[];
105
107
  };
108
+ private _resolvedTheme;
109
+ private static readonly GRADIENT_PALETTE_FALLBACK_COUNT;
110
+ private _themeSeriesPaletteHex;
111
+ private _themeSeriesColorHex;
112
+ private _themeGradientStopsSpec;
106
113
  draw(view: View): Promise<void>;
107
114
  update(view: View): Promise<void>;
108
115
  /**
@@ -15,6 +15,37 @@ export interface GradientStop {
15
15
  * that never set the gradient still produce sane output.
16
16
  */
17
17
  export declare function parseCssGradient(src: string | null | undefined): GradientStop[];
18
+ /**
19
+ * A color literal — `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`, `rgb()` or
20
+ * `rgba()` — as a `[0, 1]` RGB triple, or `null` for anything else.
21
+ */
22
+ export declare function parseCssColorStrict(src: string): [number, number, number] | null;
23
+ /**
24
+ * The shared `linear-gradient(...)` tokenizer of the viewer's CSS-valued
25
+ * config grammar.
26
+ */
27
+ export declare function tokenizeLinearGradient(src: string): Array<[[number, number, number], number | null]> | null;
28
+ /**
29
+ * Strict gradient reader: a `linear-gradient(...)` with ≥ 2 stops,
30
+ * positions optional, offsets clamped to `[0, 1]` and sorted, or `null`
31
+ * on malformed input.
32
+ */
33
+ export declare function parseCssGradientStrict(src: string): GradientStop[] | null;
34
+ /**
35
+ * Strict palette reader: a `linear-gradient(...)` of ≥ 1 colors with no
36
+ * positions, or `null` on malformed input.
37
+ */
38
+ export declare function parseCssColorList(src: string): Array<[number, number, number]> | null;
39
+ /**
40
+ * The viewer's canonical gradient string for `stops`: `linear-gradient(to
41
+ * right, #rrggbb P%, …)`, sorted, positions at 0.1% resolution.
42
+ */
43
+ export declare function stopsToCss(stops: GradientStop[]): string;
44
+ /**
45
+ * The viewer's canonical palette string for `colors`: the same frame
46
+ * with bare `#rrggbb` entries and no positions.
47
+ */
48
+ export declare function colorsToCss(colors: Array<[number, number, number]>): string;
18
49
  /**
19
50
  * Piecewise-linear color sample at `t ∈ [0, 1]`. Returns RGBA in [0, 1].
20
51
  * Clamps `t` to the gradient's first/last stop outside `[0, 1]`.
@@ -0,0 +1,19 @@
1
+ import { type GradientStop } from "./gradient";
2
+ import type { Theme } from "./theme";
3
+ import type { Vec3 } from "./palette";
4
+ /**
5
+ * Per-column color-scale overrides from the viewer's `columns_config`,
6
+ * patched into the resolved `Theme`'s `seriesPalette` / `gradientStops`
7
+ * fields.
8
+ */
9
+ export declare function applyColumnColorOverrides(theme: Theme, columnsConfig: Record<string, any> | undefined, colorColumn: string | null): Theme;
10
+ /**
11
+ * Parse a stored `palette` value into the `seriesPalette` shape, or
12
+ * `null` on anything malformed.
13
+ */
14
+ export declare function parsePaletteOverride(raw: unknown): Vec3[] | null;
15
+ /**
16
+ * Parse a stored `gradient` value into sorted `GradientStop[]`, or
17
+ * `null` on malformed input or fewer than 2 stops.
18
+ */
19
+ export declare function parseGradientOverride(raw: unknown): GradientStop[] | null;
@@ -49,8 +49,19 @@ export interface InitMsg {
49
49
  * `dpr` and there is no host-side GL drawing buffer.
50
50
  */
51
51
  glCanvas?: OffscreenCanvas;
52
- gridlinesCanvas: OffscreenCanvas;
53
- chromeCanvas: OffscreenCanvas;
52
+ /**
53
+ * The gridlines (bottom) and chrome/axes (top) 2D layers.
54
+ * Transferred via `transferControlToOffscreen` on the host, present
55
+ * iff `renderMode === "direct"` — their pixels reach the screen
56
+ * through the compositor. In blit mode the renderer allocates
57
+ * worker-local surfaces instead and composites both layers into the
58
+ * shipped `FrameBitmapMsg`, so every layer of the frame rides the
59
+ * host's staged-present hold (a transferred canvas presents on the
60
+ * compositor's own schedule, unsynchronized with the host's layout
61
+ * commits — the one-frame axes warp on anticipated resizes).
62
+ */
63
+ gridlinesCanvas?: OffscreenCanvas;
64
+ chromeCanvas?: OffscreenCanvas;
54
65
  /**
55
66
  * `MessagePort` to the host's `ProxySession`. Worker mode only —
56
67
  * the worker bootstraps a fresh `Client` and bridges it through
@@ -1 +1,5 @@
1
1
  export declare function parseCSSColorToVec3(colorStr: string): [number, number, number];
2
+ /**
3
+ * Format a `[0, 1]`-range RGB triple as a plain `#rrggbb` string.
4
+ */
5
+ export declare function vec3ToHexColor(c: [number, number, number]): string;
@@ -13,6 +13,17 @@ export declare class WorkerRenderer {
13
13
  cssWidth: number;
14
14
  cssHeight: number;
15
15
  dpr: number;
16
+ /**
17
+ * Blit-mode compose surface: gridlines + GL frame + chrome are
18
+ * drawn here each `endFrame` and shipped as ONE `ImageBitmap`, so
19
+ * every layer rides the host's staged-present hold (see
20
+ * `InitMsg.gridlinesCanvas`). `null` in direct mode.
21
+ */
22
+ private _composeCanvas;
23
+ private _composeCtx;
24
+ private _lastPlot;
25
+ private _frameSerial;
26
+ private _overlayPresentQueued;
16
27
  client: Client;
17
28
  view: View;
18
29
  /**
@@ -104,6 +115,22 @@ export declare class WorkerRenderer {
104
115
  * mouse-event branches fall through.
105
116
  */
106
117
  private _tooltip;
118
+ /**
119
+ * Blit-mode frame composite: draw the gridlines (bottom), the GL
120
+ * plot bitmap (middle) and the chrome/axes (top) into the compose
121
+ * surface and transfer the result — the single `ImageBitmap` the
122
+ * host blits, and the unit the staged-present hold stages. Runs in
123
+ * `endFrame`, which the scheduler orders after `render2D`'s 2D
124
+ * flush, so both layers hold this frame's content.
125
+ *
126
+ * A layer whose buffer differs from the plot's dimensions is
127
+ * skipped rather than scaled: its content is a stale-sized frame
128
+ * mid-resize (the 2D draws re-size their canvas to `css × dpr` on
129
+ * the next flush), and scaling it would re-introduce exactly the
130
+ * warp this composite exists to prevent.
131
+ */
132
+ private _composeFrame;
133
+ private _presentOverlay;
107
134
  /**
108
135
  * Composite the three layers into a single PNG `Blob`.
109
136
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@perspective-dev/viewer-charts",
3
- "version": "5.2.0",
3
+ "version": "5.3.0",
4
4
  "description": "Perspective.js WebGL Plugin",
5
5
  "unpkg": "./dist/cdn/perspective-viewer-charts.js",
6
6
  "jsdelivr": "./dist/cdn/perspective-viewer-charts.js",
@@ -281,6 +281,7 @@ export function buildCandlestickPipeline(
281
281
  numRows,
282
282
  groupBy.length,
283
283
  levelTypes,
284
+ axisMode,
284
285
  );
285
286
  if (numCategories === 0) {
286
287
  return {
@@ -230,7 +230,15 @@ export function renderCandlestickFrame(
230
230
  chart._defer2D(() => renderCandlestickChromeOverlay(chart));
231
231
  }
232
232
 
233
+ /**
234
+ * Draw axes chrome + (optional) tooltip onto the overlay canvas.
235
+ */
233
236
  export function renderCandlestickChromeOverlay(chart: CandlestickChart): void {
237
+ paintCandlestickChromeOverlay(chart);
238
+ chart.presentOverlay();
239
+ }
240
+
241
+ function paintCandlestickChromeOverlay(chart: CandlestickChart): void {
234
242
  if (
235
243
  !chart._chromeCanvas ||
236
244
  !chart._lastLayout ||
@@ -212,9 +212,10 @@ export class CandlestickChart extends CategoricalYChart {
212
212
 
213
213
  const catIdx = this._candles.catIdx[idx];
214
214
  const splitIdx = this._candles.splitIdx[idx];
215
- const groupByValues: (string | null)[] = this._rowPaths.map(
216
- (level) => level.labels[catIdx] ?? null,
217
- );
215
+ const groupByValues: (string | number | null)[] =
216
+ this._categoryAxisMode === "numeric" && this._categoryPositions
217
+ ? [this._categoryPositions[catIdx] ?? null]
218
+ : this._rowPaths.map((level) => level.labels[catIdx] ?? null);
218
219
  const splitKey = this._splitPrefixes[splitIdx] ?? "";
219
220
  const splitByValues =
220
221
  this._splitBy.length > 0 && splitKey !== ""
@@ -699,6 +699,11 @@ function renderFacetedFrame(
699
699
  * Redraw the chrome canvas only. Used for lightweight hover updates.
700
700
  */
701
701
  export function renderCartesianChromeOverlay(chart: CartesianChart): void {
702
+ paintCartesianChromeOverlay(chart);
703
+ chart.presentOverlay();
704
+ }
705
+
706
+ function paintCartesianChromeOverlay(chart: CartesianChart): void {
702
707
  if (
703
708
  !chart._chromeCanvas ||
704
709
  !chart._lastLayout ||
@@ -61,6 +61,10 @@ export class CartesianChart extends AbstractChart {
61
61
  this.glyph = glyph;
62
62
  }
63
63
 
64
+ protected override colorScaleColumn(): string | null {
65
+ return this._colorName || null;
66
+ }
67
+
64
68
  /**
65
69
  * Rendering pipeline selector. `"cartesian"` is the default —
66
70
  * draws axes, gridlines, and ticks via the chrome canvas.
@@ -44,6 +44,7 @@ import {
44
44
  import type { PerspectiveClickDetail } from "../event-detail";
45
45
  import type { ViewConfig } from "@perspective-dev/client";
46
46
  import { resolveThemeFromVars, type Theme } from "../theme/theme";
47
+ import { applyColumnColorOverrides } from "../theme/overrides";
47
48
  import { requestRender as scheduleRender } from "../render/scheduler";
48
49
 
49
50
  // TODO I don't know if this is the behavior we want. On the plus side, this
@@ -141,6 +142,7 @@ export abstract class AbstractChart implements ChartImplementation {
141
142
  _glManager: WebGLContextManager | null = null;
142
143
  _gridlineCanvas: HTMLCanvasElement | OffscreenCanvas | null = null;
143
144
  _chromeCanvas: HTMLCanvasElement | OffscreenCanvas | null = null;
145
+ _overlayPresenter: (() => void) | null = null;
144
146
 
145
147
  /**
146
148
  * 2D-canvas draw closures collected during `_fullRender` (the GL
@@ -259,12 +261,16 @@ export abstract class AbstractChart implements ChartImplementation {
259
261
 
260
262
  /**
261
263
  * Cached resolved theme — populated on first `_resolveTheme()` call,
262
- * cleared by `invalidateTheme()` (driven from `plugin.restyle()`).
263
- * `getComputedStyle` / `getPropertyValue` reads cost ~100µs each;
264
- * zoom/hover dispatch redraws at 60Hz so we resolve once and reuse.
264
+ * cleared by `invalidateTheme()` (driven from `plugin.restyle()`)
265
+ * and by `setColumnsConfig` (per-column color overrides patch the
266
+ * resolved theme). `getComputedStyle` / `getPropertyValue` reads
267
+ * cost ~100µs each; zoom/hover dispatch redraws at 60Hz so we
268
+ * resolve once and reuse.
265
269
  */
266
270
  _theme: Theme | null = null;
267
271
 
272
+ _themeColorColumn: string | null = null;
273
+
268
274
  /**
269
275
  * On-demand single-row fetcher used by lazy tooltip column
270
276
  * lookups. Reset on every `setView` call; subclasses read
@@ -287,6 +293,18 @@ export abstract class AbstractChart implements ChartImplementation {
287
293
  this._chromeCanvas = canvas;
288
294
  }
289
295
 
296
+ setOverlayPresenter(cb: () => void): void {
297
+ this._overlayPresenter = cb;
298
+ }
299
+
300
+ /**
301
+ * Present a chrome-overlay repaint that ran outside the scheduler's
302
+ * frame chain.
303
+ */
304
+ presentOverlay(): void {
305
+ this._overlayPresenter?.();
306
+ }
307
+
290
308
  setTheme(vars: Record<string, string>): void {
291
309
  this._themeVars = vars;
292
310
  this._theme = null;
@@ -434,6 +452,8 @@ export abstract class AbstractChart implements ChartImplementation {
434
452
  setColumnsConfig(cfg: Record<string, any>): void {
435
453
  this._columnsConfig = cfg ?? {};
436
454
  this._rebuildColumnFormatters();
455
+
456
+ this._theme = null;
437
457
  }
438
458
 
439
459
  /**
@@ -599,16 +619,28 @@ export abstract class AbstractChart implements ChartImplementation {
599
619
  cfg.series_zoom_mode === "dynamic";
600
620
  }
601
621
 
622
+ protected colorScaleColumn(): string | null {
623
+ return null;
624
+ }
625
+
602
626
  /**
603
- * Lazily decode the host-supplied theme vars. Subsequent calls hit
604
- * the cache until `invalidateTheme()` clears it. Render-path
605
- * callers should always read theme values through this method so
606
- * the parsed `Theme` (gradient stops, palette, etc.) amortizes
607
- * across an entire frame.
627
+ * Lazily decode the host-supplied theme vars, then patch in any
628
+ * per-column color-scale override for `colorScaleColumn()`.
629
+ * Subsequent calls hit the cache until `invalidateTheme()` /
630
+ * `setColumnsConfig` clears it or the color column changes.
631
+ * Render-path callers should always read theme values through this
632
+ * method so the parsed `Theme` (gradient stops, palette, etc.)
633
+ * amortizes across an entire frame.
608
634
  */
609
635
  _resolveTheme(): Theme {
610
- if (!this._theme) {
611
- this._theme = resolveThemeFromVars(this._themeVars);
636
+ const colorColumn = this.colorScaleColumn();
637
+ if (!this._theme || this._themeColorColumn !== colorColumn) {
638
+ this._themeColorColumn = colorColumn;
639
+ this._theme = applyColumnColorOverrides(
640
+ resolveThemeFromVars(this._themeVars),
641
+ this._columnsConfig,
642
+ colorColumn,
643
+ );
612
644
  }
613
645
 
614
646
  return this._theme;
@@ -84,6 +84,11 @@ export interface ChartImplementation {
84
84
  */
85
85
  setChromeCanvas?(canvas: HTMLCanvasElement | OffscreenCanvas): void;
86
86
 
87
+ /**
88
+ * Install the renderer's overlay-present hook.
89
+ */
90
+ setOverlayPresenter?(cb: () => void): void;
91
+
87
92
  /**
88
93
  * Hand the chart a pre-computed CSS-variable map produced on the
89
94
  * main thread via `snapshotThemeVars(el)`, which it can decode into
@@ -203,15 +203,27 @@ export function synthesizeStringLevel(
203
203
  const seen = new Map<string, number>();
204
204
  seen.set("", 0);
205
205
 
206
+ const valueIdx = new Map<number, number>();
207
+
206
208
  for (let r = 0; r < numRows; r++) {
207
209
  const isValid = valid ? !!((valid[r >> 3] >> (r & 7)) & 1) : true;
210
+ if (!isValid) {
211
+ indices[r] = 0;
212
+ continue;
213
+ }
214
+
208
215
  const v = values[r] as number;
209
- const label = formatLevelValue(v, isValid, levelType);
210
- let dictIdx = seen.get(label);
216
+ let dictIdx = valueIdx.get(v);
211
217
  if (dictIdx === undefined) {
212
- dictIdx = dictionary.length;
213
- dictionary.push(label);
214
- seen.set(label, dictIdx);
218
+ const label = formatLevelValue(v, true, levelType);
219
+ dictIdx = seen.get(label);
220
+ if (dictIdx === undefined) {
221
+ dictIdx = dictionary.length;
222
+ dictionary.push(label);
223
+ seen.set(label, dictIdx);
224
+ }
225
+
226
+ valueIdx.set(v, dictIdx);
215
227
  }
216
228
 
217
229
  indices[r] = dictIdx;
@@ -240,7 +252,34 @@ export function resolveCategoryAxis(
240
252
  numRows: number,
241
253
  groupByLen: number,
242
254
  levelTypes: string[] = [],
255
+ axisMode?: AxisMode,
243
256
  ): CategoryAxisResult {
257
+ if (axisMode?.mode === "numeric") {
258
+ const rp = columns.get("__ROW_PATH_0__");
259
+ if (rp && rp.type !== "string" && rp.values) {
260
+ let rowOffset = 0;
261
+ const values = rp.values;
262
+ const valid = rp.valid;
263
+ while (rowOffset < numRows) {
264
+ const isValid = valid
265
+ ? !!((valid[rowOffset >> 3] >> (rowOffset & 7)) & 1)
266
+ : true;
267
+ const v = values[rowOffset] as number;
268
+ if (isValid && v != null && !Number.isNaN(v)) {
269
+ break;
270
+ }
271
+
272
+ rowOffset++;
273
+ }
274
+
275
+ return {
276
+ rowPaths: [],
277
+ numCategories: Math.max(0, numRows - rowOffset),
278
+ rowOffset,
279
+ };
280
+ }
281
+ }
282
+
244
283
  type RawLevel = { indices: Int32Array; dictionary: string[] };
245
284
  const rawRowPaths: RawLevel[] = [];
246
285
  for (let n = 0; ; n++) {
@@ -40,6 +40,10 @@ export function firstNonMetadataColumn(columns: ColumnDataMap): string {
40
40
  * layout modules can read/write them without friction.
41
41
  */
42
42
  export abstract class TreeChartBase extends AbstractChart {
43
+ protected override colorScaleColumn(): string | null {
44
+ return this._columnSlots[1] || null;
45
+ }
46
+
43
47
  // Shared column-slot resolution
44
48
  _sizeName = "";
45
49
  _colorName = "";
@@ -128,7 +128,13 @@ export function buildHeatmapPipeline(
128
128
  rowPaths: xLevels,
129
129
  numCategories: numX,
130
130
  rowOffset,
131
- } = resolveCategoryAxis(columns, numRows, groupBy.length, levelTypes);
131
+ } = resolveCategoryAxis(
132
+ columns,
133
+ numRows,
134
+ groupBy.length,
135
+ levelTypes,
136
+ xAxisMode,
137
+ );
132
138
 
133
139
  // Numeric X domain: sourced from `__ROW_PATH_0__`'s raw values when
134
140
  // the single group_by is non-string.
@@ -215,6 +215,7 @@ export function renderHeatmapTooltip(chart: HeatmapChart): void {
215
215
  let layout: import("../../layout/plot-layout").PlotLayout | null;
216
216
  let xLevels: CategoricalLevel[];
217
217
  let yLevels: CategoricalLevel[];
218
+ let xPositions: Float64Array | null;
218
219
  let facetLabel: string | null = null;
219
220
 
220
221
  if (chart._hoveredFacetIdx >= 0) {
@@ -226,6 +227,7 @@ export function renderHeatmapTooltip(chart: HeatmapChart): void {
226
227
  layout = facet.layout;
227
228
  xLevels = facet.pipeline.xLevels;
228
229
  yLevels = facet.pipeline.yLevels;
230
+ xPositions = facet.pipeline.xPositions;
229
231
  facetLabel = facet.label;
230
232
  } else {
231
233
  if (!chart._lastLayout) {
@@ -235,6 +237,7 @@ export function renderHeatmapTooltip(chart: HeatmapChart): void {
235
237
  layout = chart._lastLayout;
236
238
  xLevels = chart._xLevels;
237
239
  yLevels = chart._yLevels;
240
+ xPositions = chart._xPositions;
238
241
  }
239
242
 
240
243
  const cell = chart._hoveredCell;
@@ -249,7 +252,13 @@ export function renderHeatmapTooltip(chart: HeatmapChart): void {
249
252
  lines.push(facetLabel);
250
253
  }
251
254
 
252
- const xPath = formatHierarchicalPath(xLevels, cell.xIdx);
255
+ const xPath =
256
+ chart._xAxisMode.mode === "numeric" && xPositions
257
+ ? chart.getColumnFormatter(
258
+ chart._groupBy[0],
259
+ "value",
260
+ )(xPositions[cell.xIdx])
261
+ : formatHierarchicalPath(xLevels, cell.xIdx);
253
262
  const yPath = formatHierarchicalPath(yLevels, cell.yIdx);
254
263
  if (xPath) {
255
264
  lines.push(xPath);
@@ -392,6 +392,11 @@ function drawCellsInstanced(
392
392
  * Chrome overlay: X axis + Y axis + color legend + (optional) tooltip.
393
393
  */
394
394
  export function renderHeatmapChromeOverlay(chart: HeatmapChart): void {
395
+ paintHeatmapChromeOverlay(chart);
396
+ chart.presentOverlay();
397
+ }
398
+
399
+ function paintHeatmapChromeOverlay(chart: HeatmapChart): void {
395
400
  if (!chart._chromeCanvas) {
396
401
  return;
397
402
  }
@@ -69,6 +69,10 @@ export interface HeatmapFacet {
69
69
  * scale and a single legend.
70
70
  */
71
71
  export class HeatmapChart extends AbstractChart {
72
+ protected override colorScaleColumn(): string | null {
73
+ return this._columnSlots[0] || null;
74
+ }
75
+
72
76
  _program: WebGLProgram | null = null;
73
77
  _locations: HeatmapLocations | null = null;
74
78
  _cornerBuffer: WebGLBuffer | null = null;
@@ -176,9 +180,10 @@ export class HeatmapChart extends AbstractChart {
176
180
  xIdx: number,
177
181
  yIdx: number,
178
182
  ): Promise<void> {
179
- const groupByValues: (string | null)[] = this._xLevels.map(
180
- (level) => level.labels[xIdx] ?? null,
181
- );
183
+ const groupByValues: (string | number | null)[] =
184
+ this._xAxisMode.mode === "numeric" && this._xPositions
185
+ ? [this._xPositions[xIdx] ?? null]
186
+ : this._xLevels.map((level) => level.labels[xIdx] ?? null);
182
187
  const splitByValues: (string | null)[] = this._yLevels
183
188
  .slice(0, this._splitBy.length)
184
189
  .map((level) => level.labels[yIdx] ?? null);
@@ -473,6 +473,7 @@ export function buildSeriesPipeline(
473
473
  numRows,
474
474
  groupBy.length,
475
475
  levelTypes,
476
+ axisMode,
476
477
  );
477
478
 
478
479
  if (numCategories === 0) {
@@ -628,19 +628,8 @@ export function formatBarCategoryPath(
628
628
  chart: SeriesChart,
629
629
  catIdx: number,
630
630
  ): string {
631
- // Numeric category mode: resolve from the bar's xCenter (real data
632
- // value) rather than the row-path label array, which is empty when
633
- // the single group_by level is non-string.
634
- if (chart._categoryAxisMode === "numeric" && chart._numericCategoryDomain) {
635
- const bars = chart._bars;
636
- let v: number | null = null;
637
- for (let i = 0; i < bars.count; i++) {
638
- if (bars.catIdx[i] === catIdx) {
639
- v = bars.xCenter[i];
640
- break;
641
- }
642
- }
643
-
631
+ if (chart._categoryAxisMode === "numeric" && chart._categoryPositions) {
632
+ const v = chart._categoryPositions[catIdx];
644
633
  if (v == null) {
645
634
  return "";
646
635
  }
@@ -1088,6 +1088,11 @@ export function layoutForRecord(
1088
1088
  * Draw axes chrome + legend + tooltip onto the overlay canvas.
1089
1089
  */
1090
1090
  export function renderBarChromeOverlay(chart: SeriesChart): void {
1091
+ paintBarChromeOverlay(chart);
1092
+ chart.presentOverlay();
1093
+ }
1094
+
1095
+ function paintBarChromeOverlay(chart: SeriesChart): void {
1091
1096
  if (
1092
1097
  !chart._chromeCanvas ||
1093
1098
  !chart._lastLayout ||
@@ -10,6 +10,8 @@
10
10
  // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11
11
  // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
12
 
13
+ import { parseCssColorList, parseCssColorStrict } from "../../theme/gradient";
14
+
13
15
  export type ChartType = "bar" | "line" | "scatter" | "area";
14
16
 
15
17
  /**
@@ -45,6 +47,18 @@ export interface ColumnChartConfig {
45
47
  * `"skip"`). Default `"skip"`. No effect on bar / scatter.
46
48
  */
47
49
  interpolate?: InterpolateMode;
50
+
51
+ /**
52
+ * Single series-color override (`#rrggbb`) — the no-`split_by`
53
+ * shape.
54
+ */
55
+ color?: string;
56
+
57
+ /**
58
+ * Discrete color palette cycled over this column's split-generated
59
+ * series — the `split_by` shape.
60
+ */
61
+ palette?: string;
48
62
  }
49
63
 
50
64
  /**
@@ -98,6 +112,57 @@ export function resolveAltAxis(
98
112
  return cfg?.[aggName]?.alt_axis === true;
99
113
  }
100
114
 
115
+ /**
116
+ * Resolve a per-series color override from `columns_config`, or `null`
117
+ * when the theme palette applies.
118
+ */
119
+ export function resolveSeriesColorOverride(
120
+ series: { aggName: string; splitIdx: number },
121
+ facetActive: boolean,
122
+ cfg: Record<string, ColumnChartConfig> | undefined,
123
+ ): [number, number, number] | null {
124
+ const entry = cfg?.[series.aggName];
125
+ if (!entry) {
126
+ return null;
127
+ }
128
+
129
+ const palette =
130
+ typeof entry.palette === "string"
131
+ ? parsePaletteCached(entry.palette)
132
+ : null;
133
+ if (palette && palette.length > 0) {
134
+ const idx = facetActive ? 0 : series.splitIdx % palette.length;
135
+ return palette[idx];
136
+ }
137
+
138
+ if (typeof entry.color === "string") {
139
+ return parseCssColorStrict(entry.color) ?? null;
140
+ }
141
+
142
+ return null;
143
+ }
144
+
145
+ const PALETTE_PARSE_CACHE = new Map<
146
+ string,
147
+ Array<[number, number, number]> | null
148
+ >();
149
+
150
+ function parsePaletteCached(
151
+ src: string,
152
+ ): Array<[number, number, number]> | null {
153
+ let parsed = PALETTE_PARSE_CACHE.get(src);
154
+ if (parsed === undefined) {
155
+ parsed = parseCssColorList(src);
156
+ if (PALETTE_PARSE_CACHE.size > 256) {
157
+ PALETTE_PARSE_CACHE.clear();
158
+ }
159
+
160
+ PALETTE_PARSE_CACHE.set(src, parsed);
161
+ }
162
+
163
+ return parsed;
164
+ }
165
+
101
166
  /**
102
167
  * Resolve the interpolation mode for this aggregate.
103
168
  */