Skip to content

Commit f70d1be

Browse files
authored
Merge pull request #3168 from perspective-dev/multi-chart-webgl
Multi-chart rendering for `@perspective-dev/charts`
2 parents 42783ec + 2ab561b commit f70d1be

82 files changed

Lines changed: 5780 additions & 1947 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/build.yaml

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -590,9 +590,12 @@ jobs:
590590

591591
- name: Run Jupyter Tests
592592
if: ${{ runner.os == 'Linux' }}
593-
run: pnpm run test
593+
run: pnpm run test --fetch-snapshots
594594
env:
595595
PACKAGE: "jupyterlab"
596+
PSP_SNAPSHOT_REPO: ${{ vars.PSP_SNAPSHOT_REPO }}
597+
PSP_SNAPSHOT_TOKEN: ${{ secrets.PSP_SNAPSHOT_TOKEN }}
598+
PSP_SNAPSHOT_REF: ${{ github.head_ref || github.ref_name }}
596599
# PSP_USE_CCACHE: 1
597600

598601
- name: Run Jupyter Integration Tests
@@ -646,9 +649,12 @@ jobs:
646649
path: .
647650

648651
- name: Run Tests
649-
run: pnpm run test
652+
run: pnpm run test -- --fetch-snapshots
650653
env:
651654
PACKAGE: "server,client,viewer,viewer-datagrid,viewer-charts,viewer-openlayers,workspace,react"
655+
PSP_SNAPSHOT_REPO: ${{ vars.PSP_SNAPSHOT_REPO }}
656+
PSP_SNAPSHOT_TOKEN: ${{ secrets.PSP_SNAPSHOT_TOKEN }}
657+
PSP_SNAPSHOT_REF: ${{ github.head_ref || github.ref_name }}
652658
# PSP_USE_CCACHE: 1
653659

654660
# ,--,--' . .-,--. . .

packages/viewer-charts/build.mjs

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,67 @@
1212

