@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.
- package/dist/cdn/perspective-viewer-charts.js +2 -2
- package/dist/cdn/perspective-viewer-charts.js.map +4 -4
- package/dist/esm/charts/candlestick/candlestick-render.d.ts +3 -0
- package/dist/esm/charts/cartesian/cartesian.d.ts +1 -0
- package/dist/esm/charts/chart-base.d.ts +22 -9
- package/dist/esm/charts/chart.d.ts +4 -0
- package/dist/esm/charts/common/category-axis-resolver.d.ts +1 -1
- package/dist/esm/charts/common/tree-chart.d.ts +1 -0
- package/dist/esm/charts/heatmap/heatmap.d.ts +1 -0
- package/dist/esm/charts/series/series-type.d.ts +18 -0
- package/dist/esm/charts/series/series.d.ts +4 -1
- package/dist/esm/charts/sunburst/sunburst-render.d.ts +4 -0
- package/dist/esm/perspective-viewer-charts.js +2 -2
- package/dist/esm/perspective-viewer-charts.js.map +4 -4
- package/dist/esm/plugin/plugin.d.ts +8 -1
- package/dist/esm/theme/gradient.d.ts +31 -0
- package/dist/esm/theme/overrides.d.ts +19 -0
- package/dist/esm/transport/protocol.d.ts +13 -2
- package/dist/esm/utils/css.d.ts +4 -0
- package/dist/esm/worker/renderer.worker.d.ts +27 -0
- package/package.json +1 -1
- package/src/ts/charts/candlestick/candlestick-build.ts +1 -0
- package/src/ts/charts/candlestick/candlestick-render.ts +8 -0
- package/src/ts/charts/candlestick/candlestick.ts +4 -3
- package/src/ts/charts/cartesian/cartesian-render.ts +5 -0
- package/src/ts/charts/cartesian/cartesian.ts +4 -0
- package/src/ts/charts/chart-base.ts +42 -10
- package/src/ts/charts/chart.ts +5 -0
- package/src/ts/charts/common/category-axis-resolver.ts +44 -5
- package/src/ts/charts/common/tree-chart.ts +4 -0
- package/src/ts/charts/heatmap/heatmap-build.ts +7 -1
- package/src/ts/charts/heatmap/heatmap-interact.ts +10 -1
- package/src/ts/charts/heatmap/heatmap-render.ts +5 -0
- package/src/ts/charts/heatmap/heatmap.ts +8 -3
- package/src/ts/charts/series/series-build.ts +1 -0
- package/src/ts/charts/series/series-interact.ts +2 -13
- package/src/ts/charts/series/series-render.ts +5 -0
- package/src/ts/charts/series/series-type.ts +65 -0
- package/src/ts/charts/series/series.ts +21 -8
- package/src/ts/charts/sunburst/sunburst-render.ts +9 -0
- package/src/ts/charts/treemap/treemap-render.ts +5 -0
- package/src/ts/layout/ticks.ts +36 -12
- package/src/ts/plugin/plugin.ts +95 -10
- package/src/ts/theme/gradient.ts +241 -1
- package/src/ts/theme/overrides.ts +76 -0
- package/src/ts/transport/protocol.ts +14 -2
- package/src/ts/transport/renderer-transport.ts +24 -10
- package/src/ts/utils/css.ts +12 -0
- 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`
|
|
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)[] =
|
|
518
|
-
|
|
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]
|
|
935
|
-
|
|
936
|
-
|
|
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
|
}
|
package/src/ts/layout/ticks.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
196
|
+
return cachedDateFormat("ymd", {
|
|
173
197
|
year: "numeric",
|
|
174
198
|
month: "short",
|
|
175
199
|
day: "numeric",
|
|
176
|
-
});
|
|
200
|
+
}).format(d);
|
|
177
201
|
}
|
package/src/ts/plugin/plugin.ts
CHANGED
|
@@ -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
|
-
|
|
459
|
-
|
|
465
|
+
group: string | undefined,
|
|
466
|
+
column_name: string,
|
|
460
467
|
current_value: Record<string, unknown> | null,
|
|
461
|
-
|
|
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:
|
package/src/ts/theme/gradient.ts
CHANGED
|
@@ -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
|
+
}
|