Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -590,9 +590,12 @@ jobs:

- name: Run Jupyter Tests
if: ${{ runner.os == 'Linux' }}
run: pnpm run test
run: pnpm run test --fetch-snapshots
env:
PACKAGE: "jupyterlab"
PSP_SNAPSHOT_REPO: ${{ vars.PSP_SNAPSHOT_REPO }}
PSP_SNAPSHOT_TOKEN: ${{ secrets.PSP_SNAPSHOT_TOKEN }}
PSP_SNAPSHOT_REF: ${{ github.head_ref || github.ref_name }}
# PSP_USE_CCACHE: 1

- name: Run Jupyter Integration Tests
Expand Down Expand Up @@ -646,9 +649,12 @@ jobs:
path: .

- name: Run Tests
run: pnpm run test
run: pnpm run test -- --fetch-snapshots
env:
PACKAGE: "server,client,viewer,viewer-datagrid,viewer-charts,viewer-openlayers,workspace,react"
PSP_SNAPSHOT_REPO: ${{ vars.PSP_SNAPSHOT_REPO }}
PSP_SNAPSHOT_TOKEN: ${{ secrets.PSP_SNAPSHOT_TOKEN }}
PSP_SNAPSHOT_REF: ${{ github.head_ref || github.ref_name }}
# PSP_USE_CCACHE: 1

# ,--,--' . .-,--. . .
Expand Down
59 changes: 57 additions & 2 deletions packages/viewer-charts/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,67 @@

import { NodeModulesExternal } from "@perspective-dev/esbuild-plugin/external.js";
import { build } from "@perspective-dev/esbuild-plugin/build.js";
import { transform as transformCss } from "lightningcss";
import { execSync } from "node:child_process";
import * as fs from "node:fs/promises";

// TODO: if shader payload ever becomes a measured bottleneck, swap this
// regex minifier for an AST-based tool (e.g. `glsl-minifier`) to get
// identifier mangling on locals/varyings. Uniform/attribute names are
// resolved by string from JS via `getUniformLocation` / `getAttribLocation`,
// so only locals are safe to rename.
const GlslMinify = () => ({
name: "glsl-minify",
setup(build) {
build.onLoad({ filter: /\.glsl$/ }, async (args) => {
const src = await fs.readFile(args.path, "utf8");
if (process.env.PSP_DEBUG) {
return { contents: src, loader: "text" };
}
const min = src
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/\/\/[^\n]*/g, "")
.replace(/\s+/g, " ")
.replace(/\s*([;,(){}\[\]=+\-*/<>!&|^~?])\s*/g, "$1")
.trim();
return { contents: min, loader: "text" };
});
},
});

// CSS is imported via `import style from "...css"` + the `.css: text`
// loader, so the final bundle embeds the source verbatim as a JS
// string literal — esbuild's own minifier doesn't touch string
// contents. Route `.css` loads through lightningcss so the embedded
// CSS is minified (whitespace collapse, selector shortening, value
// normalisation).
//
// Skipped in `PSP_DEBUG` builds to keep source maps useful.
const LightningCssMinify = () => ({
name: "lightningcss-minify",
setup(build) {
build.onLoad({ filter: /\.css$/ }, async (args) => {
const src = await fs.readFile(args.path);
if (process.env.PSP_DEBUG) {
return { contents: src.toString("utf8"), loader: "text" };
}
const { code } = transformCss({
filename: args.path,
code: src,
minify: true,
});
return { contents: code.toString("utf8"), loader: "text" };
});
},
});