1313
import { NodeModulesExternal } from "@perspective-dev/esbuild-plugin/external.js";
1414
import { build } from "@perspective-dev/esbuild-plugin/build.js";
15+
import { transform as transformCss } from "lightningcss";
1516
import { execSync } from "node:child_process";
17+
import * as fs from "node:fs/promises";
18+
19+
// TODO: if shader payload ever becomes a measured bottleneck, swap this
20+
// regex minifier for an AST-based tool (e.g. `glsl-minifier`) to get
21+
// identifier mangling on locals/varyings. Uniform/attribute names are
22+
// resolved by string from JS via `getUniformLocation` / `getAttribLocation`,
23+
// so only locals are safe to rename.
24+
const GlslMinify = () => ({
25+
name: "glsl-minify",
26+
setup(build) {
27+
build.onLoad({ filter: /\.glsl$/ }, async (args) => {
28+
const src = await fs.readFile(args.path, "utf8");
29+
if (process.env.PSP_DEBUG) {
30+
return { contents: src, loader: "text" };
31+
}
32+
const min = src
33+
.replace(/\/\*[\s\S]*?\*\//g, "")
34+
.replace(/\/\/[^\n]*/g, "")
35+
.replace(/\s+/g, " ")
36+
.replace(/\s*([;,(){}\[\]=+\-*/<>!&|^~?])\s*/g, "$1")
37+
.trim();
38+
return { contents: min, loader: "text" };
39+
});
40+
},
41+
});
42+
43+
// CSS is imported via `import style from "...css"` + the `.css: text`
44+
// loader, so the final bundle embeds the source verbatim as a JS
45+
// string literal — esbuild's own minifier doesn't touch string
46+
// contents. Route `.css` loads through lightningcss so the embedded
47+
// CSS is minified (whitespace collapse, selector shortening, value
48+
// normalisation).
49+
//
50+
// Skipped in `PSP_DEBUG` builds to keep source maps useful.
51+
const LightningCssMinify = () => ({
52+
name: "lightningcss-minify",
53+
setup(build) {
54+
build.onLoad({ filter: /\.css$/ }, async (args) => {
55+
const src = await fs.readFile(args.path);
56+
if (process.env.PSP_DEBUG) {
57+
return { contents: src.toString("utf8"), loader: "text" };
58+
}
59+
const { code } = transformCss({
60+
filename: args.path,
61+
code: src,
62+
minify: true,
63+
});
64+
return { contents: code.toString("utf8"), loader: "text" };
65+
});
66+
},
67+
});
1668

1769
const BUILD = [
1870
{
1971
entryPoints: ["src/ts/index.ts"],
2072
define: {
2173
global: "window",
2274
},
23-
plugins: [NodeModulesExternal()],
75+
plugins: [NodeModulesExternal(), GlslMinify(), LightningCssMinify()],
2476
format: "esm",
2577
loader: {
2678
".css": "text",
@@ -33,7 +85,10 @@ const BUILD = [
3385
define: {
3486
global: "window",
3587
},
36-
plugins: [],
88+
plugins: [GlslMinify(), LightningCssMinify()],
89+
minifyWhitespace: !process.env.PSP_DEBUG,
90+
minifyIdentifiers: !process.env.PSP_DEBUG,
91+
mangleProps: process.env.PSP_DEBUG ? false : /^[_#]/,
3792
format: "esm",
3893
loader: {
3994
".css": "text",

packages/viewer-charts/src/css/perspective-viewer-charts.css

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,20 @@
1515
monospace
1616
);
1717

18+
--psp-webgl--font-family: var(
19+
--psp-interface-monospace--font-family,
20+
"ui-monospace",
21+
"SFMono-Regular",
22+
"SF Mono",
23+
"Menlo",
24+
"Consolas",
25+
"Liberation Mono",
26+
monospace
27+
);
28+
--psp-webgl--tooltip--color: var(--psp--color);
29+
--psp-webgl--tooltip--background: var(--psp--background-color);
30+
--psp-webgl--tooltip--border-color: var(--psp-inactive--border-color);
31+
1832
/* --psp-webgl--axis-ticks--color: var(
1933
--psp-d3fc--axis-ticks--color,
2034
rgba(160, 160, 160, 0.8)
@@ -64,10 +78,10 @@
6478

6579
.webgl-container {
6680
position: absolute;
67-
width: 100%;
68-
height: 100%;
69-
top: 0;
70-
left: 0;
81+
top: 6px;
82+
left: 6px;
83+
right: 6px;
84+
bottom: 6px;
7185
}
7286

7387
.webgl-canvas {
@@ -126,3 +140,18 @@
126140
.zoom-reset:hover {
127141
opacity: 1;
128142
}
143+
144+
.webgl-tooltip {
145+
position: absolute;
146+
pointer-events: auto;
147+
font: 11px var(--psp-webgl--font-family);
148+
background: var(--psp-webgl--tooltip--background);
149+
color: var(--psp-webgl--tooltip--color);
150+
border: 1px solid var(--psp-webgl--tooltip--border-color);
151+
border-radius: 3px;
152+
padding: 3px;
153+
overflow-y: auto;
154+
white-space: pre;
155+
z-index: 10;
156+
line-height: 16px;
157+
}

packages/viewer-charts/src/ts/charts/bar/bar-interact.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212

1313
import type { BarChart } from "./bar";
1414
import type { BarRecord } from "./bar-build";
15-
import { resolveTheme } from "../../theme/theme";
1615
import { formatTickValue } from "../../layout/ticks";
1716
import {
1817
renderBarFrame,
@@ -357,6 +356,7 @@ export function buildBarTooltipLines(chart: BarChart, b: BarRecord): string[] {
357356
lines.push(`Base: ${formatTickValue(b.y0)}`);
358357
lines.push(`Top: ${formatTickValue(b.y1)}`);
359358
}
359+
360360
return lines;
361361
}
362362

@@ -368,7 +368,7 @@ export function formatBarCategoryPath(chart: BarChart, catIdx: number): string {
368368
if (chart._rowPaths.length === 0) return "";
369369
const parts: string[] = [];
370370
for (const rp of chart._rowPaths) {
371-
const s = rp.dictionary[rp.indices[catIdx]];
371+
const s = rp.labels[catIdx];
372372
if (s != null && s !== "") parts.push(s);
373373
}
374374
return parts.join(" / ");
@@ -409,13 +409,9 @@ function pinTooltip(chart: BarChart, b: BarRecord): void {
409409
const lines = buildBarTooltipLines(chart, b);
410410
if (lines.length === 0) return;
411411

412-
const themeEl = chart._gridlineCanvas || chart._chromeCanvas;
413-
if (!themeEl) return;
414-
const theme = resolveTheme(themeEl);
415-
416412
const parent = chart._glCanvas?.parentElement;
417413
if (!parent) return;
418-
chart._tooltip.showPinned(parent, lines, pos, layout, theme);
414+
chart._tooltip.showPinned(parent, lines, pos, layout);
419415

420416
chart._hoveredBarIdx = -1;
421417
chart._hoveredSample = null;

packages/viewer-charts/src/ts/charts/candlestick/candlestick-interact.ts

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212

1313
import type { CandlestickChart } from "./candlestick";
1414
import type { CandleRecord } from "./candlestick-build";
15-
import { resolveTheme } from "../../theme/theme";
1615
import { formatTickValue } from "../../layout/ticks";
1716
import {
1817
renderCandlestickChromeOverlay,
@@ -86,10 +85,6 @@ export function showCandlestickPinnedTooltip(
8685
const candle = chart._candles[idx];
8786
if (!candle || !chart._lastLayout) return;
8887

89-
const themeEl = chart._gridlineCanvas || chart._chromeCanvas;
90-
if (!themeEl) return;
91-
const theme = resolveTheme(themeEl);
92-
9388
const lines = buildCandlestickTooltipLines(chart, candle);
9489
if (lines.length === 0) return;
9590

@@ -104,13 +99,7 @@ export function showCandlestickPinnedTooltip(
10499
const cssWidth = (chart._glCanvas?.width || 100) / dpr;
105100
const cssHeight = (chart._glCanvas?.height || 100) / dpr;
106101

107-
chart._tooltip.showPinned(
108-
parent,
109-
lines,
110-
pos,
111-
{ cssWidth, cssHeight },
112-
theme,
113-
);
102+
chart._tooltip.showPinned(parent, lines, pos, { cssWidth, cssHeight });
114103

115104
chart._hoveredIdx = -1;
116105
if (chart._glManager) renderCandlestickFrame(chart, chart._glManager);
@@ -131,7 +120,7 @@ export function buildCandlestickTooltipLines(
131120
if (chart._rowPaths.length > 0) {
132121
const parts: string[] = [];
133122
for (const rp of chart._rowPaths) {
134-
const s = rp.dictionary[rp.indices[candle.catIdx]] ?? "";
123+
const s = rp.labels[candle.catIdx] ?? "";
135124
if (s) parts.push(s);
136125
}
137126
if (parts.length > 0) lines.push(parts.join(" › "));

packages/viewer-charts/src/ts/charts/chart-base.ts

Lines changed: 98 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,19 @@
1010
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
1111
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
1212

13+
import type { View } from "@perspective-dev/client";
1314
import type { ColumnDataMap } from "../data/view-reader";
15+
import { LazyRowFetcher } from "../data/lazy-row";
1416
import type { WebGLContextManager } from "../webgl/context-manager";
15-
import type {
16-
ZoomConfig,
17+
import {
1718
ZoomController,
19+
type ZoomConfig,
1820
} from "../interaction/zoom-controller";
19-
import type { ChartImplementation } from "./chart";
21+
import {
22+
DEFAULT_FACET_CONFIG,
23+
type ChartImplementation,
24+
type FacetConfig,
25+
} from "./chart";
2026
import { TooltipController } from "../interaction/tooltip-controller";
2127

2228
/**
@@ -76,6 +82,15 @@ export abstract class AbstractChart implements ChartImplementation {
7682
_gridlineCanvas: HTMLCanvasElement | null = null;
7783
_chromeCanvas: HTMLCanvasElement | null = null;
7884
_zoomController: ZoomController | null = null;
85+
/**
86+
* Per-facet zoom controllers. Populated when `zoom_mode ===
87+
* "independent"` and the chart enters faceted mode; each facet's
88+
* render path reads its own viewport from the matching entry.
89+
*
90+
* Shared-zoom mode leaves this empty; `_zoomController` is the
91+
* single domain used for every facet.
92+
*/
93+
_facetZoomControllers: ZoomController[] = [];
7994
_glCanvas: HTMLCanvasElement | null = null;
8095

8196
_columnSlots: (string | null)[] = [];
@@ -84,9 +99,22 @@ export abstract class AbstractChart implements ChartImplementation {
8499
_columnTypes: Record<string, string> = {};
85100
_columnsConfig: Record<string, any> = {};
86101
_defaultChartType: string | undefined = undefined;
102+
_facetConfig: FacetConfig = { ...DEFAULT_FACET_CONFIG };
87103

88104
_tooltip = new TooltipController();
89105

106+
/**
107+
* On-demand single-row fetcher used by lazy tooltip column
108+
* lookups. Reset on every `setView` call; subclasses read
109+
* `_lazyRows.fetchRow(rowIdx)` from their hover/pin paths and
110+
* compare a captured serial against the current hovered/pinned
111+
* state at resolution time, so stale fetches never paint.
112+
*
113+
* Can be `null` on chart types that don't surface the View
114+
* (unit-tested charts) or before the first `draw`.
115+
*/
116+
_lazyRows: LazyRowFetcher | null = null;
117+
90118
private _renderScheduled = false;
91119
private _renderRAFId = 0;
92120

@@ -105,6 +133,47 @@ export abstract class AbstractChart implements ChartImplementation {
105133
zc.configure(this.getZoomConfig());
106134
}
107135

136+
/**
137+
* Resolve the zoom controller that owns facet `idx`. In shared-zoom
138+
* mode (default) this is always the chart's single `_zoomController`.
139+
* In independent-zoom mode the router provisions one controller per
140+
* facet; this returns the matching entry, allocating on demand so
141+
* the render path never has to check `zoom_mode` itself.
142+
*/
143+
getZoomControllerForFacet(idx: number): ZoomController | null {
144+
if (this._facetConfig.zoom_mode === "shared") {
145+
return this._zoomController;
146+
}
147+
if (!this._zoomController) return null;
148+
let zc = this._facetZoomControllers[idx];
149+
if (!zc) {
150+
zc = new ZoomController();
151+
zc.configure(this.getZoomConfig());
152+
this._facetZoomControllers[idx] = zc;
153+
}
154+
return zc;
155+
}
156+
157+
/**
158+
* Seed base domain on every zoom controller owned by this chart.
159+
* Build paths call this once per load with the accumulated data
160+
* extents; independent-zoom facets share the same base so visual
161+
* zoom levels stay comparable across facets.
162+
*/
163+
setZoomBaseDomain(
164+
xMin: number,
165+
xMax: number,
166+
yMin: number,
167+
yMax: number,
168+
): void {
169+
if (this._zoomController) {
170+
this._zoomController.setBaseDomain(xMin, xMax, yMin, yMax);
171+
}
172+
for (const zc of this._facetZoomControllers) {
173+
if (zc) zc.setBaseDomain(xMin, xMax, yMin, yMax);
174+
}
175+
}
176+
108177
/**
109178
* Zoom-controller config for this chart type. Subclasses override to
110179
* pin an axis (e.g. bar charts pin the categorical axis). Default:
@@ -135,6 +204,28 @@ export abstract class AbstractChart implements ChartImplementation {
135204
this._defaultChartType = chartType;
136205
}
137206

207+
setFacetConfig(cfg: FacetConfig): void {
208+
this._facetConfig = { ...cfg };
209+
}
210+
211+
/**
212+
* Install a new view for lazy row fetches. Disposes any prior
213+
* fetcher and dismisses the pinned tooltip — the prior pinned
214+
* row index has no guaranteed correspondence in the new view
215+
* (pivot / filter / sort changes can all reshuffle rows).
216+
*
217+
* TODO: future work will keep pinned tooltips visible with their
218+
* last-resolved lines until the user explicitly dismisses them,
219+
* so a mid-session view update doesn't blow away focused context.
220+
*/
221+
setView(view: View): void {
222+
if (this._lazyRows) {
223+
this._lazyRows.dispose();
224+
}
225+
this._lazyRows = new LazyRowFetcher(view);
226+
this._tooltip.dismissPinned();
227+
}
228+
138229
// ── Render batching ────────────────────────────────────────────────────
139230

140231
/** Schedule one `_fullRender` on the next animation frame (idempotent). */
@@ -163,6 +254,10 @@ export abstract class AbstractChart implements ChartImplementation {
163254
this._tooltip.detach();
164255
this._tooltip.dismissPinned();
165256
this._cancelScheduledRender();
257+
if (this._lazyRows) {
258+
this._lazyRows.dispose();
259+
this._lazyRows = null;
260+
}
166261
this.destroyInternal();
167262
}
168263

0 commit comments

Comments
 (0)