@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
@@ -41,6 +41,7 @@ import {
41
41
  showBarPinnedTooltipForSample,
42
42
  } from "./series-interact";
43
43
  import { resolvePalette } from "../../theme/palette";
44
+ import { resolveSeriesColorOverride } from "./series-type";
44
45
  import { LineGlyph } from "./glyphs/draw-lines";
45
46
  import { ScatterGlyph } from "./glyphs/draw-scatter";
46
47
  import { AreaGlyph } from "./glyphs/draw-areas";
@@ -250,13 +251,16 @@ export class SeriesChart extends CategoricalYChart {
250
251
  /**
251
252
  * Cached palette + identity-keys for short-circuiting per-frame
252
253
  * resolution. Inputs (`seriesPalette` ref, `gradientStops` ref,
253
- * `series.length`) only change on data load or `restyle()`.
254
+ * `series.length`, `_columnsConfig` ref — per-series color
255
+ * overrides) only change on data load, `restyle()`, or
256
+ * `setColumnsConfig`.
254
257
  */
255
258
  _paletteCache: [number, number, number][] | null = null;
256
259
  _paletteCacheKey: {
257
260
  seriesPalette: [number, number, number][] | null;
258
261
  gradientStops: unknown;
259
262
  seriesLength: number;
263
+ columnsConfig: unknown;
260
264
  } | null = null;
261
265
 
262
266
  /**
@@ -514,9 +518,10 @@ export class SeriesChart extends CategoricalYChart {
514
518
  return;
515
519
  }
516
520
 
517
- const groupByValues: (string | null)[] = this._rowPaths.map(
518
- (level) => level.labels[b.catIdx] ?? null,
519
- );
521
+ const groupByValues: (string | number | null)[] =
522
+ this._categoryAxisMode === "numeric" && this._categoryPositions
523
+ ? [this._categoryPositions[b.catIdx] ?? null]
524
+ : this._rowPaths.map((level) => level.labels[b.catIdx] ?? null);
520
525
  const splitKey = this._splitPrefixes[b.splitIdx] ?? "";
521
526
  const splitByValues =
522
527
  this._splitBy.length > 0 && splitKey !== ""
@@ -903,6 +908,7 @@ export function ensurePalette(chart: SeriesChart): boolean {
903
908
  const theme = chart._resolveTheme();
904
909
  const seriesPalette = theme.seriesPalette;
905
910
  const gradientStops = theme.gradientStops;
911
+ const columnsConfig = chart._columnsConfig;
906
912
  const paletteCount = chart._facetActive
907
913
  ? Math.max(1, chart._aggregates.length)
908
914
  : chart._series.length;
@@ -913,7 +919,8 @@ export function ensurePalette(chart: SeriesChart): boolean {
913
919
  key &&
914
920
  key.seriesPalette === seriesPalette &&
915
921
  key.gradientStops === gradientStops &&
916
- key.seriesLength === paletteCount
922
+ key.seriesLength === paletteCount &&
923
+ key.columnsConfig === columnsConfig
917
924
  ) {
918
925
  return false;
919
926
  }
@@ -928,12 +935,18 @@ export function ensurePalette(chart: SeriesChart): boolean {
928
935
  seriesPalette,
929
936
  gradientStops,
930
937
  seriesLength: paletteCount,
938
+ columnsConfig,
931
939
  };
932
940
 
933
941
  for (let i = 0; i < chart._series.length; i++) {
934
- chart._series[i].color = chart._facetActive
935
- ? palette[chart._series[i].aggIdx]
936
- : palette[i];
942
+ const series = chart._series[i];
943
+ const themed = chart._facetActive ? palette[series.aggIdx] : palette[i];
944
+ series.color =
945
+ resolveSeriesColorOverride(
946
+ series,
947
+ chart._facetActive,
948
+ columnsConfig,
949
+ ) ?? themed;
937
950
  }
938
951
 
939
952
  return true;
@@ -501,7 +501,16 @@ function drawArcs(
501
501
 
502
502
  // Chrome overlay (Canvas2D)
503
503
 
504
+ /**
505
+ * Render the chrome overlay (labels, center text, hover highlight +
506
+ * tooltip).
507
+ */
504
508
  export function renderSunburstChromeOverlay(chart: SunburstChart): void {
509
+ paintSunburstChromeOverlay(chart);
510
+ chart.presentOverlay();
511
+ }
512
+
513
+ function paintSunburstChromeOverlay(chart: SunburstChart): void {
505
514
  if (!chart._chromeCanvas || chart._currentRootId === NULL_NODE) {
506
515
  return;
507
516
  }
@@ -467,6 +467,11 @@ function emitRect(
467
467
  * tooltip + highlight on top.
468
468
  */
469
469
  export function renderTreemapChromeOverlay(chart: TreemapChart): void {
470
+ paintTreemapChromeOverlay(chart);
471
+ chart.presentOverlay();
472
+ }
473
+
474
+ function paintTreemapChromeOverlay(chart: TreemapChart): void {
470
475
  if (!chart._chromeCanvas || chart._currentRootId === NULL_NODE) {
471
476
  return;
472
477
  }
@@ -111,6 +111,30 @@ export function formatTickValue(val: number): string {
111
111
  return val.toFixed(2);
112
112
  }
113
113
 
114
+ /**
115
+ * Cached `Intl.DateTimeFormat` per option shape. `Date.prototype.
116
+ * toLocale*` constructs a fresh `DateTimeFormat` (plus its ICU
117
+ * backing) on EVERY call — ~30µs each — which turned per-row label
118
+ * synthesis over large pivots into a multi-second stall. A cached
119
+ * formatter's `format()` is ~1µs. Keyed by precision tier; the
120
+ * default locale is fixed for the lifetime of the worker, so entries
121
+ * never invalidate.
122
+ */
123
+ const DATE_FORMAT_CACHE = new Map<string, Intl.DateTimeFormat>();
124
+
125
+ function cachedDateFormat(
126
+ key: string,
127
+ options: Intl.DateTimeFormatOptions,
128
+ ): Intl.DateTimeFormat {
129
+ let fmt = DATE_FORMAT_CACHE.get(key);
130
+ if (!fmt) {
131
+ fmt = new Intl.DateTimeFormat(undefined, options);
132
+ DATE_FORMAT_CACHE.set(key, fmt);
133
+ }
134
+
135
+ return fmt;
136
+ }
137
+
114
138
  /**
115
139
  * Format a timestamp (ms since epoch) as a human-readable date/time label.
116
140
  * Adapts precision based on the tick spacing.
@@ -129,49 +153,49 @@ export function formatDateTickValue(val: number, stepMs?: number): string {
129
153
 
130
154
  if (stepMs >= DAY * 28) {
131
155
  // Monthly or longer — show year-month
132
- return d.toLocaleDateString(undefined, {
156
+ return cachedDateFormat("ym", {
133
157
  year: "numeric",
134
158
  month: "short",
135
- });
159
+ }).format(d);
136
160
  }
137
161
 
138
162
  if (stepMs >= DAY) {
139
163
  // Daily — show month and day
140
- return d.toLocaleDateString(undefined, {
164
+ return cachedDateFormat("md", {
141
165
  month: "short",
142
166
  day: "numeric",
143
- });
167
+ }).format(d);
144
168
  }
145
169
 
146
170
  if (stepMs >= HOUR) {
147
171
  // Hourly
148
- return d.toLocaleString(undefined, {
172
+ return cachedDateFormat("mdh", {
149
173
  month: "short",
150
174
  day: "numeric",
151
175
  hour: "numeric",
152
- });
176
+ }).format(d);
153
177
  }
154
178
 
155
179
  if (stepMs >= MINUTE) {
156
180
  // Minutes
157
- return d.toLocaleTimeString(undefined, {
181
+ return cachedDateFormat("hm", {
158
182
  hour: "numeric",
159
183
  minute: "2-digit",
160
- });
184
+ }).format(d);
161
185
  }
162
186
 
163
187
  // Sub-minute
164
- return d.toLocaleTimeString(undefined, {
188
+ return cachedDateFormat("hms", {
165
189
  hour: "numeric",
166
190
  minute: "2-digit",
167
191
  second: "2-digit",
168
- });
192
+ }).format(d);
169
193
  }
170
194
 
171
195
  // Default: show date only
172
- return d.toLocaleDateString(undefined, {
196
+ return cachedDateFormat("ymd", {
173
197
  year: "numeric",
174
198
  month: "short",
175
199
  day: "numeric",
176
- });
200
+ }).format(d);
177
201
  }
@@ -10,6 +10,7 @@
10
10
  // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11
11
  // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
12
 
13
+ import { colorsToCss, stopsToCss } from "../theme/gradient";
13
14
  import type { View } from "@perspective-dev/client";
14
15
  import type {
15
16
  HTMLPerspectiveViewerElement,
@@ -27,6 +28,10 @@ import {
27
28
  import { RawEventForwarder } from "../interaction/raw-event-forwarder";
28
29
  import { RendererTransport } from "../transport/renderer-transport";
29
30
  import { RENDER_BLIT_MODE } from "../config";
31
+ import { snapshotThemeVars } from "../theme/theme-snapshot";
32
+ import { resolveThemeFromVars, type Theme } from "../theme/theme";
33
+ import { resolvePalette } from "../theme/palette";
34
+ import { vec3ToHexColor } from "../utils/css";
30
35
 
31
36
  /**
32
37
  * Facet-rendering defaults shared by every chart. Per-chart overrides
@@ -449,23 +454,79 @@ export class HTMLPerspectiveViewerWebGLPluginElement
449
454
  priority: 0,
450
455
  can_render_column_styles:
451
456
  !!this._chartType.default_chart_type ||
452
- this._chartType.category === "Cartesian Charts",
457
+ this._chartType.category === "Cartesian Charts" ||
458
+ this._chartType.category === "Hierarchical Charts" ||
459
+ this._chartType.category === "Map Charts",
453
460
  };
454
461
  }
455
462
 
456
463
  column_config_schema(
457
464
  column_type: string,
458
- _group: string | undefined,
459
- _column_name: string,
465
+ group: string | undefined,
466
+ column_name: string,
460
467
  current_value: Record<string, unknown> | null,
461
- _viewer_config?: { group_by?: string[]; group_rollup_mode?: string },
468
+ viewer_config?: {
469
+ columns?: (string | null)[];
470
+ group_by?: string[];
471
+ split_by?: string[];
472
+ group_rollup_mode?: string;
473
+ },
462
474
  ) {
463
475
  const fields: Array<Record<string, unknown> & { kind: string }> = [];
464
476
 
477
+ if (group === "Color") {
478
+ const numeric_gradient =
479
+ this._chartType.category === "Hierarchical Charts"
480
+ ? column_type === "integer" ||
481
+ column_type === "float" ||
482
+ column_type === "date" ||
483
+ column_type === "datetime"
484
+ : column_type !== "string";
485
+ if (numeric_gradient) {
486
+ fields.push({
487
+ kind: "GradientStops",
488
+ key: "gradient",
489
+ default: this._themeGradientStopsSpec(),
490
+ });
491
+ } else {
492
+ fields.push({
493
+ kind: "Palette",
494
+ key: "palette",
495
+ default: this._themeSeriesPaletteHex(),
496
+ });
497
+ }
498
+ }
499
+
465
500
  // Y-series plugins expose the per-column chart_type picker; non-Y
466
501
  // plugins leave `default_chart_type` unset.
467
502
  const def = this._chartType.default_chart_type;
468
503
  if (def && (column_type === "integer" || column_type === "float")) {
504
+ const is_series_glyph =
505
+ def === "bar" ||
506
+ def === "line" ||
507
+ def === "scatter" ||
508
+ def === "area";
509
+
510
+ if (is_series_glyph) {
511
+ const has_split = (viewer_config?.split_by?.length ?? 0) > 0;
512
+ if (has_split) {
513
+ fields.push({
514
+ kind: "Palette",
515
+ key: "palette",
516
+ default: this._themeSeriesPaletteHex(),
517
+ });
518
+ } else {
519
+ const slot = (viewer_config?.columns ?? [])
520
+ .filter((c): c is string => !!c)
521
+ .indexOf(column_name);
522
+ fields.push({
523
+ kind: "Color",
524
+ key: "color",
525
+ default: this._themeSeriesColorHex(Math.max(0, slot)),
526
+ });
527
+ }
528
+ }
529
+
469
530
  fields.push({
470
531
  kind: "Enum",
471
532
  key: "chart_type",
@@ -493,12 +554,6 @@ export class HTMLPerspectiveViewerWebGLPluginElement
493
554
  });
494
555
  }
495
556
 
496
- const is_series_glyph =
497
- def === "bar" ||
498
- def === "line" ||
499
- def === "scatter" ||
500
- def === "area";
501
-
502
557
  if (is_series_glyph) {
503
558
  fields.push({
504
559
  kind: "Bool",
@@ -557,6 +612,36 @@ export class HTMLPerspectiveViewerWebGLPluginElement
557
612
  return { fields };
558
613
  }
559
614
 
615
+ private _resolvedTheme(): Theme {
616
+ return resolveThemeFromVars(snapshotThemeVars(this));
617
+ }
618
+
619
+ private static readonly GRADIENT_PALETTE_FALLBACK_COUNT = 6;
620
+
621
+ private _themeSeriesPaletteHex(): string {
622
+ const theme = this._resolvedTheme();
623
+ const count =
624
+ theme.seriesPalette.length ||
625
+ HTMLPerspectiveViewerWebGLPluginElement.GRADIENT_PALETTE_FALLBACK_COUNT;
626
+ return colorsToCss(
627
+ resolvePalette(theme.seriesPalette, theme.gradientStops, count),
628
+ );
629
+ }
630
+
631
+ private _themeSeriesColorHex(idx: number): string {
632
+ const theme = this._resolvedTheme();
633
+ const count = Math.max(theme.seriesPalette.length, idx + 1);
634
+ return vec3ToHexColor(
635
+ resolvePalette(theme.seriesPalette, theme.gradientStops, count)[
636
+ idx
637
+ ],
638
+ );
639
+ }
640
+
641
+ private _themeGradientStopsSpec(): string {
642
+ return stopsToCss(this._resolvedTheme().gradientStops);
643
+ }
644
+
560
645
  async draw(view: View): Promise<void> {
561
646
  // `draw` always indicates a view-level change (pivots, columns,
562
647
  // filters, sorts, schema, …) — invalidate the `domain_mode:
@@ -10,7 +10,7 @@
10
10
  // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11
11
  // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
12
 
13
- import { parseCSSColorToVec3 } from "../utils/css";
13
+ import { parseCSSColorToVec3, vec3ToHexColor } from "../utils/css";
14
14
 
15
15
  /**
16
16
  * A single stop on a parsed CSS gradient. `offset` ∈ [0, 1].
@@ -178,6 +178,246 @@ export function parseCssGradient(
178
178
  return result;
179
179
  }
180
180
 
181
+ /**
182
+ * A color literal — `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`, `rgb()` or
183
+ * `rgba()` — as a `[0, 1]` RGB triple, or `null` for anything else.
184
+ */
185
+ export function parseCssColorStrict(
186
+ src: string,
187
+ ): [number, number, number] | null {
188
+ const s = src.trim();
189
+ if (s.startsWith("#")) {
190
+ const hex = s.slice(1);
191
+ if (!/^[0-9a-f]+$/i.test(hex)) {
192
+ return null;
193
+ }
194
+
195
+ if (hex.length === 3 || hex.length === 4) {
196
+ return [
197
+ parseInt(hex[0] + hex[0], 16) / 255,
198
+ parseInt(hex[1] + hex[1], 16) / 255,
199
+ parseInt(hex[2] + hex[2], 16) / 255,
200
+ ];
201
+ }
202
+
203
+ if (hex.length === 6 || hex.length === 8) {
204
+ return [
205
+ parseInt(hex.slice(0, 2), 16) / 255,
206
+ parseInt(hex.slice(2, 4), 16) / 255,
207
+ parseInt(hex.slice(4, 6), 16) / 255,
208
+ ];
209
+ }
210
+
211
+ return null;
212
+ }
213
+
214
+ const m = s.match(/^rgba?\(([^)]*)\)$/i);
215
+ if (!m) {
216
+ return null;
217
+ }
218
+
219
+ const tokens = m[1]
220
+ .split("/")[0]
221
+ .split(/[\s,]+/)
222
+ .filter((x) => x.length > 0);
223
+ if (tokens.length < 3 || tokens.length > 4) {
224
+ return null;
225
+ }
226
+
227
+ const channel = (token: string): number | null => {
228
+ const pct = token.endsWith("%");
229
+ const n = parseFloat(pct ? token.slice(0, -1) : token);
230
+ if (!isFinite(n)) {
231
+ return null;
232
+ }
233
+
234
+ const v = pct ? (n / 100) * 255 : n;
235
+ return Math.max(0, Math.min(255, Math.round(v))) / 255;
236
+ };
237
+
238
+ const r = channel(tokens[0]);
239
+ const g = channel(tokens[1]);
240
+ const b = channel(tokens[2]);
241
+ return r === null || g === null || b === null ? null : [r, g, b];
242
+ }
243
+
244
+ /**
245
+ * The shared `linear-gradient(...)` tokenizer of the viewer's CSS-valued
246
+ * config grammar.
247
+ */
248
+ export function tokenizeLinearGradient(
249
+ src: string,
250
+ ): Array<[[number, number, number], number | null]> | null {
251
+ const m = src.trim().match(/^linear-gradient\s*\((.*)\)$/is);
252
+ if (!m) {
253
+ return null;
254
+ }
255
+
256
+ const body = m[1];
257
+ const parts: string[] = [];
258
+ let depth = 0;
259
+ let start = 0;
260
+ for (let i = 0; i < body.length; i++) {
261
+ const ch = body[i];
262
+ if (ch === "(") {
263
+ depth++;
264
+ } else if (ch === ")") {
265
+ depth--;
266
+ } else if (ch === "," && depth === 0) {
267
+ parts.push(body.substring(start, i));
268
+ start = i + 1;
269
+ }
270
+ }
271
+
272
+ parts.push(body.substring(start));
273
+ const out: Array<[[number, number, number], number | null]> = [];
274
+ for (let i = 0; i < parts.length; i++) {
275
+ const piece = parts[i].trim();
276
+ if (!piece) {
277
+ return null;
278
+ }
279
+
280
+ const lower = piece.toLowerCase();
281
+ if (
282
+ i === 0 &&
283
+ (lower.startsWith("to ") ||
284
+ /^[-\d.]+(deg|rad|grad|turn)$/.test(lower))
285
+ ) {
286
+ continue;
287
+ }
288
+
289
+ const posMatch = piece.match(/\s([^\s)]+)$/);
290
+ let color = piece;
291
+ let offset: number | null = null;
292
+ if (posMatch) {
293
+ const tail = posMatch[1];
294
+ if (tail.endsWith("%")) {
295
+ const n = parseFloat(tail.slice(0, -1));
296
+ if (!isFinite(n)) {
297
+ return null;
298
+ }
299
+
300
+ color = piece.substring(0, posMatch.index).trim();
301
+ offset = n / 100;
302
+ } else if (/^[-\d.]/.test(tail)) {
303
+ return null;
304
+ }
305
+ }
306
+
307
+ const rgb = parseCssColorStrict(color);
308
+ if (!rgb) {
309
+ return null;
310
+ }
311
+
312
+ out.push([rgb, offset]);
313
+ }
314
+
315
+ return out;
316
+ }
317
+
318
+ /**
319
+ * Strict gradient reader: a `linear-gradient(...)` with ≥ 2 stops,
320
+ * positions optional, offsets clamped to `[0, 1]` and sorted, or `null`
321
+ * on malformed input.
322
+ */
323
+ export function parseCssGradientStrict(src: string): GradientStop[] | null {
324
+ const entries = tokenizeLinearGradient(src);
325
+ if (!entries || entries.length < 2) {
326
+ return null;
327
+ }
328
+
329
+ const offsets: Array<number | null> = entries.map(([, p]) => p);
330
+ const last = offsets.length - 1;
331
+ if (offsets[0] === null) {
332
+ offsets[0] = 0;
333
+ }
334
+
335
+ if (offsets[last] === null) {
336
+ offsets[last] = 1;
337
+ }
338
+
339
+ for (let i = 1; i < last; i++) {
340
+ if (offsets[i] !== null) {
341
+ continue;
342
+ }
343
+
344
+ let j = i + 1;
345
+ while (offsets[j] === null) {
346
+ j++;
347
+ }
348
+
349
+ const before = offsets[i - 1]!;
350
+ const after = offsets[j]!;
351
+ const span = j - (i - 1);
352
+ for (let k = i; k < j; k++) {
353
+ offsets[k] = before + ((k - (i - 1)) / span) * (after - before);
354
+ }
355
+
356
+ i = j - 1;
357
+ }
358
+
359
+ const stops: GradientStop[] = entries.map(([rgb], i) => ({
360
+ offset: Math.max(0, Math.min(1, offsets[i]!)),
361
+ color: [rgb[0], rgb[1], rgb[2], 1],
362
+ }));
363
+
364
+ stops.sort((a, b) => a.offset - b.offset);
365
+ return stops;
366
+ }
367
+
368
+ /**
369
+ * Strict palette reader: a `linear-gradient(...)` of ≥ 1 colors with no
370
+ * positions, or `null` on malformed input.
371
+ */
372
+ export function parseCssColorList(
373
+ src: string,
374
+ ): Array<[number, number, number]> | null {
375
+ const entries = tokenizeLinearGradient(src);
376
+ if (!entries || entries.length === 0) {
377
+ return null;
378
+ }
379
+
380
+ const out: Array<[number, number, number]> = [];
381
+ for (const [rgb, offset] of entries) {
382
+ if (offset !== null) {
383
+ return null;
384
+ }
385
+
386
+ out.push(rgb);
387
+ }
388
+
389
+ return out;
390
+ }
391
+
392
+ function formatPercent(offset: number): string {
393
+ const rounded = Math.round(Math.max(0, Math.min(1, offset)) * 1000) / 10;
394
+ return `${rounded}%`;
395
+ }
396
+
397
+ /**
398
+ * The viewer's canonical gradient string for `stops`: `linear-gradient(to
399
+ * right, #rrggbb P%, …)`, sorted, positions at 0.1% resolution.
400
+ */
401
+ export function stopsToCss(stops: GradientStop[]): string {
402
+ const body = [...stops]
403
+ .sort((a, b) => a.offset - b.offset)
404
+ .map(
405
+ (stop) =>
406
+ `${vec3ToHexColor([stop.color[0], stop.color[1], stop.color[2]])} ${formatPercent(stop.offset)}`,
407
+ )
408
+ .join(", ");
409
+
410
+ return `linear-gradient(to right, ${body})`;
411
+ }
412
+
413
+ /**
414
+ * The viewer's canonical palette string for `colors`: the same frame
415
+ * with bare `#rrggbb` entries and no positions.
416
+ */
417
+ export function colorsToCss(colors: Array<[number, number, number]>): string {
418
+ return `linear-gradient(to right, ${colors.map(vec3ToHexColor).join(", ")})`;
419
+ }
420
+
181
421
  /**
182
422
  * Piecewise-linear color sample at `t ∈ [0, 1]`. Returns RGBA in [0, 1].
183
423
  * Clamps `t` to the gradient's first/last stop outside `[0, 1]`.
@@ -0,0 +1,76 @@
1
+ // ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
2
+ // ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
3
+ // ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
4
+ // ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
5
+ // ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
6
+ // ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
7
+ // ┃ Copyright (c) 2017, the Perspective Authors. ┃
8
+ // ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
9
+ // ┃ This file is part of the Perspective library, distributed under the terms ┃
10
+ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11
+ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
+
13
+ import {
14
+ parseCssColorList,
15
+ parseCssGradientStrict,
16
+ type GradientStop,
17
+ } from "./gradient";
18
+ import type { Theme } from "./theme";
19
+ import type { Vec3 } from "./palette";
20
+
21
+ /**
22
+ * Per-column color-scale overrides from the viewer's `columns_config`,
23
+ * patched into the resolved `Theme`'s `seriesPalette` / `gradientStops`
24
+ * fields.
25
+ */
26
+ export function applyColumnColorOverrides(
27
+ theme: Theme,
28
+ columnsConfig: Record<string, any> | undefined,
29
+ colorColumn: string | null,
30
+ ): Theme {
31
+ if (!colorColumn) {
32
+ return theme;
33
+ }
34
+
35
+ const cfg = columnsConfig?.[colorColumn];
36
+ if (!cfg) {
37
+ return theme;
38
+ }
39
+
40
+ let out = theme;
41
+ const gradient = parseGradientOverride(cfg.gradient);
42
+ if (gradient) {
43
+ out = { ...out, gradientStops: gradient };
44
+ }
45
+
46
+ const palette = parsePaletteOverride(cfg.palette);
47
+ if (palette) {
48
+ out = { ...out, seriesPalette: palette };
49
+ }
50
+
51
+ return out;
52
+ }
53
+
54
+ /**
55
+ * Parse a stored `palette` value into the `seriesPalette` shape, or
56
+ * `null` on anything malformed.
57
+ */
58
+ export function parsePaletteOverride(raw: unknown): Vec3[] | null {
59
+ if (typeof raw !== "string") {
60
+ return null;
61
+ }
62
+
63
+ return parseCssColorList(raw);
64
+ }
65
+
66
+ /**
67
+ * Parse a stored `gradient` value into sorted `GradientStop[]`, or
68
+ * `null` on malformed input or fewer than 2 stops.
69
+ */
70
+ export function parseGradientOverride(raw: unknown): GradientStop[] | null {
71
+ if (typeof raw !== "string") {
72
+ return null;
73
+ }
74
+
75
+ return parseCssGradientStrict(raw);
76
+ }