const BUILD = [
{
entryPoints: ["src/ts/index.ts"],
define: {
global: "window",
},
plugins: [NodeModulesExternal()],
plugins: [NodeModulesExternal(), GlslMinify(), LightningCssMinify()],
format: "esm",
loader: {
".css": "text",
Expand All @@ -33,7 +85,10 @@ const BUILD = [
define: {
global: "window",
},
plugins: [],
plugins: [GlslMinify(), LightningCssMinify()],
minifyWhitespace: !process.env.PSP_DEBUG,
minifyIdentifiers: !process.env.PSP_DEBUG,
mangleProps: process.env.PSP_DEBUG ? false : /^[_#]/,
format: "esm",
loader: {
".css": "text",
Expand Down
37 changes: 33 additions & 4 deletions packages/viewer-charts/src/css/perspective-viewer-charts.css
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,20 @@
monospace
);

--psp-webgl--font-family: var(
--psp-interface-monospace--font-family,
"ui-monospace",
"SFMono-Regular",
"SF Mono",
"Menlo",
"Consolas",
"Liberation Mono",
monospace
);
--psp-webgl--tooltip--color: var(--psp--color);
--psp-webgl--tooltip--background: var(--psp--background-color);
--psp-webgl--tooltip--border-color: var(--psp-inactive--border-color);

/* --psp-webgl--axis-ticks--color: var(
--psp-d3fc--axis-ticks--color,
rgba(160, 160, 160, 0.8)
Expand Down Expand Up @@ -64,10 +78,10 @@

.webgl-container {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
top: 6px;
left: 6px;
right: 6px;
bottom: 6px;
}

.webgl-canvas {
Expand Down Expand Up @@ -126,3 +140,18 @@
.zoom-reset:hover {
opacity: 1;
}

.webgl-tooltip {
position: absolute;
pointer-events: auto;
font: 11px var(--psp-webgl--font-family);
background: var(--psp-webgl--tooltip--background);
color: var(--psp-webgl--tooltip--color);
border: 1px solid var(--psp-webgl--tooltip--border-color);
border-radius: 3px;
padding: 3px;
overflow-y: auto;
white-space: pre;
z-index: 10;
line-height: 16px;
}
10 changes: 3 additions & 7 deletions packages/viewer-charts/src/ts/charts/bar/bar-interact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@

import type { BarChart } from "./bar";
import type { BarRecord } from "./bar-build";
import { resolveTheme } from "../../theme/theme";
import { formatTickValue } from "../../layout/ticks";
import {
renderBarFrame,
Expand Down Expand Up @@ -357,6 +356,7 @@ export function buildBarTooltipLines(chart: BarChart, b: BarRecord): string[] {
lines.push(`Base: ${formatTickValue(b.y0)}`);
lines.push(`Top: ${formatTickValue(b.y1)}`);
}

return lines;
}

Expand All @@ -368,7 +368,7 @@ export function formatBarCategoryPath(chart: BarChart, catIdx: number): string {
if (chart._rowPaths.length === 0) return "";
const parts: string[] = [];
for (const rp of chart._rowPaths) {
const s = rp.dictionary[rp.indices[catIdx]];
const s = rp.labels[catIdx];
if (s != null && s !== "") parts.push(s);
}
return parts.join(" / ");
Expand Down Expand Up @@ -409,13 +409,9 @@ function pinTooltip(chart: BarChart, b: BarRecord): void {
const lines = buildBarTooltipLines(chart, b);
if (lines.length === 0) return;

const themeEl = chart._gridlineCanvas || chart._chromeCanvas;
if (!themeEl) return;
const theme = resolveTheme(themeEl);

const parent = chart._glCanvas?.parentElement;
if (!parent) return;
chart._tooltip.showPinned(parent, lines, pos, layout, theme);
chart._tooltip.showPinned(parent, lines, pos, layout);

chart._hoveredBarIdx = -1;
chart._hoveredSample = null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@

import type { CandlestickChart } from "./candlestick";
import type { CandleRecord } from "./candlestick-build";
import { resolveTheme } from "../../theme/theme";
import { formatTickValue } from "../../layout/ticks";
import {
renderCandlestickChromeOverlay,
Expand Down Expand Up @@ -86,10 +85,6 @@ export function showCandlestickPinnedTooltip(
const candle = chart._candles[idx];
if (!candle || !chart._lastLayout) return;

const themeEl = chart._gridlineCanvas || chart._chromeCanvas;
if (!themeEl) return;
const theme = resolveTheme(themeEl);

const lines = buildCandlestickTooltipLines(chart, candle);
if (lines.length === 0) return;

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

chart._tooltip.showPinned(
parent,
lines,
pos,
{ cssWidth, cssHeight },
theme,
);
chart._tooltip.showPinned(parent, lines, pos, { cssWidth, cssHeight });

chart._hoveredIdx = -1;
if (chart._glManager) renderCandlestickFrame(chart, chart._glManager);
Expand All @@ -131,7 +120,7 @@ export function buildCandlestickTooltipLines(
if (chart._rowPaths.length > 0) {
const parts: string[] = [];
for (const rp of chart._rowPaths) {
const s = rp.dictionary[rp.indices[candle.catIdx]] ?? "";
const s = rp.labels[candle.catIdx] ?? "";
if (s) parts.push(s);
}
if (parts.length > 0) lines.push(parts.join(" › "));
Expand Down
101 changes: 98 additions & 3 deletions packages/viewer-charts/src/ts/charts/chart-base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,19 @@
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

import type { View } from "@perspective-dev/client";
import type { ColumnDataMap } from "../data/view-reader";
import { LazyRowFetcher } from "../data/lazy-row";
import type { WebGLContextManager } from "../webgl/context-manager";
import type {
ZoomConfig,
import {
ZoomController,
type ZoomConfig,
} from "../interaction/zoom-controller";
import type { ChartImplementation } from "./chart";
import {
DEFAULT_FACET_CONFIG,
type ChartImplementation,
type FacetConfig,
} from "./chart";
import { TooltipController } from "../interaction/tooltip-controller";

/**
Expand Down Expand Up @@ -76,6 +82,15 @@ export abstract class AbstractChart implements ChartImplementation {
_gridlineCanvas: HTMLCanvasElement | null = null;
_chromeCanvas: HTMLCanvasElement | null = null;
_zoomController: ZoomController | null = null;
/**
* Per-facet zoom controllers. Populated when `zoom_mode ===
* "independent"` and the chart enters faceted mode; each facet's
* render path reads its own viewport from the matching entry.
*
* Shared-zoom mode leaves this empty; `_zoomController` is the
* single domain used for every facet.
*/
_facetZoomControllers: ZoomController[] = [];
_glCanvas: HTMLCanvasElement | null = null;

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

_tooltip = new TooltipController();

/**
* On-demand single-row fetcher used by lazy tooltip column
* lookups. Reset on every `setView` call; subclasses read
* `_lazyRows.fetchRow(rowIdx)` from their hover/pin paths and
* compare a captured serial against the current hovered/pinned
* state at resolution time, so stale fetches never paint.
*
* Can be `null` on chart types that don't surface the View
* (unit-tested charts) or before the first `draw`.
*/
_lazyRows: LazyRowFetcher | null = null;

private _renderScheduled = false;
private _renderRAFId = 0;

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

/**
* Resolve the zoom controller that owns facet `idx`. In shared-zoom
* mode (default) this is always the chart's single `_zoomController`.
* In independent-zoom mode the router provisions one controller per
* facet; this returns the matching entry, allocating on demand so
* the render path never has to check `zoom_mode` itself.
*/
getZoomControllerForFacet(idx: number): ZoomController | null {
if (this._facetConfig.zoom_mode === "shared") {
return this._zoomController;
}
if (!this._zoomController) return null;
let zc = this._facetZoomControllers[idx];
if (!zc) {
zc = new ZoomController();
zc.configure(this.getZoomConfig());
this._facetZoomControllers[idx] = zc;
}
return zc;
}

/**
* Seed base domain on every zoom controller owned by this chart.
* Build paths call this once per load with the accumulated data
* extents; independent-zoom facets share the same base so visual
* zoom levels stay comparable across facets.
*/
setZoomBaseDomain(
xMin: number,
xMax: number,
yMin: number,
yMax: number,
): void {
if (this._zoomController) {
this._zoomController.setBaseDomain(xMin, xMax, yMin, yMax);
}
for (const zc of this._facetZoomControllers) {
if (zc) zc.setBaseDomain(xMin, xMax, yMin, yMax);
}
}

/**
* Zoom-controller config for this chart type. Subclasses override to
* pin an axis (e.g. bar charts pin the categorical axis). Default:
Expand Down Expand Up @@ -135,6 +204,28 @@ export abstract class AbstractChart implements ChartImplementation {
this._defaultChartType = chartType;
}

setFacetConfig(cfg: FacetConfig): void {
this._facetConfig = { ...cfg };
}

/**
* Install a new view for lazy row fetches. Disposes any prior
* fetcher and dismisses the pinned tooltip — the prior pinned
* row index has no guaranteed correspondence in the new view
* (pivot / filter / sort changes can all reshuffle rows).
*
* TODO: future work will keep pinned tooltips visible with their
* last-resolved lines until the user explicitly dismisses them,
* so a mid-session view update doesn't blow away focused context.
*/
setView(view: View): void {
if (this._lazyRows) {
this._lazyRows.dispose();
}
this._lazyRows = new LazyRowFetcher(view);
this._tooltip.dismissPinned();
}

// ── Render batching ────────────────────────────────────────────────────

/** Schedule one `_fullRender` on the next animation frame (idempotent). */
Expand Down Expand Up @@ -163,6 +254,10 @@ export abstract class AbstractChart implements ChartImplementation {
this._tooltip.detach();
this._tooltip.dismissPinned();
this._cancelScheduledRender();
if (this._lazyRows) {
this._lazyRows.dispose();
this._lazyRows = null;
}
this.destroyInternal();
}

Expand Down
Loading
Loading