diff --git a/examples/webgl-example/build.js b/examples/webgl-example/build.js
new file mode 100644
index 0000000000..14e631bba1
--- /dev/null
+++ b/examples/webgl-example/build.js
@@ -0,0 +1,39 @@
+// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
+// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
+// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
+// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
+// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
+// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
+// ┃ Copyright (c) 2017, the Perspective Authors. ┃
+// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
+// ┃ This file is part of the Perspective library, distributed under the terms ┃
+// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
+// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
+
+const esbuild = require("esbuild");
+const fs = require("fs");
+const path = require("path");
+
+async function build() {
+ await esbuild.build({
+ entryPoints: ["src/index.js"],
+ outdir: "dist",
+ format: "esm",
+ bundle: true,
+ target: "es2022",
+ loader: {
+ ".ttf": "file",
+ ".arrow": "file",
+ ".wasm": "file",
+ ".glsl": "text",
+ },
+ assetNames: "[name]",
+ });
+
+ fs.writeFileSync(
+ path.join(__dirname, "dist/index.html"),
+ fs.readFileSync(path.join(__dirname, "src/index.html")).toString(),
+ );
+}
+
+build();
diff --git a/examples/webgl-example/package.json b/examples/webgl-example/package.json
new file mode 100644
index 0000000000..4f91e039ba
--- /dev/null
+++ b/examples/webgl-example/package.json
@@ -0,0 +1,24 @@
+{
+ "name": "webgl-example",
+ "private": true,
+ "version": "4.3.0",
+ "description": "A WebGL chart plugin example built using `@perspective-dev/viewer-webgl`.",
+ "scripts": {
+ "build": "node build.js",
+ "start": "node build.js && http-server dist"
+ },
+ "keywords": [],
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@perspective-dev/client": "workspace:",
+ "@perspective-dev/viewer": "workspace:",
+ "@perspective-dev/viewer-datagrid": "workspace:",
+ "@perspective-dev/viewer-d3fc": "workspace:",
+ "@perspective-dev/viewer-webgl": "workspace:",
+ "superstore-arrow": "catalog:"
+ },
+ "devDependencies": {
+ "esbuild": "catalog:",
+ "http-server": "catalog:"
+ }
+}
diff --git a/examples/webgl-example/src/index.css b/examples/webgl-example/src/index.css
new file mode 100644
index 0000000000..546627936c
--- /dev/null
+++ b/examples/webgl-example/src/index.css
@@ -0,0 +1,7 @@
+perspective-viewer {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+}
diff --git a/examples/webgl-example/src/index.html b/examples/webgl-example/src/index.html
new file mode 100644
index 0000000000..9957aba8e5
--- /dev/null
+++ b/examples/webgl-example/src/index.html
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+ Perspective WebGL Plugin Example
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/webgl-example/src/index.js b/examples/webgl-example/src/index.js
new file mode 100644
index 0000000000..277eb7d569
--- /dev/null
+++ b/examples/webgl-example/src/index.js
@@ -0,0 +1,429 @@
+// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
+// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
+// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
+// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
+// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
+// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
+// ┃ Copyright (c) 2017, the Perspective Authors. ┃
+// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
+// ┃ This file is part of the Perspective library, distributed under the terms ┃
+// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
+// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
+
+import perspective from "@perspective-dev/client";
+import perspective_viewer from "@perspective-dev/viewer";
+import "@perspective-dev/viewer-datagrid";
+import "@perspective-dev/viewer-d3fc";
+import "@perspective-dev/viewer-webgl";
+
+import "@perspective-dev/viewer/dist/css/pro-dark.css";
+import "./index.css";
+
+import SERVER_WASM from "@perspective-dev/server/dist/wasm/perspective-server.wasm";
+import CLIENT_WASM from "@perspective-dev/viewer/dist/wasm/perspective-viewer.wasm";
+
+import arrow from "superstore-arrow/superstore.lz4.arrow";
+
+// --- Ticker universe by sector (S&P 500-ish) ---
+const SECTORS = {
+ Technology: [
+ "AAPL",
+ "MSFT",
+ "GOOGL",
+ "AMZN",
+ "META",
+ "NVDA",
+ "AVGO",
+ "ORCL",
+ "CRM",
+ "ADBE",
+ "AMD",
+ "INTC",
+ "QCOM",
+ "TXN",
+ "IBM",
+ "NOW",
+ "INTU",
+ "AMAT",
+ "MU",
+ "LRCX",
+ "ADI",
+ "KLAC",
+ "SNPS",
+ "CDNS",
+ "MRVL",
+ "FTNT",
+ "PANW",
+ "CRWD",
+ ],
+ Healthcare: [
+ "UNH",
+ "JNJ",
+ "LLY",
+ "PFE",
+ "ABBV",
+ "MRK",
+ "TMO",
+ "ABT",
+ "DHR",
+ "BMY",
+ "AMGN",
+ "MDT",
+ "GILD",
+ "ISRG",
+ "VRTX",
+ "SYK",
+ "BSX",
+ "REGN",
+ "ZTS",
+ "BDX",
+ "EW",
+ "HCA",
+ "IDXX",
+ "IQV",
+ "DXCM",
+ "BIIB",
+ "MRNA",
+ "ALGN",
+ ],
+ Financials: [
+ "JPM",
+ "V",
+ "MA",
+ "BAC",
+ "WFC",
+ "GS",
+ "MS",
+ "SCHW",
+ "BLK",
+ "SPGI",
+ "AXP",
+ "CB",
+ "MMC",
+ "PGR",
+ "ICE",
+ "CME",
+ "AON",
+ "MCO",
+ "MET",
+ "AIG",
+ "TRV",
+ "AFL",
+ "PRU",
+ "ALL",
+ "MSCI",
+ "FIS",
+ "FISV",
+ "COF",
+ ],
+ "Consumer Disc.": [
+ "TSLA",
+ "HD",
+ "MCD",
+ "NKE",
+ "SBUX",
+ "LOW",
+ "TJX",
+ "BKNG",
+ "CMG",
+ "MAR",
+ "ORLY",
+ "AZO",
+ "ROST",
+ "DHI",
+ "LEN",
+ "GM",
+ "F",
+ "YUM",
+ "DG",
+ "DLTR",
+ "EBAY",
+ "ETSY",
+ "BBY",
+ "POOL",
+ "ULTA",
+ "GRMN",
+ "DRI",
+ "MGM",
+ ],
+ Industrials: [
+ "CAT",
+ "UNP",
+ "HON",
+ "UPS",
+ "BA",
+ "RTX",
+ "DE",
+ "LMT",
+ "GE",
+ "MMM",
+ "FDX",
+ "EMR",
+ "ITW",
+ "ETN",
+ "WM",
+ "RSG",
+ "CSX",
+ "NSC",
+ "PCAR",
+ "GD",
+ "NOC",
+ "TT",
+ "ROK",
+ "FAST",
+ "ODFL",
+ "VRSK",
+ "IR",
+ "CTAS",
+ ],
+ Energy: [
+ "XOM",
+ "CVX",
+ "COP",
+ "SLB",
+ "EOG",
+ "MPC",
+ "PSX",
+ "VLO",
+ "OXY",
+ "DVN",
+ "HES",
+ "WMB",
+ "KMI",
+ "OKE",
+ "HAL",
+ "BKR",
+ "TRGP",
+ "CTRA",
+ ],
+ "Consumer Staples": [
+ "PG",
+ "KO",
+ "PEP",
+ "COST",
+ "WMT",
+ "PM",
+ "MO",
+ "CL",
+ "MDLZ",
+ "EL",
+ "GIS",
+ "KMB",
+ "SYY",
+ "HSY",
+ "KHC",
+ "TSN",
+ "MKC",
+ "CLX",
+ ],
+ Utilities: [
+ "NEE",
+ "DUK",
+ "SO",
+ "AEP",
+ "SRE",
+ "EXC",
+ "XEL",
+ "ED",
+ "WEC",
+ "ES",
+ "AWK",
+ "DTE",
+ "PPL",
+ "FE",
+ "AEE",
+ "CMS",
+ "EVRG",
+ "LNT",
+ ],
+ "Real Estate": [
+ "PLD",
+ "AMT",
+ "CCI",
+ "EQIX",
+ "PSA",
+ "SPG",
+ "DLR",
+ "WELL",
+ "AVB",
+ "EQR",
+ "VTR",
+ "ARE",
+ "MAA",
+ "UDR",
+ "HST",
+ "KIM",
+ "REG",
+ "CPT",
+ ],
+ Materials: [
+ "LIN",
+ "APD",
+ "SHW",
+ "FCX",
+ "NEM",
+ "ECL",
+ "DD",
+ "NUE",
+ "VMC",
+ "MLM",
+ "PPG",
+ "DOW",
+ "CTVA",
+ "ALB",
+ "FMC",
+ "CF",
+ "MOS",
+ "EMN",
+ ],
+ Communication: [
+ "DIS",
+ "CMCSA",
+ "NFLX",
+ "VZ",
+ "TMUS",
+ "CHTR",
+ "EA",
+ "TTWO",
+ "OMC",
+ "IPG",
+ "MTCH",
+ "LYV",
+ "RBLX",
+ "PINS",
+ "SNAP",
+ "ROKU",
+ "ZM",
+ "SPOT",
+ ],
+};
+
+const CORS_PROXY = "https://corsproxy.io/?url=";
+
+const ALL_TICKERS = Object.entries(SECTORS).flatMap(([sector, tickers]) =>
+ tickers.map((t) => ({ ticker: t, sector })),
+);
+
+// Fetch 1 year of daily OHLCV from Yahoo Finance via CORS proxy
+async function fetchTicker({ ticker, sector }) {
+ const period2 = Math.floor(Date.now() / 1000);
+ const period1 = period2 - 365 * 24 * 60 * 60;
+ const url = `https://query1.finance.yahoo.com/v8/finance/chart/${ticker}?period1=${period1}&period2=${period2}&interval=1d`;
+ try {
+ const resp = await fetch(CORS_PROXY + encodeURIComponent(url));
+ if (!resp.ok) return [];
+ const json = await resp.json();
+ const result = json.chart?.result?.[0];
+ if (!result) return [];
+ const timestamps = result.timestamp;
+ const q = result.indicators.quote[0];
+ const rows = [];
+ for (let i = 0; i < timestamps.length; i++) {
+ if (q.close[i] == null) continue;
+ rows.push({
+ Date: new Date(timestamps[i] * 1000),
+ Ticker: ticker,
+ Sector: sector,
+ Open: q.open[i],
+ High: q.high[i],
+ Low: q.low[i],
+ Close: q.close[i],
+ Volume: q.volume[i],
+ });
+ }
+ return rows;
+ } catch {
+ return [];
+ }
+}
+
+// Fetch in batches to avoid overwhelming the proxy
+async function fetchAllTickers(tickers, batchSize = 20) {
+ const allRows = [];
+ for (let i = 0; i < tickers.length; i += batchSize) {
+ const batch = tickers.slice(i, i + batchSize);
+ const results = await Promise.all(batch.map(fetchTicker));
+ for (const rows of results) allRows.push(...rows);
+ console.log(
+ `Fetched ${Math.min(i + batchSize, tickers.length)}/${tickers.length} tickers (${allRows.length} rows)`,
+ );
+ }
+ return allRows;
+}
+
+// --- Init and load ---
+await Promise.all([
+ perspective.init_server(fetch(SERVER_WASM)),
+ perspective_viewer.init_client(fetch(CLIENT_WASM)),
+]);
+
+const viewer = document.createElement("perspective-viewer");
+document.body.append(viewer);
+const worker = await perspective.worker();
+
+// Configure plugin based on URL params
+const params = new URLSearchParams(window.location.search);
+const view = params.get("v");
+if (view === "treemap") {
+ console.log(`Fetching ${ALL_TICKERS.length} tickers from Yahoo Finance...`);
+ const data = await fetchAllTickers(ALL_TICKERS);
+ console.log(`Loaded ${data.length} total rows`);
+
+ const table = worker.table(data);
+ viewer.load(table);
+ viewer.restore({
+ plugin: "GPU Treemap",
+ group_by: ["Sector", "Ticker"],
+ columns: ["Volume", null, null],
+ });
+} else if (view === "treemap2") {
+ const req = fetch(arrow);
+ const resp = await req;
+ const buffer = await resp.arrayBuffer();
+ const table = worker.table(buffer);
+ viewer.load(table);
+ viewer.restore({
+ plugin: "GPU Treemap",
+ group_by: ["Region", "State", "Product Name"],
+ columns: ["Sales", "Profit", null],
+ });
+} else if (view === "stonks") {
+ console.log(`Fetching ${ALL_TICKERS.length} tickers from Yahoo Finance...`);
+ const data = await fetchAllTickers(ALL_TICKERS);
+ console.log(`Loaded ${data.length} total rows`);
+
+ const table = worker.table(data);
+ viewer.load(table);
+ viewer.restore({
+ version: "4.3.0",
+ plugin: "GPU Treemap",
+ settings: true,
+ theme: "Pro Dark",
+ group_by: ["Sector", "Ticker"],
+ split_by: [],
+ sort: [["Close", "desc"]],
+ columns: ["Close", null, null],
+ aggregates: {
+ Close: "last minus first",
+ },
+ });
+} else if (view === "line") {
+ console.log(`Fetching ${ALL_TICKERS.length} tickers from Yahoo Finance...`);
+ const data = await fetchAllTickers(ALL_TICKERS);
+ console.log(`Loaded ${data.length} total rows`);
+
+ const table = worker.table(data);
+ viewer.load(table);
+ viewer.restore({
+ plugin: "GPU Line",
+ filter: [["Ticker", "in", ["AAPL", "MSFT", "GOOGL", "AMZN", "META"]]],
+ split_by: ["Ticker"],
+ sort: [["Date", "asc"]],
+ columns: ["Date", "Close"],
+ });
+} else {
+ viewer.restore({
+ plugin: "GPU Scatter",
+ columns: ["Date", "Close", "Volume", "Sector", "Ticker"],
+ });
+}
diff --git a/package.json b/package.json
index 4cb6560fa0..2afe59390b 100644
--- a/package.json
+++ b/package.json
@@ -27,6 +27,7 @@
"packages/viewer-datagrid",
"packages/viewer-d3fc",
"packages/viewer-openlayers",
+ "packages/viewer-webgl",
"packages/workspace",
"packages/jupyterlab",
"packages/cli",
@@ -48,6 +49,7 @@
"@perspective-dev/viewer-d3fc": "workspace:^",
"@perspective-dev/viewer-datagrid": "workspace:^",
"@perspective-dev/viewer-openlayers": "workspace:^",
+ "@perspective-dev/viewer-webgl": "workspace:^",
"@perspective-dev/workspace": "workspace:^",
"@playwright/experimental-ct-react": "catalog:",
"@playwright/test": "catalog:",
diff --git a/packages/viewer-webgl/build.mjs b/packages/viewer-webgl/build.mjs
new file mode 100644
index 0000000000..01728ef815
--- /dev/null
+++ b/packages/viewer-webgl/build.mjs
@@ -0,0 +1,56 @@
+// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
+// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
+// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
+// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
+// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
+// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
+// ┃ Copyright (c) 2017, the Perspective Authors. ┃
+// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
+// ┃ This file is part of the Perspective library, distributed under the terms ┃
+// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
+// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
+
+import { NodeModulesExternal } from "@perspective-dev/esbuild-plugin/external.js";
+import { build } from "@perspective-dev/esbuild-plugin/build.js";
+import { execSync } from "node:child_process";
+
+const BUILD = [
+ {
+ entryPoints: ["src/ts/index.ts"],
+ define: {
+ global: "window",
+ },
+ plugins: [NodeModulesExternal()],
+ format: "esm",
+ loader: {
+ ".css": "text",
+ ".glsl": "text",
+ },
+ outfile: "dist/esm/perspective-viewer-webgl.js",
+ },
+ {
+ entryPoints: ["src/ts/index.ts"],
+ define: {
+ global: "window",
+ },
+ plugins: [],
+ format: "esm",
+ loader: {
+ ".css": "text",
+ ".glsl": "text",
+ },
+ outfile: "dist/cdn/perspective-viewer-webgl.js",
+ },
+];
+
+async function build_all() {
+ await Promise.all(BUILD.map(build)).catch(() => process.exit(1));
+ try {
+ execSync("tsc", { stdio: "inherit" });
+ } catch (error) {
+ console.error(error);
+ process.exit(1);
+ }
+}
+
+build_all();
diff --git a/packages/viewer-webgl/clean.mjs b/packages/viewer-webgl/clean.mjs
new file mode 100644
index 0000000000..49a5253931
--- /dev/null
+++ b/packages/viewer-webgl/clean.mjs
@@ -0,0 +1,15 @@
+// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
+// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
+// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
+// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
+// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
+// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
+// ┃ Copyright (c) 2017, the Perspective Authors. ┃
+// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
+// ┃ This file is part of the Perspective library, distributed under the terms ┃
+// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
+// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
+
+import * as fs from "node:fs";
+
+fs.rmSync("dist", { recursive: true, force: true });
diff --git a/packages/viewer-webgl/package.json b/packages/viewer-webgl/package.json
new file mode 100644
index 0000000000..7afef24e42
--- /dev/null
+++ b/packages/viewer-webgl/package.json
@@ -0,0 +1,47 @@
+{
+ "name": "@perspective-dev/viewer-webgl",
+ "version": "4.3.0",
+ "description": "Perspective.js WebGL Plugin",
+ "unpkg": "./dist/cdn/perspective-viewer-webgl.js",
+ "jsdelivr": "./dist/cdn/perspective-viewer-webgl.js",
+ "type": "module",
+ "exports": {
+ ".": {
+ "types": "./dist/esm/index.d.ts",
+ "default": "./dist/esm/perspective-viewer-webgl.js"
+ },
+ "./src/*": "./src/*",
+ "./dist/*": "./dist/*",
+ "./cdn/": "./dist/cdn/",
+ "./esm/": "./dist/esm/",
+ "./package.json": "./package.json"
+ },
+ "files": [
+ "dist/**/*",
+ "src/**/*"
+ ],
+ "types": "dist/esm/index.d.ts",
+ "scripts": {
+ "build": "node ./build.mjs",
+ "clean": "node ./clean.mjs"
+ },
+ "publishConfig": {
+ "access": "public"
+ },
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/perspective-dev/perspective"
+ },
+ "author": "",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@perspective-dev/client": "workspace:",
+ "@perspective-dev/viewer": "workspace:",
+ "apache-arrow": "catalog:"
+ },
+ "devDependencies": {
+ "@perspective-dev/esbuild-plugin": "workspace:",
+ "lightningcss": "catalog:",
+ "typescript": "catalog:"
+ }
+}
diff --git a/packages/viewer-webgl/src/css/perspective-viewer-webgl.css b/packages/viewer-webgl/src/css/perspective-viewer-webgl.css
new file mode 100644
index 0000000000..8b6d005d44
--- /dev/null
+++ b/packages/viewer-webgl/src/css/perspective-viewer-webgl.css
@@ -0,0 +1,121 @@
+:host {
+ position: relative;
+ display: block;
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+
+ --psp-webgl--axis-ticks--color: var(
+ --psp-d3fc--axis-ticks--color,
+ rgba(160, 160, 160, 0.8)
+ );
+ --psp-webgl--axis-lines--color: var(
+ --psp-d3fc--axis-lines--color,
+ rgba(160, 160, 160, 0.4)
+ );
+ --psp-webgl--gridline--color: var(
+ --psp-d3fc--gridline--color,
+ rgba(128, 128, 128, 0.8)
+ );
+ --psp-webgl--label--color: var(
+ --psp-d3fc--label--color,
+ var(--psp-d3fc--axis-ticks--color, rgba(180, 180, 180, 0.9))
+ );
+ --psp-webgl--legend--color: var(
+ --psp-d3fc--legend--color,
+ var(--psp-d3fc--axis-ticks--color, rgba(180, 180, 180, 0.9))
+ );
+ --psp-webgl--gradient-start--color: var(
+ --psp-d3fc--series-1--color,
+ #0366d6
+ );
+ --psp-webgl--gradient-end--color: var(--psp-d3fc--series-2--color, #ff7f0e);
+ --psp-webgl--tooltip--background: var(
+ --psp-d3fc--tooltip--background-color,
+ rgba(155, 155, 155, 0.8)
+ );
+ --psp-webgl--tooltip--color: var(--psp-d3fc--tooltip--color, #161616);
+ --psp-webgl--tooltip--border-color: var(
+ --psp-d3fc--tooltip--border-color,
+ #fff
+ );
+ --psp-webgl--legend-border--color: var(
+ --psp-d3fc--axis-lines--color,
+ rgba(128, 128, 128, 0.3)
+ );
+ --psp-webgl--font-family: var(
+ --psp-interface-monospace--font-family,
+ "ui-monospace",
+ "SFMono-Regular",
+ "SF Mono",
+ "Menlo",
+ "Consolas",
+ "Liberation Mono",
+ monospace
+ );
+}
+
+.webgl-container {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ top: 0;
+ left: 0;
+}
+
+.webgl-canvas {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ top: 0;
+ left: 0;
+}
+
+.webgl-gridlines {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ top: 0;
+ left: 0;
+ pointer-events: none;
+}
+
+.webgl-chrome {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ top: 0;
+ left: 0;
+ pointer-events: none;
+}
+
+.zoom-controls {
+ position: absolute;
+ top: 8px;
+ left: 50%;
+ transform: translateX(-50%);
+ display: none;
+ z-index: 2;
+ pointer-events: auto;
+}
+
+.zoom-controls.visible {
+ display: flex;
+ gap: 6px;
+}
+
+.zoom-reset {
+ background: var(--psp-webgl--tooltip--background);
+ color: var(--psp-webgl--tooltip--color);
+ border: 1px solid var(--psp-webgl--tooltip--border-color);
+ border-radius: 4px;
+ padding: 4px 12px;
+ font: 11px var(--psp-webgl--font-family);
+ cursor: pointer;
+ opacity: 0.7;
+ transition: opacity 0.15s;
+}
+
+.zoom-reset:hover {
+ opacity: 1;
+}
diff --git a/packages/viewer-webgl/src/ts/charts/chart.ts b/packages/viewer-webgl/src/ts/charts/chart.ts
new file mode 100644
index 0000000000..743d7b6e5c
--- /dev/null
+++ b/packages/viewer-webgl/src/ts/charts/chart.ts
@@ -0,0 +1,50 @@
+// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
+// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
+// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
+// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
+// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
+// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
+// ┃ Copyright (c) 2017, the Perspective Authors. ┃
+// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
+// ┃ This file is part of the Perspective library, distributed under the terms ┃
+// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
+// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
+
+import type { ColumnDataMap } from "../data/arrow-reader";
+import type { WebGLContextManager } from "../webgl/context-manager";
+import type { ZoomController } from "../interaction/zoom-controller";
+
+export interface ChartImplementation {
+ uploadAndRender(
+ glManager: WebGLContextManager,
+ columns: ColumnDataMap,
+ startRow: number,
+ endRow: number,
+ ): void;
+
+ /** Re-render with existing GPU buffer data (e.g., after resize). */
+ redraw(glManager: WebGLContextManager): void;
+
+ /** Set the gridline canvas (behind WebGL, for gridlines). */
+ setGridlineCanvas?(canvas: HTMLCanvasElement): void;
+
+ /** Set the chrome canvas (above WebGL, for axes/labels/legend/tooltip). */
+ setChromeCanvas?(canvas: HTMLCanvasElement): void;
+
+ /** Set the zoom controller for interactive zoom/pan. */
+ setZoomController?(zc: ZoomController): void;
+
+ /** Attach tooltip mouse handlers to the GL canvas. */
+ attachTooltip?(glCanvas: HTMLCanvasElement): void;
+
+ /** Set the column slot config (with nulls for empty slots). */
+ setColumnSlots?(slots: (string | null)[]): void;
+
+ /** Set group_by and split_by config from the viewer. */
+ setViewPivots?(groupBy: string[], splitBy: string[]): void;
+
+ /** Set column type schema from the view (e.g., { "col": "date" }). */
+ setColumnTypes?(schema: Record): void;
+
+ destroy(): void;
+}
diff --git a/packages/viewer-webgl/src/ts/charts/line.ts b/packages/viewer-webgl/src/ts/charts/line.ts
new file mode 100644
index 0000000000..ba121bafef
--- /dev/null
+++ b/packages/viewer-webgl/src/ts/charts/line.ts
@@ -0,0 +1,1073 @@
+// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
+// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
+// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
+// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
+// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
+// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
+// ┃ Copyright (c) 2017, the Perspective Authors. ┃
+// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
+// ┃ This file is part of the Perspective library, distributed under the terms ┃
+// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
+// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
+
+import type { ColumnDataMap } from "../data/arrow-reader";
+import type { WebGLContextManager } from "../webgl/context-manager";
+import type { ChartImplementation } from "./chart";
+import type { ZoomController } from "../interaction/zoom-controller";
+import { SpatialGrid } from "../interaction/spatial-grid";
+import { parseCSSColorToVec3, getCSSVar } from "../utils/css";
+import { PlotLayout } from "../layout/plot-layout";
+import {
+ computeTicks,
+ renderGridlines,
+ renderAxesChrome,
+ type AxisDomain,
+} from "../layout/axes";
+import { renderCategoricalLegend } from "../layout/legend";
+import { formatTickValue, formatDateTickValue } from "../layout/ticks";
+import lineVert from "../shaders/line.vert.glsl";
+import lineFrag from "../shaders/line.frag.glsl";
+
+const TOOLTIP_RADIUS_PX = 24;
+const LINE_WIDTH_PX = 2.0;
+
+// ── Types ──────────────────────────────────────────────────────────────────────
+
+interface SplitGroup {
+ prefix: string;
+ xColName: string;
+ yColName: string;
+}
+
+interface CachedLocations {
+ u_projection: WebGLUniformLocation | null;
+ u_color: WebGLUniformLocation | null;
+ u_resolution: WebGLUniformLocation | null;
+ u_line_width: WebGLUniformLocation | null;
+ a_start: number;
+ a_end: number;
+ a_corner: number;
+}
+
+// ── LineChart ──────────────────────────────────────────────────────────────────
+
+export class LineChart implements ChartImplementation {
+ private _program: WebGLProgram | null = null;
+ private _locations: CachedLocations | null = null;
+ private _cornerBuffer: WebGLBuffer | null = null;
+ private _gridlineCanvas: HTMLCanvasElement | null = null;
+ private _chromeCanvas: HTMLCanvasElement | null = null;
+ private _zoomController: ZoomController | null = null;
+ private _glManager: WebGLContextManager | null = null;
+
+ // Column config
+ private _columnSlots: (string | null)[] = [];
+ private _groupBy: string[] = [];
+ private _splitBy: string[] = [];
+ private _columnTypes: Record = {};
+ private _xName = "";
+ private _yName = "";
+ private _xLabel = "";
+ private _yLabel = "";
+ private _xIsRowIndex = false;
+ private _splitGroups: SplitGroup[] = [];
+
+ // Series-first VBO: series i at [i*cap .. (i+1)*cap), raw data points
+ private _seriesCapacity = 0;
+ private _seriesUploadedCounts: number[] = [];
+
+ // Data domain
+ private _xMin = Infinity;
+ private _xMax = -Infinity;
+ private _yMin = Infinity;
+ private _yMax = -Infinity;
+
+ // CPU hit-test data (flat: series i at [i*cap .. i*cap+count))
+ private _xData: Float32Array | null = null;
+ private _yData: Float32Array | null = null;
+
+ // Reusable staging buffer (one series at a time)
+ private _stagingPositions: Float32Array | null = null;
+ private _stagingSize = 0;
+
+ // Spatial index
+ private _spatialGrid: SpatialGrid | null = null;
+ private _spatialGridDirty = true;
+
+ // Categorical colors (split prefix → index)
+ private _uniqueColorLabels: Map = new Map();
+
+ // Tooltip state
+ private _lastLayout: PlotLayout | null = null;
+ private _hoveredIndex = -1;
+ private _pinnedIndex = -1;
+ private _pinnedTooltip: HTMLDivElement | null = null;
+ private _glCanvas: HTMLCanvasElement | null = null;
+ private _mouseMoveHandler: ((e: MouseEvent) => void) | null = null;
+ private _mouseLeaveHandler: (() => void) | null = null;
+ private _clickHandler: ((e: MouseEvent) => void) | null = null;
+ private _hoverRAFId = 0;
+
+ // Render batching
+ private _renderScheduled = false;
+ private _renderRAFId = 0;
+
+ // Cached chrome state
+ private _lastXDomain: AxisDomain | null = null;
+ private _lastYDomain: AxisDomain | null = null;
+ private _lastXTicks: number[] | null = null;
+ private _lastYTicks: number[] | null = null;
+ private _lastColorStart: [number, number, number] = [0.13, 0.4, 0.84];
+ private _lastColorEnd: [number, number, number] = [1.0, 0.5, 0.06];
+
+ // ── Lifecycle ──────────────────────────────────────────────────────────────
+
+ setGridlineCanvas(canvas: HTMLCanvasElement): void {
+ this._gridlineCanvas = canvas;
+ }
+
+ setChromeCanvas(canvas: HTMLCanvasElement): void {
+ this._chromeCanvas = canvas;
+ }
+
+ setZoomController(zc: ZoomController): void {
+ this._zoomController = zc;
+ }
+
+ setColumnSlots(slots: (string | null)[]): void {
+ this._columnSlots = slots;
+ }
+
+ setViewPivots(groupBy: string[], splitBy: string[]): void {
+ this._groupBy = groupBy;
+ this._splitBy = splitBy;
+ }
+
+ setColumnTypes(schema: Record): void {
+ this._columnTypes = schema;
+ }
+
+ attachTooltip(glCanvas: HTMLCanvasElement): void {
+ this._glCanvas = glCanvas;
+
+ this._mouseMoveHandler = (e: MouseEvent) => {
+ if (this._pinnedIndex >= 0) return;
+ if (this._hoverRAFId) return;
+ const rect = glCanvas.getBoundingClientRect();
+ const mx = e.clientX - rect.left;
+ const my = e.clientY - rect.top;
+ this._hoverRAFId = requestAnimationFrame(() => {
+ this._hoverRAFId = 0;
+ this._handleHover(mx, my);
+ });
+ };
+
+ this._mouseLeaveHandler = () => {
+ if (this._pinnedIndex >= 0) return;
+ if (this._hoveredIndex !== -1) {
+ this._hoveredIndex = -1;
+ this._renderChromeOverlay();
+ }
+ };
+
+ this._clickHandler = () => {
+ if (this._pinnedIndex >= 0) {
+ this._dismissPinnedTooltip();
+ return;
+ }
+ if (this._hoveredIndex >= 0) {
+ this._showPinnedTooltip(this._hoveredIndex);
+ }
+ };
+
+ glCanvas.addEventListener("mousemove", this._mouseMoveHandler);
+ glCanvas.addEventListener("mouseleave", this._mouseLeaveHandler);
+ glCanvas.addEventListener("click", this._clickHandler);
+ }
+
+ private _detachTooltip(): void {
+ if (this._glCanvas) {
+ if (this._mouseMoveHandler) {
+ this._glCanvas.removeEventListener(
+ "mousemove",
+ this._mouseMoveHandler,
+ );
+ }
+ if (this._mouseLeaveHandler) {
+ this._glCanvas.removeEventListener(
+ "mouseleave",
+ this._mouseLeaveHandler,
+ );
+ }
+ if (this._clickHandler) {
+ this._glCanvas.removeEventListener("click", this._clickHandler);
+ }
+ }
+ this._mouseMoveHandler = null;
+ this._mouseLeaveHandler = null;
+ this._clickHandler = null;
+ }
+
+ // ── Split groups ───────────────────────────────────────────────────────────
+
+ private _buildSplitGroups(
+ columns: ColumnDataMap,
+ xBase: string,
+ yBase: string,
+ ): SplitGroup[] {
+ const grouped = new Map>();
+ for (const key of columns.keys()) {
+ if (key.startsWith("__")) continue;
+ const pipeIdx = key.lastIndexOf("|");
+ if (pipeIdx === -1) continue;
+ const prefix = key.substring(0, pipeIdx);
+ if (!grouped.has(prefix)) grouped.set(prefix, new Set());
+ grouped.get(prefix)!.add(key);
+ }
+
+ const groups: SplitGroup[] = [];
+ for (const [prefix, colNames] of grouped) {
+ const yColName = `${prefix}|${yBase}`;
+ if (!colNames.has(yColName)) continue;
+ const yCol = columns.get(yColName);
+ if (!yCol?.values) continue;
+
+ if (xBase) {
+ const xColName = `${prefix}|${xBase}`;
+ if (!colNames.has(xColName)) continue;
+ const xCol = columns.get(xColName);
+ if (!xCol?.values) continue;
+ groups.push({ prefix, xColName, yColName });
+ } else {
+ groups.push({ prefix, xColName: "", yColName });
+ }
+ }
+ return groups;
+ }
+
+ // ── Upload ─────────────────────────────────────────────────────────────────
+
+ uploadAndRender(
+ glManager: WebGLContextManager,
+ columns: ColumnDataMap,
+ startRow: number,
+ endRow: number,
+ ): void {
+ const chunkLength = endRow - startRow;
+ this._glManager = glManager;
+ const gl = glManager.gl;
+
+ // ── First chunk: init ──────────────────────────────────────────────────
+ if (startRow === 0) {
+ // Cancel any pending RAF from the previous stream.
+ if (this._renderRAFId) {
+ cancelAnimationFrame(this._renderRAFId);
+ this._renderRAFId = 0;
+ this._renderScheduled = false;
+ }
+
+ if (!this._program) {
+ this._program = glManager.shaders.getOrCreate(
+ "line",
+ lineVert,
+ lineFrag,
+ );
+ const p = this._program;
+ this._locations = {
+ u_projection: gl.getUniformLocation(p, "u_projection"),
+ u_color: gl.getUniformLocation(p, "u_color"),
+ u_resolution: gl.getUniformLocation(p, "u_resolution"),
+ u_line_width: gl.getUniformLocation(p, "u_line_width"),
+ a_start: gl.getAttribLocation(p, "a_start"),
+ a_end: gl.getAttribLocation(p, "a_end"),
+ a_corner: gl.getAttribLocation(p, "a_corner"),
+ };
+
+ // Static corner buffer: [0, 1, 2, 3]
+ this._cornerBuffer = gl.createBuffer()!;
+ gl.bindBuffer(gl.ARRAY_BUFFER, this._cornerBuffer);
+ gl.bufferData(
+ gl.ARRAY_BUFFER,
+ new Float32Array([0, 1, 2, 3]),
+ gl.STATIC_DRAW,
+ );
+ }
+
+ this._xMin = Infinity;
+ this._xMax = -Infinity;
+ this._yMin = Infinity;
+ this._yMax = -Infinity;
+ this._spatialGrid = null;
+ this._spatialGridDirty = true;
+ this._uniqueColorLabels = new Map();
+
+ const slots = this._columnSlots;
+ const xBase = slots[0] || "";
+ const yBase = slots[1] || "";
+ this._xLabel = xBase;
+ this._yLabel = yBase;
+ this._xIsRowIndex = !xBase;
+
+ if (this._splitBy.length > 0) {
+ this._splitGroups = this._buildSplitGroups(
+ columns,
+ xBase,
+ yBase,
+ );
+ if (this._splitGroups.length === 0) return;
+ for (const sg of this._splitGroups) {
+ if (!this._uniqueColorLabels.has(sg.prefix)) {
+ this._uniqueColorLabels.set(
+ sg.prefix,
+ this._uniqueColorLabels.size,
+ );
+ }
+ }
+ this._xName = this._splitGroups[0].xColName;
+ this._yName = this._splitGroups[0].yColName;
+ } else {
+ this._splitGroups = [];
+ this._xName = xBase;
+ this._yName = yBase;
+ }
+
+ const numSeries = Math.max(1, this._splitGroups.length);
+ const rowsPerSeries = glManager.bufferPool.totalCapacity || endRow;
+ this._seriesCapacity = rowsPerSeries;
+ this._seriesUploadedCounts = new Array(numSeries).fill(0);
+
+ // Raw position buffer — no expansion needed
+ glManager.ensureBufferCapacity(numSeries * rowsPerSeries);
+
+ const cpuCap = numSeries * rowsPerSeries;
+ this._xData = new Float32Array(cpuCap);
+ this._yData = new Float32Array(cpuCap);
+ }
+
+ if (!this._locations) return;
+
+ const numSeries = Math.max(1, this._splitGroups.length);
+
+ // ── Leaf filter ────────────────────────────────────────────────────────
+ let rowIndices: number[] | null = null;
+ if (this._groupBy.length > 0) {
+ const rowPathCol = columns.get("__ROW_PATH__");
+ if (rowPathCol?.type === "list-string" && rowPathCol.listValues) {
+ rowIndices = [];
+ for (let i = 0; i < chunkLength; i++) {
+ if (
+ rowPathCol.listValues[i].length === this._groupBy.length
+ ) {
+ rowIndices.push(i);
+ }
+ }
+ }
+ }
+
+ const sourceLength = rowIndices ? rowIndices.length : chunkLength;
+ if (sourceLength === 0) return;
+
+ // Ensure staging buffer
+ if (this._stagingSize < sourceLength) {
+ this._stagingPositions = new Float32Array(sourceLength * 2);
+ this._stagingSize = sourceLength;
+ }
+ const positions = this._stagingPositions!;
+
+ const hasSplits = this._splitGroups.length > 0;
+
+ // ── Process each series ────────────────────────────────────────────────
+ for (let s = 0; s < numSeries; s++) {
+ let xValues: Float32Array | Int32Array | null = null;
+ let yValues: Float32Array | Int32Array | null = null;
+ let xValid: Uint8Array | undefined;
+ let yValid: Uint8Array | undefined;
+
+ if (hasSplits) {
+ const sg = this._splitGroups[s];
+ if (sg.xColName) {
+ const xc = columns.get(sg.xColName);
+ if (!xc?.values) continue;
+ xValues = xc.values;
+ xValid = xc.valid;
+ }
+ const yc = columns.get(sg.yColName);
+ if (!yc?.values) continue;
+ yValues = yc.values;
+ yValid = yc.valid;
+ } else {
+ if (!this._xIsRowIndex && this._xName) {
+ const xc = columns.get(this._xName);
+ if (!xc?.values) continue;
+ xValues = xc.values;
+ xValid = xc.valid;
+ }
+ if (this._yName) {
+ const yc = columns.get(this._yName);
+ if (!yc?.values) continue;
+ yValues = yc.values;
+ yValid = yc.valid;
+ }
+ }
+
+ if (!yValues) continue;
+
+ let writeIdx = 0;
+ const prevCount = this._seriesUploadedCounts[s];
+ const cpuBase = s * this._seriesCapacity + prevCount;
+
+ for (let j = 0; j < sourceLength; j++) {
+ const i = rowIndices ? rowIndices[j] : j;
+ if (yValid && !yValid[i]) continue;
+ if (xValid && !xValid[i]) continue;
+
+ const y = yValues[i] as number;
+ const x = xValues ? (xValues[i] as number) : startRow + i;
+ if (isNaN(x) || isNaN(y)) continue;
+
+ if (x < this._xMin) this._xMin = x;
+ if (x > this._xMax) this._xMax = x;
+ if (y < this._yMin) this._yMin = y;
+ if (y > this._yMax) this._yMax = y;
+
+ this._xData![cpuBase + writeIdx] = x;
+ this._yData![cpuBase + writeIdx] = y;
+
+ positions[writeIdx * 2] = x;
+ positions[writeIdx * 2 + 1] = y;
+ writeIdx++;
+ }
+
+ if (writeIdx === 0) continue;
+
+ const byteOffset =
+ (s * this._seriesCapacity + prevCount) *
+ 2 *
+ Float32Array.BYTES_PER_ELEMENT;
+ glManager.bufferPool.upload(
+ "a_position",
+ positions.subarray(0, writeIdx * 2),
+ byteOffset,
+ 2,
+ );
+
+ this._seriesUploadedCounts[s] += writeIdx;
+ }
+
+ glManager.uploadedCount = this._seriesUploadedCounts.reduce(
+ (a, c) => a + c,
+ 0,
+ );
+ this._spatialGridDirty = true;
+
+ if (this._zoomController && isFinite(this._xMin)) {
+ this._zoomController.setBaseDomain(
+ this._xMin,
+ this._xMax,
+ this._yMin,
+ this._yMax,
+ );
+ }
+
+ this._scheduleRender(glManager);
+ }
+
+ private _scheduleRender(glManager: WebGLContextManager): void {
+ if (this._renderScheduled) return;
+ this._renderScheduled = true;
+ this._renderRAFId = requestAnimationFrame(() => {
+ this._renderScheduled = false;
+ this._renderRAFId = 0;
+ this._fullRender(glManager);
+ });
+ }
+
+ redraw(glManager: WebGLContextManager): void {
+ if (!this._program || glManager.uploadedCount === 0) return;
+ this._glManager = glManager;
+ this._fullRender(glManager);
+ }
+
+ // ── Full render ────────────────────────────────────────────────────────────
+
+ private _fullRender(glManager: WebGLContextManager): void {
+ const gl = glManager.gl;
+ const dpr = window.devicePixelRatio || 1;
+ const cssWidth = gl.canvas.width / dpr;
+ const cssHeight = gl.canvas.height / dpr;
+ if (cssWidth <= 0 || cssHeight <= 0) return;
+
+ const numSeries = Math.max(1, this._splitGroups.length);
+ const hasSplits = this._splitGroups.length > 0;
+
+ let domain: { xMin: number; xMax: number; yMin: number; yMax: number };
+ if (this._zoomController) {
+ domain = this._zoomController.getVisibleDomain();
+ } else {
+ domain = {
+ xMin: this._xMin,
+ xMax: this._xMax,
+ yMin: this._yMin,
+ yMax: this._yMax,
+ };
+ }
+ if (!isFinite(domain.xMin) || !isFinite(domain.yMin)) return;
+
+ const layout = new PlotLayout(cssWidth, cssHeight, {
+ hasXLabel: !!this._xLabel,
+ hasYLabel: !!this._yLabel,
+ hasLegend: hasSplits,
+ });
+ this._lastLayout = layout;
+
+ if (this._zoomController) {
+ this._zoomController.updateLayout(layout);
+ }
+
+ const projection = layout.buildProjectionMatrix(
+ domain.xMin,
+ domain.xMax,
+ domain.yMin,
+ domain.yMax,
+ );
+
+ const themeEl = this._gridlineCanvas!;
+ const colorStart = parseCSSColorToVec3(
+ getCSSVar(themeEl, "--psp-webgl--gradient-start--color", "#0366d6"),
+ );
+ const colorEnd = parseCSSColorToVec3(
+ getCSSVar(themeEl, "--psp-webgl--gradient-end--color", "#ff7f0e"),
+ );
+
+ const xType = this._columnTypes[this._xLabel] || "";
+ const yType = this._columnTypes[this._yLabel] || "";
+ const xIsDate = xType === "date" || xType === "datetime";
+ const yIsDate = yType === "date" || yType === "datetime";
+
+ const xDomain: AxisDomain = {
+ min: domain.xMin,
+ max: domain.xMax,
+ label: this._xLabel || (this._xIsRowIndex ? "Row" : ""),
+ isDate: xIsDate,
+ };
+ const yDomain: AxisDomain = {
+ min: domain.yMin,
+ max: domain.yMax,
+ label: this._yLabel,
+ isDate: yIsDate,
+ };
+ const { xTicks, yTicks } = computeTicks(xDomain, yDomain, layout);
+
+ // Layer 1 — gridlines
+ if (this._gridlineCanvas) {
+ renderGridlines(
+ this._gridlineCanvas,
+ layout,
+ xTicks,
+ yTicks,
+ this._gridlineCanvas,
+ );
+ }
+
+ // Layer 2 — WebGL instanced line segments
+ gl.clearColor(0, 0, 0, 0);
+ gl.clear(gl.COLOR_BUFFER_BIT);
+ gl.enable(gl.BLEND);
+ gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
+ gl.enable(gl.SCISSOR_TEST);
+ gl.scissor(
+ Math.round(layout.margins.left * dpr),
+ Math.round(layout.margins.bottom * dpr),
+ Math.round(layout.plotRect.width * dpr),
+ Math.round(layout.plotRect.height * dpr),
+ );
+
+ gl.useProgram(this._program!);
+ const loc = this._locations!;
+ gl.uniformMatrix4fv(loc.u_projection, false, projection);
+ gl.uniform2f(loc.u_resolution, gl.canvas.width, gl.canvas.height);
+ gl.uniform1f(loc.u_line_width, LINE_WIDTH_PX * dpr);
+
+ const posBuf = glManager.bufferPool.getOrCreate(
+ "a_position",
+ 2,
+ Float32Array.BYTES_PER_ELEMENT,
+ );
+
+ // Instancing support (WebGL2 native or ANGLE extension)
+ const gl2 = glManager.isWebGL2 ? (gl as WebGL2RenderingContext) : null;
+ const ext = !gl2
+ ? (gl.getExtension(
+ "ANGLE_instanced_arrays",
+ ) as ANGLE_instanced_arrays | null)
+ : null;
+
+ const setDivisor = (location: number, divisor: number) => {
+ if (gl2) gl2.vertexAttribDivisor(location, divisor);
+ else if (ext) ext.vertexAttribDivisorANGLE(location, divisor);
+ };
+
+ const drawInstanced = (
+ mode: number,
+ first: number,
+ count: number,
+ instances: number,
+ ) => {
+ if (gl2) gl2.drawArraysInstanced(mode, first, count, instances);
+ else if (ext)
+ ext.drawArraysInstancedANGLE(mode, first, count, instances);
+ };
+
+ // Corner buffer (per-vertex, divisor=0)
+ gl.bindBuffer(gl.ARRAY_BUFFER, this._cornerBuffer!);
+ gl.enableVertexAttribArray(loc.a_corner);
+ gl.vertexAttribPointer(loc.a_corner, 1, gl.FLOAT, false, 0, 0);
+ setDivisor(loc.a_corner, 0);
+
+ // Draw each series
+ for (let s = 0; s < numSeries; s++) {
+ const count = this._seriesUploadedCounts[s] ?? 0;
+ if (count < 2) continue;
+
+ const t = numSeries === 1 ? 0.5 : s / (numSeries - 1);
+ const r = colorStart[0] + t * (colorEnd[0] - colorStart[0]);
+ const g = colorStart[1] + t * (colorEnd[1] - colorStart[1]);
+ const b = colorStart[2] + t * (colorEnd[2] - colorStart[2]);
+ gl.uniform4f(loc.u_color, r, g, b, 1.0);
+
+ const seriesStart = s * this._seriesCapacity;
+ const startBytes = seriesStart * 2 * Float32Array.BYTES_PER_ELEMENT;
+ const stride = 2 * Float32Array.BYTES_PER_ELEMENT;
+
+ // a_start: position[seriesStart + instance]
+ gl.bindBuffer(gl.ARRAY_BUFFER, posBuf.buffer);
+ gl.enableVertexAttribArray(loc.a_start);
+ gl.vertexAttribPointer(
+ loc.a_start,
+ 2,
+ gl.FLOAT,
+ false,
+ stride,
+ startBytes,
+ );
+ setDivisor(loc.a_start, 1);
+
+ // a_end: position[seriesStart + 1 + instance]
+ gl.enableVertexAttribArray(loc.a_end);
+ gl.vertexAttribPointer(
+ loc.a_end,
+ 2,
+ gl.FLOAT,
+ false,
+ stride,
+ startBytes + stride,
+ );
+ setDivisor(loc.a_end, 1);
+
+ drawInstanced(gl.TRIANGLE_STRIP, 0, 4, count - 1);
+ }
+
+ // Reset divisors so other draw calls aren't affected
+ setDivisor(loc.a_start, 0);
+ setDivisor(loc.a_end, 0);
+
+ gl.disable(gl.SCISSOR_TEST);
+
+ this._lastXDomain = xDomain;
+ this._lastYDomain = yDomain;
+ this._lastXTicks = xTicks;
+ this._lastYTicks = yTicks;
+ this._lastColorStart = colorStart;
+ this._lastColorEnd = colorEnd;
+
+ // Layer 3 — chrome overlay
+ this._renderChromeOverlay();
+ }
+
+ private _renderChromeOverlay(): void {
+ if (
+ !this._chromeCanvas ||
+ !this._lastLayout ||
+ !this._lastXDomain ||
+ !this._lastYDomain
+ )
+ return;
+
+ const layout = this._lastLayout;
+
+ renderAxesChrome(
+ this._chromeCanvas,
+ this._lastXDomain,
+ this._lastYDomain,
+ layout,
+ this._lastXTicks!,
+ this._lastYTicks!,
+ );
+
+ if (this._uniqueColorLabels.size > 0) {
+ renderCategoricalLegend(
+ this._chromeCanvas,
+ layout,
+ this._uniqueColorLabels,
+ this._lastColorStart,
+ this._lastColorEnd,
+ );
+ }
+
+ if (this._hoveredIndex >= 0 && this._xData) {
+ this._renderTooltip(this._chromeCanvas, layout);
+ }
+ }
+
+ // ── Tooltip ────────────────────────────────────────────────────────────────
+
+ private _ensureSpatialGrid(): void {
+ if (!this._spatialGridDirty || !this._xData || !this._yData) return;
+
+ const xRange = this._xMax - this._xMin || 1;
+ const yRange = this._yMax - this._yMin || 1;
+ const avgRange = (xRange + yRange) / 2;
+ const totalUploaded = this._seriesUploadedCounts.reduce(
+ (a, c) => a + c,
+ 0,
+ );
+ const cellSize = avgRange / Math.max(1, Math.sqrt(totalUploaded));
+
+ const grid = new SpatialGrid(
+ this._xMin,
+ this._xMax,
+ this._yMin,
+ this._yMax,
+ cellSize,
+ );
+
+ const totalSeries = this._splitGroups.length || 1;
+ for (let s = 0; s < totalSeries; s++) {
+ const count = this._seriesUploadedCounts[s] ?? 0;
+ const base = s * this._seriesCapacity;
+ for (let j = 0; j < count; j++) {
+ grid.insert(
+ base + j,
+ this._xData[base + j],
+ this._yData[base + j],
+ );
+ }
+ }
+
+ this._spatialGrid = grid;
+ this._spatialGridDirty = false;
+ }
+
+ private _handleHover(mx: number, my: number): void {
+ if (!this._xData || !this._yData || !this._lastLayout) return;
+
+ const layout = this._lastLayout;
+ const plot = layout.plotRect;
+
+ if (
+ mx < plot.x ||
+ mx > plot.x + plot.width ||
+ my < plot.y ||
+ my > plot.y + plot.height
+ ) {
+ if (this._hoveredIndex !== -1) {
+ this._hoveredIndex = -1;
+ this._renderChromeOverlay();
+ }
+ return;
+ }
+
+ const xMin = layout.paddedXMin;
+ const xMax = layout.paddedXMax;
+ const yMin = layout.paddedYMin;
+ const yMax = layout.paddedYMax;
+ const dataX = xMin + ((mx - plot.x) / plot.width) * (xMax - xMin);
+ const dataY = yMax - ((my - plot.y) / plot.height) * (yMax - yMin);
+ const pxPerDataX = plot.width / (xMax - xMin);
+ const pxPerDataY = plot.height / (yMax - yMin);
+
+ this._ensureSpatialGrid();
+ let bestIdx: number;
+ if (this._spatialGrid) {
+ bestIdx = this._spatialGrid.query(
+ dataX,
+ dataY,
+ TOOLTIP_RADIUS_PX,
+ pxPerDataX,
+ pxPerDataY,
+ this._xData,
+ this._yData,
+ );
+ } else {
+ bestIdx = -1;
+ let bestDistSq = TOOLTIP_RADIUS_PX * TOOLTIP_RADIUS_PX;
+ const totalSeries = this._splitGroups.length || 1;
+ for (let s = 0; s < totalSeries; s++) {
+ const count = this._seriesUploadedCounts[s] ?? 0;
+ const base = s * this._seriesCapacity;
+ for (let j = 0; j < count; j++) {
+ const idx = base + j;
+ const dx = (this._xData[idx] - dataX) * pxPerDataX;
+ const dy = (this._yData[idx] - dataY) * pxPerDataY;
+ const distSq = dx * dx + dy * dy;
+ if (distSq < bestDistSq) {
+ bestDistSq = distSq;
+ bestIdx = idx;
+ }
+ }
+ }
+ }
+
+ if (bestIdx !== this._hoveredIndex) {
+ this._hoveredIndex = bestIdx;
+ this._renderChromeOverlay();
+ }
+ }
+
+ private _renderTooltip(
+ canvas: HTMLCanvasElement,
+ layout: PlotLayout,
+ ): void {
+ const idx = this._hoveredIndex;
+ if (idx < 0 || !this._xData || !this._yData) return;
+
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return;
+
+ const dpr = window.devicePixelRatio || 1;
+ ctx.save();
+ ctx.scale(dpr, dpr);
+
+ const xVal = this._xData[idx];
+ const yVal = this._yData[idx];
+ const pos = layout.dataToPixel(xVal, yVal);
+ const lines = this._buildTooltipLines(idx, xVal, yVal);
+
+ const fontFamily = getCSSVar(
+ canvas,
+ "--psp-webgl--font-family",
+ "monospace",
+ );
+ const tickColor = getCSSVar(
+ canvas,
+ "--psp-webgl--axis-ticks--color",
+ "rgba(128,128,128,0.8)",
+ );
+ const tooltipBg = getCSSVar(
+ canvas,
+ "--psp-webgl--tooltip--background",
+ "rgba(155,155,155,0.8)",
+ );
+ const tooltipBorder = getCSSVar(
+ canvas,
+ "--psp-webgl--tooltip--border-color",
+ "#fff",
+ );
+ const tooltipText = getCSSVar(
+ canvas,
+ "--psp-webgl--tooltip--color",
+ "#161616",
+ );
+
+ ctx.font = `11px ${fontFamily}`;
+ const lineHeight = 16;
+ const padding = 8;
+ let maxWidth = 0;
+ for (const line of lines) {
+ const w = ctx.measureText(line).width;
+ if (w > maxWidth) maxWidth = w;
+ }
+ const boxW = maxWidth + padding * 2;
+ const boxH = lines.length * lineHeight + padding * 2 - 4;
+
+ let tx = pos.px + 12;
+ let ty = pos.py - boxH - 8;
+ if (tx + boxW > layout.cssWidth) tx = pos.px - boxW - 12;
+ if (ty < 0) ty = pos.py + 12;
+ if (ty + boxH > layout.cssHeight) ty = layout.cssHeight - boxH - 4;
+
+ // Crosshair
+ ctx.strokeStyle = tickColor;
+ ctx.globalAlpha = 0.3;
+ ctx.lineWidth = 1;
+ ctx.setLineDash([4, 4]);
+ ctx.beginPath();
+ ctx.moveTo(pos.px, layout.plotRect.y);
+ ctx.lineTo(pos.px, layout.plotRect.y + layout.plotRect.height);
+ ctx.moveTo(layout.plotRect.x, pos.py);
+ ctx.lineTo(layout.plotRect.x + layout.plotRect.width, pos.py);
+ ctx.stroke();
+ ctx.setLineDash([]);
+ ctx.globalAlpha = 1.0;
+
+ // Highlight dot
+ ctx.strokeStyle = tickColor;
+ ctx.globalAlpha = 0.8;
+ ctx.lineWidth = 2;
+ ctx.beginPath();
+ ctx.arc(pos.px, pos.py, 5, 0, Math.PI * 2);
+ ctx.stroke();
+ ctx.globalAlpha = 1.0;
+
+ // Box
+ ctx.fillStyle = tooltipBg;
+ ctx.strokeStyle = tooltipBorder;
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ ctx.roundRect(tx, ty, boxW, boxH, 4);
+ ctx.fill();
+ ctx.stroke();
+
+ // Text
+ ctx.fillStyle = tooltipText;
+ ctx.textAlign = "left";
+ ctx.textBaseline = "top";
+ for (let i = 0; i < lines.length; i++) {
+ ctx.fillText(lines[i], tx + padding, ty + padding + i * lineHeight);
+ }
+
+ ctx.restore();
+ }
+
+ private _buildTooltipLines(
+ flatIdx: number,
+ xVal: number,
+ yVal: number,
+ ): string[] {
+ const lines: string[] = [];
+
+ if (this._splitGroups.length > 0 && this._seriesCapacity > 0) {
+ const seriesIdx = Math.floor(flatIdx / this._seriesCapacity);
+ const sg = this._splitGroups[seriesIdx];
+ if (sg) lines.push(sg.prefix);
+ }
+
+ const xType = this._columnTypes[this._xLabel] || "";
+ const xIsDate = xType === "date" || xType === "datetime";
+ const xFormatted = xIsDate
+ ? formatDateTickValue(xVal)
+ : formatTickValue(xVal);
+ lines.push(`${this._xLabel || "Row"}: ${xFormatted}`);
+
+ const yType = this._columnTypes[this._yLabel] || "";
+ const yIsDate = yType === "date" || yType === "datetime";
+ const yFormatted = yIsDate
+ ? formatDateTickValue(yVal)
+ : formatTickValue(yVal);
+ lines.push(`${this._yLabel}: ${yFormatted}`);
+
+ return lines;
+ }
+
+ private _showPinnedTooltip(pointIdx: number): void {
+ this._dismissPinnedTooltip();
+ this._pinnedIndex = pointIdx;
+
+ if (pointIdx < 0 || !this._xData || !this._yData || !this._lastLayout)
+ return;
+
+ const layout = this._lastLayout;
+ const xVal = this._xData[pointIdx];
+ const yVal = this._yData[pointIdx];
+ const pos = layout.dataToPixel(xVal, yVal);
+ const lines = this._buildTooltipLines(pointIdx, xVal, yVal);
+ if (lines.length === 0) return;
+
+ const themeEl = this._gridlineCanvas || this._chromeCanvas;
+ const tooltipBg = themeEl
+ ? getCSSVar(
+ themeEl,
+ "--psp-webgl--tooltip--background",
+ "rgba(155,155,155,0.8)",
+ )
+ : "rgba(155,155,155,0.8)";
+ const tooltipText = themeEl
+ ? getCSSVar(themeEl, "--psp-webgl--tooltip--color", "#161616")
+ : "#161616";
+ const tooltipBorder = themeEl
+ ? getCSSVar(themeEl, "--psp-webgl--tooltip--border-color", "#fff")
+ : "#fff";
+ const fontFamily = themeEl
+ ? getCSSVar(themeEl, "--psp-webgl--font-family", "monospace")
+ : "monospace";
+
+ const div = document.createElement("div");
+ div.style.cssText = [
+ "position:absolute",
+ "pointer-events:auto",
+ `font:11px ${fontFamily}`,
+ `background:${tooltipBg}`,
+ `color:${tooltipText}`,
+ `border:1px solid ${tooltipBorder}`,
+ "border-radius:4px",
+ "padding:8px",
+ "overflow-y:auto",
+ `max-height:${Math.round(layout.cssHeight * 0.6)}px`,
+ "white-space:pre",
+ "z-index:10",
+ "line-height:16px",
+ ].join(";");
+
+ div.textContent = lines.join("\n");
+
+ const parent = this._glCanvas?.parentElement;
+ if (!parent) return;
+ parent.style.position = "relative";
+ div.style.left = "-9999px";
+ div.style.top = "0px";
+ parent.appendChild(div);
+ this._pinnedTooltip = div;
+
+ const divW = div.getBoundingClientRect().width;
+ const divH = div.getBoundingClientRect().height;
+ let tx = pos.px + 12;
+ let ty = pos.py - divH - 8;
+ if (tx + divW > layout.cssWidth) tx = pos.px - divW - 12;
+ if (tx < 0) tx = 4;
+ if (ty < 0) ty = pos.py + 12;
+ if (ty + divH > layout.cssHeight) ty = layout.cssHeight - divH - 4;
+
+ div.style.left = `${tx}px`;
+ div.style.top = `${ty}px`;
+
+ this._hoveredIndex = -1;
+ this._renderChromeOverlay();
+ }
+
+ private _dismissPinnedTooltip(): void {
+ if (this._pinnedTooltip) {
+ this._pinnedTooltip.remove();
+ this._pinnedTooltip = null;
+ }
+ this._pinnedIndex = -1;
+ }
+
+ // ── Cleanup ────────────────────────────────────────────────────────────────
+
+ destroy(): void {
+ this._detachTooltip();
+ this._dismissPinnedTooltip();
+ if (this._renderRAFId) {
+ cancelAnimationFrame(this._renderRAFId);
+ this._renderRAFId = 0;
+ this._renderScheduled = false;
+ }
+ if (this._hoverRAFId) {
+ cancelAnimationFrame(this._hoverRAFId);
+ this._hoverRAFId = 0;
+ }
+ if (this._cornerBuffer && this._glManager) {
+ this._glManager.gl.deleteBuffer(this._cornerBuffer);
+ }
+ this._program = null;
+ this._locations = null;
+ this._cornerBuffer = null;
+ this._spatialGrid = null;
+ this._xData = null;
+ this._yData = null;
+ this._stagingPositions = null;
+ this._uniqueColorLabels = new Map();
+ this._splitGroups = [];
+ this._seriesUploadedCounts = [];
+ }
+}
diff --git a/packages/viewer-webgl/src/ts/charts/scatter.ts b/packages/viewer-webgl/src/ts/charts/scatter.ts
new file mode 100644
index 0000000000..06d548c219
--- /dev/null
+++ b/packages/viewer-webgl/src/ts/charts/scatter.ts
@@ -0,0 +1,1364 @@
+// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
+// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
+// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
+// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
+// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
+// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
+// ┃ Copyright (c) 2017, the Perspective Authors. ┃
+// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
+// ┃ This file is part of the Perspective library, distributed under the terms ┃
+// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
+// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
+
+import type { ColumnDataMap } from "../data/arrow-reader";
+import type { WebGLContextManager } from "../webgl/context-manager";
+import type { ChartImplementation } from "./chart";
+import type { ZoomController } from "../interaction/zoom-controller";
+import { SpatialGrid } from "../interaction/spatial-grid";
+import { parseCSSColorToVec3, getCSSVar } from "../utils/css";
+import { PlotLayout } from "../layout/plot-layout";
+import {
+ computeTicks,
+ renderGridlines,
+ renderAxesChrome,
+ type AxisDomain,
+} from "../layout/axes";
+import { renderLegend, renderCategoricalLegend } from "../layout/legend";
+import { formatTickValue, formatDateTickValue } from "../layout/ticks";
+import scatterVert from "../shaders/scatter.vert.glsl";
+import scatterFrag from "../shaders/scatter.frag.glsl";
+
+const TOOLTIP_RADIUS_PX = 24;
+
+interface CachedLocations {
+ u_projection: WebGLUniformLocation | null;
+ u_point_size: WebGLUniformLocation | null;
+ u_color_range: WebGLUniformLocation | null;
+ u_color_start: WebGLUniformLocation | null;
+ u_color_end: WebGLUniformLocation | null;
+ u_size_range: WebGLUniformLocation | null;
+ u_point_size_range: WebGLUniformLocation | null;
+ a_position: number;
+ a_color_value: number;
+ a_size_value: number;
+}
+
+interface SplitGroup {
+ prefix: string;
+ xColName: string;
+ yColName: string;
+ colorColName: string;
+ sizeColName: string;
+}
+
+export class ScatterChart implements ChartImplementation {
+ private _program: WebGLProgram | null = null;
+ private _locations: CachedLocations | null = null;
+ private _vao: WebGLVertexArrayObject | null = null;
+ private _vaoSetup = false;
+ private _gridlineCanvas: HTMLCanvasElement | null = null;
+ private _chromeCanvas: HTMLCanvasElement | null = null;
+ private _zoomController: ZoomController | null = null;
+ private _glManager: WebGLContextManager | null = null;
+
+ // Column slots from viewer config (with nulls for empty slots)
+ // Slots: [X Axis, Y Axis, Color, Size, Tooltip]
+ private _columnSlots: (string | null)[] = [];
+ private _groupBy: string[] = [];
+ private _splitBy: string[] = [];
+ private _allColumns: string[] = [];
+ private _xName = "";
+ private _yName = "";
+ private _xLabel = "";
+ private _yLabel = "";
+ private _colorName = "";
+ private _sizeName = "";
+ private _colorIsString = false;
+ private _columnTypes: Record = {};
+ private _uniqueColorLabels: Map = new Map();
+ private _tooltipColumns: string[] = [];
+ private _writeCursor = 0;
+ private _splitGroups: SplitGroup[] = [];
+
+ // Data domain (raw, un-zoomed)
+ private _xMin = Infinity;
+ private _xMax = -Infinity;
+ private _yMin = Infinity;
+ private _yMax = -Infinity;
+ private _colorMin = Infinity;
+ private _colorMax = -Infinity;
+ private _sizeMin = Infinity;
+ private _sizeMax = -Infinity;
+
+ // CPU-side data for hit testing and tooltips
+ private _xData: Float32Array | null = null;
+ private _yData: Float32Array | null = null;
+ private _colorData: Float32Array | null = null;
+ // Typed arrays for numeric columns, regular arrays only for strings
+ private _numericRowData: Map = new Map();
+ private _stringRowData: Map = new Map();
+ private _dataCount = 0;
+
+ // Render batching: coalesce multiple chunk uploads into one frame
+ private _renderScheduled = false;
+ private _renderRAFId = 0;
+
+ // Reusable staging buffers (avoids per-chunk allocations)
+ private _stagingPositions: Float32Array | null = null;
+ private _stagingColors: Float32Array | null = null;
+ private _stagingSizes: Float32Array | null = null;
+ private _stagingChunkSize = 0;
+
+ // Spatial index for fast tooltip hit testing
+ private _spatialGrid: SpatialGrid | null = null;
+ private _spatialGridDirty = true;
+
+ // Tooltip state
+ private _lastLayout: PlotLayout | null = null;
+ private _mouseMoveHandler: ((e: MouseEvent) => void) | null = null;
+ private _mouseLeaveHandler: (() => void) | null = null;
+ private _clickHandler: ((e: MouseEvent) => void) | null = null;
+ private _hoverRAFId = 0;
+ private _hoveredIndex = -1;
+ private _pinnedIndex = -1;
+ private _pinnedTooltip: HTMLDivElement | null = null;
+ private _glCanvas: HTMLCanvasElement | null = null;
+
+ // Cached chrome render state (avoids full re-render on hover)
+ private _lastXDomain: AxisDomain | null = null;
+ private _lastYDomain: AxisDomain | null = null;
+ private _lastXTicks: ReturnType["xTicks"] | null =
+ null;
+ private _lastYTicks: ReturnType["yTicks"] | null =
+ null;
+ private _lastColorStart: [number, number, number] = [0, 0, 0];
+ private _lastColorEnd: [number, number, number] = [0, 0, 0];
+ private _lastHasColorCol = false;
+
+ setGridlineCanvas(canvas: HTMLCanvasElement): void {
+ this._gridlineCanvas = canvas;
+ }
+
+ setChromeCanvas(canvas: HTMLCanvasElement): void {
+ this._chromeCanvas = canvas;
+ }
+
+ /**
+ * Attach tooltip mouse handlers to the WebGL canvas (not overlay).
+ * Call after setGridlineCanvas.
+ */
+ attachTooltip(glCanvas: HTMLCanvasElement): void {
+ this._detachTooltip();
+ this._glCanvas = glCanvas;
+
+ this._mouseMoveHandler = (e: MouseEvent) => {
+ if (this._pinnedIndex >= 0) return; // Don't hover while pinned
+ if (this._hoverRAFId) return;
+ const rect = glCanvas.getBoundingClientRect();
+ const mx = e.clientX - rect.left;
+ const my = e.clientY - rect.top;
+ this._hoverRAFId = requestAnimationFrame(() => {
+ this._hoverRAFId = 0;
+ this._handleHover(mx, my);
+ });
+ };
+
+ this._mouseLeaveHandler = () => {
+ if (this._pinnedIndex >= 0) return;
+ if (this._hoveredIndex !== -1) {
+ this._hoveredIndex = -1;
+ this._renderChromeOverlay();
+ }
+ };
+
+ this._clickHandler = () => {
+ if (this._pinnedIndex >= 0) {
+ this._dismissPinnedTooltip();
+ return;
+ }
+ if (this._hoveredIndex >= 0) {
+ this._showPinnedTooltip(this._hoveredIndex);
+ }
+ };
+
+ glCanvas.addEventListener("mousemove", this._mouseMoveHandler);
+ glCanvas.addEventListener("mouseleave", this._mouseLeaveHandler);
+ glCanvas.addEventListener("click", this._clickHandler);
+ }
+
+ private _detachTooltip(): void {
+ if (this._glCanvas) {
+ if (this._mouseMoveHandler) {
+ this._glCanvas.removeEventListener(
+ "mousemove",
+ this._mouseMoveHandler,
+ );
+ }
+ if (this._mouseLeaveHandler) {
+ this._glCanvas.removeEventListener(
+ "mouseleave",
+ this._mouseLeaveHandler,
+ );
+ }
+ if (this._clickHandler) {
+ this._glCanvas.removeEventListener("click", this._clickHandler);
+ }
+ }
+ this._mouseMoveHandler = null;
+ this._mouseLeaveHandler = null;
+ this._clickHandler = null;
+ }
+
+ private _ensureSpatialGrid(): void {
+ if (!this._spatialGridDirty || !this._xData || !this._yData) return;
+ if (this._dataCount === 0) return;
+
+ // Cell size in data units: aim for ~sqrt(n) cells for good distribution
+ const xRange = this._xMax - this._xMin || 1;
+ const yRange = this._yMax - this._yMin || 1;
+ const avgRange = (xRange + yRange) / 2;
+ const cellSize = avgRange / Math.max(1, Math.sqrt(this._dataCount));
+
+ const grid = new SpatialGrid(
+ this._xMin,
+ this._xMax,
+ this._yMin,
+ this._yMax,
+ cellSize,
+ );
+ for (let i = 0; i < this._dataCount; i++) {
+ grid.insert(i, this._xData[i], this._yData[i]);
+ }
+ this._spatialGrid = grid;
+ this._spatialGridDirty = false;
+ }
+
+ private _handleHover(mx: number, my: number): void {
+ if (
+ !this._xData ||
+ !this._yData ||
+ !this._lastLayout ||
+ !this._glManager
+ )
+ return;
+
+ const layout = this._lastLayout;
+ const plot = layout.plotRect;
+
+ // Only respond in plot area
+ if (
+ mx < plot.x ||
+ mx > plot.x + plot.width ||
+ my < plot.y ||
+ my > plot.y + plot.height
+ ) {
+ if (this._hoveredIndex !== -1) {
+ this._hoveredIndex = -1;
+ this._fullRender(this._glManager);
+ }
+ return;
+ }
+
+ // Use the padded domain (matches the WebGL projection exactly)
+ const xMin = layout.paddedXMin;
+ const xMax = layout.paddedXMax;
+ const yMin = layout.paddedYMin;
+ const yMax = layout.paddedYMax;
+
+ // Convert mouse to data coords
+ const dataX = xMin + ((mx - plot.x) / plot.width) * (xMax - xMin);
+ const dataY = yMax - ((my - plot.y) / plot.height) * (yMax - yMin);
+
+ const pxPerDataX = plot.width / (xMax - xMin);
+ const pxPerDataY = plot.height / (yMax - yMin);
+
+ // Use spatial grid for O(1) lookup when available
+ let bestIdx: number;
+ this._ensureSpatialGrid();
+ if (this._spatialGrid) {
+ bestIdx = this._spatialGrid.query(
+ dataX,
+ dataY,
+ TOOLTIP_RADIUS_PX,
+ pxPerDataX,
+ pxPerDataY,
+ this._xData,
+ this._yData,
+ );
+ } else {
+ // Fallback: linear scan (only before grid is built)
+ bestIdx = -1;
+ let bestDistSq = TOOLTIP_RADIUS_PX * TOOLTIP_RADIUS_PX;
+ for (let i = 0; i < this._dataCount; i++) {
+ const dx = (this._xData[i] - dataX) * pxPerDataX;
+ const dy = (this._yData[i] - dataY) * pxPerDataY;
+ const distSq = dx * dx + dy * dy;
+ if (distSq < bestDistSq) {
+ bestDistSq = distSq;
+ bestIdx = i;
+ }
+ }
+ }
+
+ if (bestIdx !== this._hoveredIndex) {
+ this._hoveredIndex = bestIdx;
+ this._renderChromeOverlay();
+ }
+ }
+
+ setColumnSlots(slots: (string | null)[]): void {
+ this._columnSlots = slots;
+ }
+
+ setViewPivots(groupBy: string[], splitBy: string[]): void {
+ this._groupBy = groupBy;
+ this._splitBy = splitBy;
+ }
+
+ setColumnTypes(schema: Record): void {
+ this._columnTypes = schema;
+ }
+
+ /**
+ * Build split groups from composite Arrow column names.
+ * Groups columns by their split prefix (everything before the last "|").
+ */
+ private _buildSplitGroups(
+ columns: ColumnDataMap,
+ xBase: string,
+ yBase: string,
+ colorBase: string,
+ sizeBase: string,
+ ): SplitGroup[] {
+ const grouped = new Map>();
+ for (const key of columns.keys()) {
+ if (key.startsWith("__")) continue;
+ const pipeIdx = key.lastIndexOf("|");
+ if (pipeIdx === -1) continue;
+ const prefix = key.substring(0, pipeIdx);
+ if (!grouped.has(prefix)) grouped.set(prefix, new Set());
+ grouped.get(prefix)!.add(key);
+ }
+
+ const groups: SplitGroup[] = [];
+ for (const [prefix, colNames] of grouped) {
+ const xColName = `${prefix}|${xBase}`;
+ const yColName = `${prefix}|${yBase}`;
+ if (!colNames.has(xColName) || !colNames.has(yColName)) continue;
+ const xCol = columns.get(xColName);
+ const yCol = columns.get(yColName);
+ if (!xCol?.values || !yCol?.values) continue;
+ groups.push({
+ prefix,
+ xColName,
+ yColName,
+ colorColName: colorBase ? `${prefix}|${colorBase}` : "",
+ sizeColName: sizeBase ? `${prefix}|${sizeBase}` : "",
+ });
+ }
+ return groups;
+ }
+
+ setZoomController(zc: ZoomController): void {
+ this._zoomController = zc;
+ }
+
+ uploadAndRender(
+ glManager: WebGLContextManager,
+ columns: ColumnDataMap,
+ startRow: number,
+ endRow: number,
+ ): void {
+ const chunkLength = endRow - startRow;
+ this._glManager = glManager;
+
+ if (!this._program) {
+ this._program = glManager.shaders.getOrCreate(
+ "scatter",
+ scatterVert,
+ scatterFrag,
+ );
+ const gl = glManager.gl;
+ const p = this._program;
+ this._locations = {
+ u_projection: gl.getUniformLocation(p, "u_projection"),
+ u_point_size: gl.getUniformLocation(p, "u_point_size"),
+ u_color_range: gl.getUniformLocation(p, "u_color_range"),
+ u_color_start: gl.getUniformLocation(p, "u_color_start"),
+ u_color_end: gl.getUniformLocation(p, "u_color_end"),
+ u_size_range: gl.getUniformLocation(p, "u_size_range"),
+ u_point_size_range: gl.getUniformLocation(
+ p,
+ "u_point_size_range",
+ ),
+ a_position: gl.getAttribLocation(p, "a_position"),
+ a_color_value: gl.getAttribLocation(p, "a_color_value"),
+ a_size_value: gl.getAttribLocation(p, "a_size_value"),
+ };
+ }
+
+ // Reset domain tracking on first chunk
+ if (startRow === 0) {
+ // Cancel any pending RAF from the previous stream.
+ if (this._renderRAFId) {
+ cancelAnimationFrame(this._renderRAFId);
+ this._renderRAFId = 0;
+ this._renderScheduled = false;
+ }
+
+ this._allColumns = Array.from(columns.keys()).filter(
+ (k) => !k.startsWith("__"),
+ );
+ this._xMin = Infinity;
+ this._xMax = -Infinity;
+ this._yMin = Infinity;
+ this._yMax = -Infinity;
+ this._colorMin = Infinity;
+ this._colorMax = -Infinity;
+ this._sizeMin = Infinity;
+ this._sizeMax = -Infinity;
+ this._dataCount = 0;
+ this._writeCursor = 0;
+ this._numericRowData = new Map();
+ this._stringRowData = new Map();
+ this._uniqueColorLabels = new Map();
+ this._spatialGrid = null;
+ this._spatialGridDirty = true;
+ this._vaoSetup = false;
+
+ const slots = this._columnSlots;
+ const xBase = slots[0] || this._allColumns[0] || "";
+ const yBase = slots[1] || this._allColumns[1] || "";
+ const colorBase = slots[2] || "";
+ const sizeBase = slots[3] || "";
+ this._xLabel = xBase;
+ this._yLabel = yBase;
+
+ if (this._splitBy.length > 0) {
+ // Build split groups from composite column names
+ this._splitGroups = this._buildSplitGroups(
+ columns,
+ xBase,
+ yBase,
+ colorBase,
+ sizeBase,
+ );
+ if (this._splitGroups.length === 0) return;
+ // Use split prefix as string color
+ this._colorIsString = true;
+ this._xName = this._splitGroups[0].xColName;
+ this._yName = this._splitGroups[0].yColName;
+ this._colorName = "";
+ this._sizeName = "";
+ // Grow buffer capacity to hold rows × splits
+ const baseCap = glManager.bufferPool.totalCapacity || endRow;
+ glManager.ensureBufferCapacity(
+ baseCap * this._splitGroups.length,
+ );
+ // Extract unique base column names for tooltips
+ const baseNames = new Set();
+ for (const key of this._allColumns) {
+ const pipeIdx = key.lastIndexOf("|");
+ baseNames.add(
+ pipeIdx === -1 ? key : key.substring(pipeIdx + 1),
+ );
+ }
+ this._tooltipColumns = ["Split", ...baseNames];
+ } else {
+ this._splitGroups = [];
+ this._xName = xBase;
+ this._yName = yBase;
+ this._colorName = colorBase;
+ this._sizeName = sizeBase;
+ this._colorIsString = false;
+
+ if (this._colorName) {
+ const colorCol = columns.get(this._colorName);
+ this._colorIsString = colorCol?.type === "string";
+ }
+ }
+
+ if (this._splitBy.length === 0) {
+ this._tooltipColumns = this._allColumns.slice(0);
+ }
+ }
+
+ if (!this._xName || !this._yName) return;
+
+ // Filter to leaf rows when group_by is active
+ let rowIndices: number[] | null = null;
+ if (this._groupBy.length > 0) {
+ const rowPathCol = columns.get("__ROW_PATH__");
+ if (rowPathCol?.type === "list-string" && rowPathCol.listValues) {
+ rowIndices = [];
+ for (let i = 0; i < chunkLength; i++) {
+ if (
+ rowPathCol.listValues[i].length === this._groupBy.length
+ ) {
+ rowIndices.push(i);
+ }
+ }
+ }
+ }
+
+ const sourceLength = rowIndices ? rowIndices.length : chunkLength;
+ if (sourceLength === 0) return;
+
+ // Determine series to iterate: split groups or a single series
+ const hasSplits = this._splitGroups.length > 0;
+ const series: {
+ xCol: Float32Array | Int32Array;
+ yCol: Float32Array | Int32Array;
+ xValid: Uint8Array | undefined;
+ yValid: Uint8Array | undefined;
+ colorLabel: string;
+ sizeCol: (Float32Array | Int32Array) | null;
+ }[] = [];
+
+ if (hasSplits) {
+ for (const sg of this._splitGroups) {
+ const xc = columns.get(sg.xColName);
+ const yc = columns.get(sg.yColName);
+ if (!xc?.values || !yc?.values) continue;
+ const sc = sg.sizeColName ? columns.get(sg.sizeColName) : null;
+ series.push({
+ xCol: xc.values,
+ yCol: yc.values,
+ xValid: xc.valid,
+ yValid: yc.valid,
+ colorLabel: sg.prefix,
+ sizeCol: sc?.values ?? null,
+ });
+ }
+ } else {
+ const xc = columns.get(this._xName);
+ const yc = columns.get(this._yName);
+ if (!xc?.values || !yc?.values) return;
+ series.push({
+ xCol: xc.values,
+ yCol: yc.values,
+ xValid: xc.valid,
+ yValid: yc.valid,
+ colorLabel: "",
+ sizeCol: null,
+ });
+ }
+
+ if (series.length === 0) return;
+
+ const totalPoints = sourceLength * series.length;
+
+ // Allocate CPU arrays
+ const totalCapacity = glManager.bufferPool.totalCapacity || endRow;
+ if (!this._xData || this._xData.length < totalCapacity) {
+ this._xData = new Float32Array(totalCapacity);
+ this._yData = new Float32Array(totalCapacity);
+ this._colorData = new Float32Array(totalCapacity);
+ }
+
+ const destStart = this._writeCursor;
+ const maxWrite = totalCapacity - destStart;
+ if (maxWrite <= 0) return;
+
+ // Ensure staging buffers (capped to what fits in remaining capacity)
+ const stagingSize = Math.min(totalPoints, maxWrite);
+ if (this._stagingChunkSize < stagingSize) {
+ this._stagingPositions = new Float32Array(stagingSize * 2);
+ this._stagingColors = new Float32Array(stagingSize);
+ this._stagingSizes = new Float32Array(stagingSize);
+ this._stagingChunkSize = stagingSize;
+ }
+
+ const positions = this._stagingPositions!;
+ const colorValues = this._stagingColors!;
+ const sizeValues = this._stagingSizes!;
+ let writeIdx = 0;
+
+ // Store __ROW_PATH__ tooltip data if present
+ if (this._groupBy.length > 0) {
+ const rowPathCol = columns.get("__ROW_PATH__");
+ if (rowPathCol?.type === "list-string" && rowPathCol.listValues) {
+ if (!this._stringRowData.has("__ROW_PATH__")) {
+ this._stringRowData.set(
+ "__ROW_PATH__",
+ new Array(totalCapacity),
+ );
+ }
+ const arr = this._stringRowData.get("__ROW_PATH__")!;
+ for (let s = 0; s < series.length; s++) {
+ for (let j = 0; j < sourceLength; j++) {
+ const i = rowIndices ? rowIndices[j] : j;
+ arr[destStart + s * sourceLength + j] =
+ rowPathCol.listValues[i].join(" / ");
+ }
+ }
+ }
+ }
+
+ // Prepare split label and tooltip storage
+ let splitLabelArr: string[] | null = null;
+ let splitXArr: Float32Array | null = null;
+ let splitYArr: Float32Array | null = null;
+ if (hasSplits) {
+ if (!this._stringRowData.has("Split")) {
+ this._stringRowData.set("Split", new Array(totalCapacity));
+ }
+ splitLabelArr = this._stringRowData.get("Split")!;
+
+ if (!this._numericRowData.has(this._xLabel)) {
+ this._numericRowData.set(
+ this._xLabel,
+ new Float32Array(totalCapacity),
+ );
+ }
+ if (!this._numericRowData.has(this._yLabel)) {
+ this._numericRowData.set(
+ this._yLabel,
+ new Float32Array(totalCapacity),
+ );
+ }
+ splitXArr = this._numericRowData.get(this._xLabel)!;
+ splitYArr = this._numericRowData.get(this._yLabel)!;
+ }
+
+ for (let s = 0; s < series.length; s++) {
+ if (writeIdx >= maxWrite) break;
+ const ser = series[s];
+ const colorCol =
+ !hasSplits && this._colorName
+ ? columns.get(this._colorName)
+ : null;
+
+ for (let j = 0; j < sourceLength; j++) {
+ if (writeIdx >= maxWrite) break;
+ const i = rowIndices ? rowIndices[j] : j;
+ const x = ser.xCol[i] as number;
+ const y = ser.yCol[i] as number;
+
+ // Skip null values (common in split columns)
+ if (
+ (ser.xValid && !ser.xValid[i]) ||
+ (ser.yValid && !ser.yValid[i])
+ )
+ continue;
+
+ // Domain tracking
+ if (x < this._xMin) this._xMin = x;
+ if (x > this._xMax) this._xMax = x;
+ if (y < this._yMin) this._yMin = y;
+ if (y > this._yMax) this._yMax = y;
+
+ // CPU data for hit-testing
+ this._xData![destStart + writeIdx] = x;
+ this._yData![destStart + writeIdx] = y;
+
+ // Split tooltip x/y under base column names
+ if (splitXArr) {
+ splitXArr[destStart + writeIdx] = x;
+ splitYArr![destStart + writeIdx] = y;
+ }
+
+ // Position staging
+ positions[writeIdx * 2] = x;
+ positions[writeIdx * 2 + 1] = y;
+
+ // Color staging
+ if (hasSplits) {
+ // Split prefix as string color
+ if (!this._uniqueColorLabels.has(ser.colorLabel)) {
+ this._uniqueColorLabels.set(
+ ser.colorLabel,
+ this._uniqueColorLabels.size,
+ );
+ }
+ const idx = this._uniqueColorLabels.get(ser.colorLabel)!;
+ colorValues[writeIdx] = idx;
+ this._colorData![destStart + writeIdx] = idx;
+ if (idx < this._colorMin) this._colorMin = idx;
+ if (idx > this._colorMax) this._colorMax = idx;
+ } else if (
+ colorCol &&
+ !this._colorIsString &&
+ colorCol.values
+ ) {
+ const v = colorCol.values[i] as number;
+ colorValues[writeIdx] = v;
+ this._colorData![destStart + writeIdx] = v;
+ if (v < this._colorMin) this._colorMin = v;
+ if (v > this._colorMax) this._colorMax = v;
+ } else if (colorCol && this._colorIsString && colorCol.labels) {
+ const label = colorCol.labels[i];
+ if (!this._uniqueColorLabels.has(label)) {
+ this._uniqueColorLabels.set(
+ label,
+ this._uniqueColorLabels.size,
+ );
+ }
+ const idx = this._uniqueColorLabels.get(label)!;
+ colorValues[writeIdx] = idx;
+ this._colorData![destStart + writeIdx] = idx;
+ if (idx < this._colorMin) this._colorMin = idx;
+ if (idx > this._colorMax) this._colorMax = idx;
+ } else {
+ colorValues[writeIdx] = 0.5;
+ }
+
+ // Size staging
+ if (ser.sizeCol) {
+ const v = ser.sizeCol[i] as number;
+ sizeValues[writeIdx] = v;
+ if (v < this._sizeMin) this._sizeMin = v;
+ if (v > this._sizeMax) this._sizeMax = v;
+ } else if (!hasSplits && this._sizeName) {
+ const sc = columns.get(this._sizeName);
+ if (sc?.values) {
+ const v = sc.values[i] as number;
+ sizeValues[writeIdx] = v;
+ if (v < this._sizeMin) this._sizeMin = v;
+ if (v > this._sizeMax) this._sizeMax = v;
+ } else {
+ sizeValues[writeIdx] = 0;
+ }
+ } else {
+ sizeValues[writeIdx] = 0;
+ }
+
+ // Store split label for tooltip
+ if (splitLabelArr) {
+ splitLabelArr[destStart + writeIdx] = ser.colorLabel;
+ }
+
+ writeIdx++;
+ }
+ }
+
+ // Store tooltip data (skip per-column storage for splits —
+ // the split label + x/y values are sufficient and per-column
+ // arrays would allocate one Float32Array(totalCapacity) per
+ // composite column, which is prohibitive at high cardinality).
+ if (!hasSplits) {
+ for (const [name, col] of columns) {
+ if (name.startsWith("__")) continue;
+ if (col.type === "string") {
+ if (!this._stringRowData.has(name)) {
+ this._stringRowData.set(name, new Array(totalCapacity));
+ }
+ const arr = this._stringRowData.get(name)!;
+ for (let j = 0; j < sourceLength; j++) {
+ const i = rowIndices ? rowIndices[j] : j;
+ arr[destStart + j] = col.labels![i];
+ }
+ } else if (col.values) {
+ if (!this._numericRowData.has(name)) {
+ this._numericRowData.set(
+ name,
+ new Float32Array(totalCapacity),
+ );
+ }
+ const arr = this._numericRowData.get(name)!;
+ const vals = col.values;
+ for (let j = 0; j < sourceLength; j++) {
+ const i = rowIndices ? rowIndices[j] : j;
+ arr[destStart + j] = vals[i] as number;
+ }
+ }
+ }
+ }
+
+ this._writeCursor += writeIdx;
+ this._dataCount = this._writeCursor;
+ this._spatialGridDirty = true;
+
+ // Upload position data
+ const byteOffset = destStart * 2 * Float32Array.BYTES_PER_ELEMENT;
+ glManager.bufferPool.upload(
+ "a_position",
+ positions.subarray(0, writeIdx * 2),
+ byteOffset,
+ 2,
+ );
+
+ // Upload color data
+ const colorByteOffset = destStart * Float32Array.BYTES_PER_ELEMENT;
+ glManager.bufferPool.upload(
+ "a_color_value",
+ colorValues.subarray(0, writeIdx),
+ colorByteOffset,
+ );
+
+ // Upload size data
+ const sizeByteOffset = destStart * Float32Array.BYTES_PER_ELEMENT;
+ glManager.bufferPool.upload(
+ "a_size_value",
+ sizeValues.subarray(0, writeIdx),
+ sizeByteOffset,
+ );
+
+ glManager.uploadedCount = this._writeCursor;
+
+ // Update zoom controller base domain
+ if (this._zoomController) {
+ this._zoomController.setBaseDomain(
+ this._xMin,
+ this._xMax,
+ this._yMin,
+ this._yMax,
+ );
+ }
+
+ // Batch rendering: coalesce multiple chunk uploads into a single frame
+ this._scheduleRender(glManager);
+ }
+
+ private _scheduleRender(glManager: WebGLContextManager): void {
+ if (this._renderScheduled) return;
+ this._renderScheduled = true;
+ this._renderRAFId = requestAnimationFrame(() => {
+ this._renderScheduled = false;
+ this._renderRAFId = 0;
+ this._fullRender(glManager);
+ });
+ }
+
+ redraw(glManager: WebGLContextManager): void {
+ if (!this._program || glManager.uploadedCount === 0) return;
+ this._glManager = glManager;
+ this._fullRender(glManager);
+ }
+
+ /**
+ * Full render cycle: gridlines → points → axes overlay → legend overlay.
+ */
+ private _fullRender(glManager: WebGLContextManager): void {
+ const gl = glManager.gl;
+ const dpr = window.devicePixelRatio || 1;
+ const cssWidth = glManager.gl.canvas.width / dpr;
+ const cssHeight = glManager.gl.canvas.height / dpr;
+
+ if (cssWidth <= 0 || cssHeight <= 0) return;
+
+ // Get visible domain (accounts for zoom)
+ const hasColorCol =
+ (this._colorName !== "" || this._splitGroups.length > 0) &&
+ this._colorMin < this._colorMax;
+ let domain: { xMin: number; xMax: number; yMin: number; yMax: number };
+ if (this._zoomController) {
+ domain = this._zoomController.getVisibleDomain();
+ } else {
+ domain = {
+ xMin: this._xMin,
+ xMax: this._xMax,
+ yMin: this._yMin,
+ yMax: this._yMax,
+ };
+ }
+
+ // Build layout and cache for tooltip hit-testing
+ const layout = new PlotLayout(cssWidth, cssHeight, {
+ hasXLabel: !!this._xName,
+ hasYLabel: !!this._yName,
+ hasLegend: hasColorCol,
+ });
+ this._lastLayout = layout;
+
+ // Update zoom controller layout for mouse coordinate mapping
+ if (this._zoomController) {
+ this._zoomController.updateLayout(layout);
+ }
+
+ // Build projection matrix
+ const projection = layout.buildProjectionMatrix(
+ domain.xMin,
+ domain.xMax,
+ domain.yMin,
+ domain.yMax,
+ );
+
+ // Resolve gradient colors from theme
+ const themeEl = this._gridlineCanvas!;
+ const colorStart = parseCSSColorToVec3(
+ getCSSVar(themeEl, "--psp-webgl--gradient-start--color", "#0366d6"),
+ );
+ const colorEnd = hasColorCol
+ ? parseCSSColorToVec3(
+ getCSSVar(
+ themeEl,
+ "--psp-webgl--gradient-end--color",
+ "#ff7f0e",
+ ),
+ )
+ : colorStart;
+
+ const xType = this._columnTypes[this._xLabel] || "";
+ const yType = this._columnTypes[this._yLabel] || "";
+ const xIsDate = xType === "date" || xType === "datetime";
+ const yIsDate = yType === "date" || yType === "datetime";
+
+ const xDomain: AxisDomain = {
+ min: domain.xMin,
+ max: domain.xMax,
+ label: this._xLabel || this._xName,
+ isDate: xIsDate,
+ };
+ const yDomain: AxisDomain = {
+ min: domain.yMin,
+ max: domain.yMax,
+ label: this._yLabel || this._yName,
+ isDate: yIsDate,
+ };
+
+ const { xTicks, yTicks } = computeTicks(xDomain, yDomain, layout);
+
+ // Layer 1 (bottom canvas): gridlines behind points
+ if (this._gridlineCanvas) {
+ renderGridlines(
+ this._gridlineCanvas,
+ layout,
+ xTicks,
+ yTicks,
+ this._gridlineCanvas,
+ );
+ }
+
+ // Layer 2 (WebGL): scatter points, scissored to plot area
+ gl.clearColor(0, 0, 0, 0);
+ gl.clear(gl.COLOR_BUFFER_BIT);
+ gl.enable(gl.BLEND);
+ gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
+ gl.enable(gl.SCISSOR_TEST);
+ gl.scissor(
+ Math.round(layout.margins.left * dpr),
+ Math.round(layout.margins.bottom * dpr),
+ Math.round(layout.plotRect.width * dpr),
+ Math.round(layout.plotRect.height * dpr),
+ );
+ gl.useProgram(this._program!);
+ this._setUniforms(gl, projection, colorStart, colorEnd);
+ this._drawPoints(gl, glManager);
+ gl.disable(gl.SCISSOR_TEST);
+
+ // Cache chrome state for lightweight hover redraws
+ this._lastXDomain = xDomain;
+ this._lastYDomain = yDomain;
+ this._lastXTicks = xTicks;
+ this._lastYTicks = yTicks;
+ this._lastColorStart = colorStart;
+ this._lastColorEnd = colorEnd;
+ this._lastHasColorCol = hasColorCol;
+
+ // Layer 3 (top canvas): axes chrome, legend, tooltip
+ this._renderChromeOverlay();
+ }
+
+ /**
+ * Redraw only the chrome canvas (axes, legend, tooltip).
+ * Used for hover updates without re-rendering WebGL points or gridlines.
+ */
+ private _renderChromeOverlay(): void {
+ if (
+ !this._chromeCanvas ||
+ !this._lastLayout ||
+ !this._lastXDomain ||
+ !this._lastYDomain
+ )
+ return;
+
+ const layout = this._lastLayout;
+
+ renderAxesChrome(
+ this._chromeCanvas,
+ this._lastXDomain,
+ this._lastYDomain,
+ layout,
+ this._lastXTicks!,
+ this._lastYTicks!,
+ );
+
+ if (this._lastHasColorCol) {
+ if (this._colorIsString && this._uniqueColorLabels.size > 0) {
+ renderCategoricalLegend(
+ this._chromeCanvas,
+ layout,
+ this._uniqueColorLabels,
+ this._lastColorStart,
+ this._lastColorEnd,
+ );
+ } else {
+ renderLegend(
+ this._chromeCanvas,
+ layout,
+ {
+ min: this._colorMin,
+ max: this._colorMax,
+ label: this._colorName,
+ },
+ this._lastColorStart,
+ this._lastColorEnd,
+ );
+ }
+ }
+
+ if (this._hoveredIndex >= 0 && this._xData) {
+ this._renderTooltip(this._chromeCanvas, layout);
+ }
+ }
+
+ private _renderTooltip(
+ canvas: HTMLCanvasElement,
+ layout: PlotLayout,
+ ): void {
+ const idx = this._hoveredIndex;
+ if (idx < 0 || !this._xData || !this._yData) return;
+
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return;
+
+ const dpr = window.devicePixelRatio || 1;
+ ctx.save();
+ ctx.scale(dpr, dpr);
+
+ const xVal = this._xData[idx];
+ const yVal = this._yData[idx];
+
+ // Convert to pixel coords (uses padded domain from projection)
+ const pos = layout.dataToPixel(xVal, yVal);
+
+ const lines = this._buildTooltipLines(idx);
+
+ // Resolve theme colors
+ const fontFamily = getCSSVar(
+ canvas,
+ "--psp-webgl--font-family",
+ "monospace",
+ );
+ const tickColor = getCSSVar(
+ canvas,
+ "--psp-webgl--axis-ticks--color",
+ "rgba(128,128,128,0.8)",
+ );
+ const tooltipBg = getCSSVar(
+ canvas,
+ "--psp-webgl--tooltip--background",
+ "rgba(155,155,155,0.8)",
+ );
+ const tooltipBorder = getCSSVar(
+ canvas,
+ "--psp-webgl--tooltip--border-color",
+ "#fff",
+ );
+ const tooltipText = getCSSVar(
+ canvas,
+ "--psp-webgl--tooltip--color",
+ "#161616",
+ );
+
+ // Measure text
+ ctx.font = `11px ${fontFamily}`;
+ const lineHeight = 16;
+ const padding = 8;
+ let maxWidth = 0;
+ for (const line of lines) {
+ const w = ctx.measureText(line).width;
+ if (w > maxWidth) maxWidth = w;
+ }
+ const boxW = maxWidth + padding * 2;
+ const boxH = lines.length * lineHeight + padding * 2 - 4;
+
+ // Position tooltip (offset from point, keep in viewport)
+ let tx = pos.px + 12;
+ let ty = pos.py - boxH - 8;
+ if (tx + boxW > layout.cssWidth) tx = pos.px - boxW - 12;
+ if (ty < 0) ty = pos.py + 12;
+ if (ty + boxH > layout.cssHeight) ty = layout.cssHeight - boxH - 4;
+
+ // Draw crosshair
+ ctx.strokeStyle = tickColor;
+ ctx.globalAlpha = 0.3;
+ ctx.lineWidth = 1;
+ ctx.setLineDash([4, 4]);
+ ctx.beginPath();
+ ctx.moveTo(pos.px, layout.plotRect.y);
+ ctx.lineTo(pos.px, layout.plotRect.y + layout.plotRect.height);
+ ctx.moveTo(layout.plotRect.x, pos.py);
+ ctx.lineTo(layout.plotRect.x + layout.plotRect.width, pos.py);
+ ctx.stroke();
+ ctx.setLineDash([]);
+ ctx.globalAlpha = 1.0;
+
+ // Draw highlight ring
+ ctx.strokeStyle = tickColor;
+ ctx.globalAlpha = 0.8;
+ ctx.lineWidth = 2;
+ ctx.beginPath();
+ ctx.arc(pos.px, pos.py, 6, 0, Math.PI * 2);
+ ctx.stroke();
+ ctx.globalAlpha = 1.0;
+
+ // Draw tooltip background
+ ctx.fillStyle = tooltipBg;
+ ctx.strokeStyle = tooltipBorder;
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ ctx.roundRect(tx, ty, boxW, boxH, 4);
+ ctx.fill();
+ ctx.stroke();
+
+ // Draw tooltip text
+ ctx.fillStyle = tooltipText;
+ ctx.textAlign = "left";
+ ctx.textBaseline = "top";
+ for (let i = 0; i < lines.length; i++) {
+ ctx.fillText(lines[i], tx + padding, ty + padding + i * lineHeight);
+ }
+
+ ctx.restore();
+ }
+
+ private _buildTooltipLines(idx: number): string[] {
+ const lines: string[] = [];
+ const rowPath = this._stringRowData.get("__ROW_PATH__");
+ if (rowPath && rowPath[idx] != null) {
+ lines.push(String(rowPath[idx]));
+ }
+ for (const colName of this._tooltipColumns) {
+ const strData = this._stringRowData.get(colName);
+ if (strData && strData[idx] != null) {
+ lines.push(`${colName}: ${strData[idx]}`);
+ continue;
+ }
+ const numData = this._numericRowData.get(colName);
+ if (numData) {
+ const colType = this._columnTypes[colName] || "";
+ const isDate = colType === "date" || colType === "datetime";
+ const formatted = isDate
+ ? formatDateTickValue(numData[idx])
+ : formatTickValue(numData[idx]);
+ lines.push(`${colName}: ${formatted}`);
+ }
+ }
+ return lines;
+ }
+
+ private _showPinnedTooltip(pointIdx: number): void {
+ this._dismissPinnedTooltip();
+ this._pinnedIndex = pointIdx;
+ const idx = pointIdx;
+ if (idx < 0 || !this._xData || !this._yData || !this._lastLayout)
+ return;
+
+ const layout = this._lastLayout;
+ const pos = layout.dataToPixel(this._xData[idx], this._yData[idx]);
+ const lines = this._buildTooltipLines(idx);
+ if (lines.length === 0) return;
+
+ // Resolve theme
+ const themeEl = this._gridlineCanvas || this._chromeCanvas;
+ const tooltipBg = themeEl
+ ? getCSSVar(
+ themeEl,
+ "--psp-webgl--tooltip--background",
+ "rgba(155,155,155,0.8)",
+ )
+ : "rgba(155,155,155,0.8)";
+ const tooltipText = themeEl
+ ? getCSSVar(themeEl, "--psp-webgl--tooltip--color", "#161616")
+ : "#161616";
+ const tooltipBorder = themeEl
+ ? getCSSVar(themeEl, "--psp-webgl--tooltip--border-color", "#fff")
+ : "#fff";
+ const fontFamily = themeEl
+ ? getCSSVar(themeEl, "--psp-webgl--font-family", "monospace")
+ : "monospace";
+
+ const div = document.createElement("div");
+ div.style.cssText = [
+ "position:absolute",
+ "pointer-events:auto",
+ `font:11px ${fontFamily}`,
+ `background:${tooltipBg}`,
+ `color:${tooltipText}`,
+ `border:1px solid ${tooltipBorder}`,
+ "border-radius:4px",
+ "padding:8px",
+ "overflow-y:auto",
+ `max-height:${Math.round(layout.cssHeight * 0.6)}px`,
+ "white-space:pre",
+ "z-index:10",
+ "line-height:16px",
+ ].join(";");
+
+ div.textContent = lines.join("\n");
+
+ // Position relative to the canvas parent
+ const parent = this._glCanvas?.parentElement;
+ if (!parent) return;
+
+ parent.style.position = "relative";
+ // Place offscreen first to measure without flicker
+ div.style.left = "-9999px";
+ div.style.top = "0px";
+ parent.appendChild(div);
+ this._pinnedTooltip = div;
+
+ // Force reflow to get accurate dimensions
+ const divW = div.getBoundingClientRect().width;
+ const divH = div.getBoundingClientRect().height;
+ let tx = pos.px + 12;
+ let ty = pos.py - divH - 8;
+ if (tx + divW > layout.cssWidth) tx = pos.px - divW - 12;
+ if (tx < 0) tx = 4;
+ if (ty < 0) ty = pos.py + 12;
+ if (ty + divH > layout.cssHeight) ty = layout.cssHeight - divH - 4;
+
+ div.style.left = `${tx}px`;
+ div.style.top = `${ty}px`;
+
+ // Clear the canvas tooltip so both don't show
+ this._hoveredIndex = -1;
+ this._renderChromeOverlay();
+ }
+
+ private _dismissPinnedTooltip(): void {
+ if (this._pinnedTooltip) {
+ this._pinnedTooltip.remove();
+ this._pinnedTooltip = null;
+ }
+ this._pinnedIndex = -1;
+ }
+
+ private _setUniforms(
+ gl: WebGL2RenderingContext | WebGLRenderingContext,
+ projection: Float32Array,
+ colorStart: [number, number, number],
+ colorEnd: [number, number, number],
+ ): void {
+ const loc = this._locations!;
+ const dpr = window.devicePixelRatio || 1;
+
+ gl.uniformMatrix4fv(loc.u_projection, false, projection);
+ gl.uniform1f(loc.u_point_size, 8.0 * dpr);
+
+ if (this._colorMin < this._colorMax) {
+ gl.uniform2f(loc.u_color_range, this._colorMin, this._colorMax);
+ } else {
+ gl.uniform2f(loc.u_color_range, 0.0, 1.0);
+ }
+
+ gl.uniform4f(
+ loc.u_color_start,
+ colorStart[0],
+ colorStart[1],
+ colorStart[2],
+ 1.0,
+ );
+
+ gl.uniform4f(
+ loc.u_color_end,
+ colorEnd[0],
+ colorEnd[1],
+ colorEnd[2],
+ 1.0,
+ );
+
+ if (this._sizeMin < this._sizeMax) {
+ gl.uniform2f(loc.u_size_range, this._sizeMin, this._sizeMax);
+ } else {
+ gl.uniform2f(loc.u_size_range, 0.0, 0.0);
+ }
+
+ gl.uniform2f(loc.u_point_size_range, 2.0 * dpr, 16.0 * dpr);
+ }
+
+ private _drawPoints(
+ gl: WebGL2RenderingContext | WebGLRenderingContext,
+ glManager: WebGLContextManager,
+ ): void {
+ const loc = this._locations!;
+ const gl2 = glManager.isWebGL2 ? (gl as WebGL2RenderingContext) : null;
+
+ // Use VAO on WebGL2 to avoid rebinding attributes every frame
+ if (gl2 && this._vaoSetup && this._vao) {
+ gl2.bindVertexArray(this._vao);
+ gl.drawArrays(gl.POINTS, 0, glManager.uploadedCount);
+ gl2.bindVertexArray(null);
+ return;
+ }
+
+ if (gl2 && !this._vao) {
+ this._vao = gl2.createVertexArray();
+ }
+
+ if (gl2 && this._vao) {
+ gl2.bindVertexArray(this._vao);
+ }
+
+ const positionBuf = glManager.bufferPool.getOrCreate(
+ "a_position",
+ 2,
+ Float32Array.BYTES_PER_ELEMENT,
+ );
+ gl.bindBuffer(gl.ARRAY_BUFFER, positionBuf.buffer);
+ gl.enableVertexAttribArray(loc.a_position);
+ gl.vertexAttribPointer(loc.a_position, 2, gl.FLOAT, false, 0, 0);
+
+ const colorBuf = glManager.bufferPool.getOrCreate(
+ "a_color_value",
+ 1,
+ Float32Array.BYTES_PER_ELEMENT,
+ );
+ gl.bindBuffer(gl.ARRAY_BUFFER, colorBuf.buffer);
+ gl.enableVertexAttribArray(loc.a_color_value);
+ gl.vertexAttribPointer(loc.a_color_value, 1, gl.FLOAT, false, 0, 0);
+
+ const sizeBuf = glManager.bufferPool.getOrCreate(
+ "a_size_value",
+ 1,
+ Float32Array.BYTES_PER_ELEMENT,
+ );
+ gl.bindBuffer(gl.ARRAY_BUFFER, sizeBuf.buffer);
+ gl.enableVertexAttribArray(loc.a_size_value);
+ gl.vertexAttribPointer(loc.a_size_value, 1, gl.FLOAT, false, 0, 0);
+
+ if (gl2 && this._vao) {
+ gl2.bindVertexArray(null);
+ this._vaoSetup = true;
+ // Redraw using the VAO
+ gl2.bindVertexArray(this._vao);
+ }
+
+ gl.drawArrays(gl.POINTS, 0, glManager.uploadedCount);
+
+ if (gl2 && this._vao) {
+ gl2.bindVertexArray(null);
+ }
+ }
+
+ destroy(): void {
+ this._detachTooltip();
+ this._dismissPinnedTooltip();
+ if (this._renderRAFId) {
+ cancelAnimationFrame(this._renderRAFId);
+ this._renderRAFId = 0;
+ this._renderScheduled = false;
+ }
+ if (this._hoverRAFId) {
+ cancelAnimationFrame(this._hoverRAFId);
+ this._hoverRAFId = 0;
+ }
+ this._program = null;
+ this._locations = null;
+ this._vao = null;
+ this._vaoSetup = false;
+ this._allColumns = [];
+ this._xData = null;
+ this._yData = null;
+ this._colorData = null;
+ this._numericRowData.clear();
+ this._stringRowData.clear();
+ this._uniqueColorLabels.clear();
+ this._spatialGrid = null;
+ this._stagingPositions = null;
+ this._stagingColors = null;
+ this._stagingSizes = null;
+ }
+}
diff --git a/packages/viewer-webgl/src/ts/charts/treemap.ts b/packages/viewer-webgl/src/ts/charts/treemap.ts
new file mode 100644
index 0000000000..d9cf96023e
--- /dev/null
+++ b/packages/viewer-webgl/src/ts/charts/treemap.ts
@@ -0,0 +1,1994 @@
+// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
+// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
+// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
+// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
+// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
+// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
+// ┃ Copyright (c) 2017, the Perspective Authors. ┃
+// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
+// ┃ This file is part of the Perspective library, distributed under the terms ┃
+// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
+// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
+
+import type { ColumnDataMap, ColumnData } from "../data/arrow-reader";
+import type { WebGLContextManager } from "../webgl/context-manager";
+import type { ChartImplementation } from "./chart";
+import { parseCSSColorToVec3, getCSSVar } from "../utils/css";
+import { renderLegend, renderCategoricalLegend } from "../layout/legend";
+import { PlotLayout } from "../layout/plot-layout";
+import { formatTickValue } from "../layout/ticks";
+import treemapVert from "../shaders/treemap.vert.glsl";
+import treemapFrag from "../shaders/treemap.frag.glsl";
+
+function luminance(r: number, g: number, b: number): number {
+ return 0.299 * r + 0.587 * g + 0.114 * b;
+}
+
+function lerpColor(
+ a: [number, number, number],
+ b: [number, number, number],
+ t: number,
+): [number, number, number] {
+ return [
+ a[0] + (b[0] - a[0]) * t,
+ a[1] + (b[1] - a[1]) * t,
+ a[2] + (b[2] - a[2]) * t,
+ ];
+}
+
+// ---------------------------------------------------------------------------
+// Tree data structure
+// ---------------------------------------------------------------------------
+
+interface TreeNode {
+ name: string;
+ children: TreeNode[];
+ size: number;
+ value: number; // aggregated (sum of descendant sizes)
+ colorValue: number;
+ colorLabel: string;
+ depth: number;
+ x0: number;
+ y0: number;
+ x1: number;
+ y1: number;
+ parent: TreeNode | null;
+ rowPath: string[];
+ tooltipData: Map;
+}
+
+function createNode(name: string, parent: TreeNode | null): TreeNode {
+ return {
+ name,
+ children: [],
+ size: 0,
+ value: 0,
+ colorValue: NaN,
+ colorLabel: "",
+ depth: parent ? parent.depth + 1 : 0,
+ x0: 0,
+ y0: 0,
+ x1: 0,
+ y1: 0,
+ parent,
+ rowPath: [],
+ tooltipData: new Map(),
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Squarified treemap layout (Bruls-Huizing-van Wijk)
+// ---------------------------------------------------------------------------
+
+const PADDING_OUTER = 1;
+const PADDING_LABEL = 14; // top padding for header tab label
+const PADDING_INNER = 1;
+
+function sumValues(node: TreeNode): number {
+ if (node.children.length === 0) {
+ node.value = Math.max(0, node.size);
+ return node.value;
+ }
+ let total = 0;
+ for (const child of node.children) {
+ total += sumValues(child);
+ }
+ node.value = total;
+ return total;
+}
+
+/**
+ * Order-preserving treemap layout using recursive binary splits.
+ * Splits the node list at the value midpoint, gives each half a
+ * proportional portion of the rectangle, and alternates split
+ * direction. Produces decent aspect ratios while preserving the
+ * view's data order.
+ */
+function squarify(
+ node: TreeNode,
+ x0: number,
+ y0: number,
+ x1: number,
+ y1: number,
+ baseDepth: number,
+): void {
+ node.x0 = Math.round(x0);
+ node.y0 = Math.round(y0);
+ node.x1 = Math.round(x1);
+ node.y1 = Math.round(y1);
+
+ if (node.children.length === 0) return;
+
+ // Depth relative to the current view root
+ const relDepth = node.depth - baseDepth;
+
+ // Only direct children of the view root (relDepth 1) get header tabs.
+ const showHeader = relDepth === 1 && node.children.length > 0;
+ const padTop = showHeader ? PADDING_LABEL : PADDING_INNER;
+ const padOuter = showHeader ? PADDING_OUTER : PADDING_INNER;
+
+ const ix0 = node.x0 + padOuter;
+ const iy0 = node.y0 + padTop;
+ const ix1 = node.x1 - padOuter;
+ const iy1 = node.y1 - padOuter;
+ if (ix1 <= ix0 || iy1 <= iy0) return;
+
+ const active = node.children.filter((c) => c.value > 0);
+ if (active.length === 0) return;
+
+ layoutOrdered(active, ix0, iy0, ix1, iy1, baseDepth);
+}
+
+function layoutOrdered(
+ nodes: TreeNode[],
+ x0: number,
+ y0: number,
+ x1: number,
+ y1: number,
+ baseDepth: number,
+): void {
+ if (nodes.length === 0) return;
+
+ if (nodes.length === 1) {
+ squarify(nodes[0], x0, y0, x1, y1, baseDepth);
+ return;
+ }
+
+ // Find the split point closest to half the total value
+ let totalValue = 0;
+ for (const n of nodes) totalValue += n.value;
+ const halfValue = totalValue / 2;
+
+ let cumulative = 0;
+ let splitIdx = 1; // at least 1 item on each side
+ let bestDiff = Infinity;
+ for (let i = 0; i < nodes.length - 1; i++) {
+ cumulative += nodes[i].value;
+ const diff = Math.abs(cumulative - halfValue);
+ if (diff < bestDiff) {
+ bestDiff = diff;
+ splitIdx = i + 1;
+ }
+ }
+
+ const leftNodes = nodes.slice(0, splitIdx);
+ const rightNodes = nodes.slice(splitIdx);
+ let leftValue = 0;
+ for (const n of leftNodes) leftValue += n.value;
+ const fraction = leftValue / totalValue;
+
+ // Split along the longer side, snapped to pixels (no gap here —
+ // gaps are applied per-leaf in vertex generation to avoid accumulation)
+ const rw = x1 - x0;
+ const rh = y1 - y0;
+
+ if (rw >= rh) {
+ const splitX = Math.round(x0 + rw * fraction);
+ layoutOrdered(leftNodes, x0, y0, splitX, y1, baseDepth);
+ layoutOrdered(rightNodes, splitX, y0, x1, y1, baseDepth);
+ } else {
+ const splitY = Math.round(y0 + rh * fraction);
+ layoutOrdered(leftNodes, x0, y0, x1, splitY, baseDepth);
+ layoutOrdered(rightNodes, x0, splitY, x1, y1, baseDepth);
+ }
+}
+
+// Collect all visible nodes (for rendering)
+function collectVisible(
+ node: TreeNode,
+ maxDepth: number,
+ baseDepth: number,
+ out: TreeNode[],
+): void {
+ if (node.value <= 0) return;
+ if (node.depth >= baseDepth) {
+ out.push(node);
+ }
+ if (node.depth - baseDepth < maxDepth) {
+ for (const child of node.children) {
+ collectVisible(child, maxDepth, baseDepth, out);
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Shader locations
+// ---------------------------------------------------------------------------
+
+interface TreemapLocations {
+ u_resolution: WebGLUniformLocation | null;
+ a_position: number;
+ a_color: number;
+}
+
+// ---------------------------------------------------------------------------
+// Breadcrumb hit region
+// ---------------------------------------------------------------------------
+
+interface BreadcrumbRegion {
+ node: TreeNode;
+ x0: number;
+ y0: number;
+ x1: number;
+ y1: number;
+}
+
+// ---------------------------------------------------------------------------
+// TreemapChart
+// ---------------------------------------------------------------------------
+
+export class TreemapChart implements ChartImplementation {
+ private _program: WebGLProgram | null = null;
+ private _locations: TreemapLocations | null = null;
+ private _positionBuffer: WebGLBuffer | null = null;
+ private _colorBuffer: WebGLBuffer | null = null;
+ private _vertexCount = 0;
+
+ private _gridlineCanvas: HTMLCanvasElement | null = null;
+ private _chromeCanvas: HTMLCanvasElement | null = null;
+ private _glCanvas: HTMLCanvasElement | null = null;
+ private _glManager: WebGLContextManager | null = null;
+
+ // Config
+ private _columnSlots: (string | null)[] = [];
+ private _groupBy: string[] = [];
+ private _splitBy: string[] = [];
+ private _columnTypes: Record = {};
+
+ // Buffered data (treemap needs all data before layout)
+ private _bufferedRows: {
+ rowPath: string[];
+ sizeValue: number;
+ colorValue: number;
+ colorLabel: string;
+ tooltipData: Map;
+ }[] = [];
+ private _sizeName = "";
+ private _colorName = "";
+ private _colorIsString = false;
+ private _allColumns: string[] = [];
+
+ // Tree state
+ private _root: TreeNode | null = null;
+ private _currentRoot: TreeNode | null = null;
+ private _breadcrumbs: TreeNode[] = [];
+
+ // Color state
+ private _colorMin = Infinity;
+ private _colorMax = -Infinity;
+ private _uniqueColorLabels: Map = new Map();
+
+ // Interaction
+ private _hoveredNode: TreeNode | null = null;
+ private _visibleNodes: TreeNode[] = [];
+ private _breadcrumbRegions: BreadcrumbRegion[] = [];
+ private _mouseMoveHandler: ((e: MouseEvent) => void) | null = null;
+ private _clickHandler: ((e: MouseEvent) => void) | null = null;
+ private _dblClickHandler: ((e: MouseEvent) => void) | null = null;
+ private _mouseLeaveHandler: (() => void) | null = null;
+
+ // Render batching
+ private _renderScheduled = false;
+ private _renderRAFId = 0;
+
+ // Cached static chrome (labels, breadcrumbs, legend) to avoid
+ // redrawing thousands of labels on every hover frame.
+ private _chromeCache: ImageBitmap | null = null;
+ private _chromeCacheDirty = true;
+
+ // Pinned tooltip
+ private _pinnedNode: TreeNode | null = null;
+ private _pinnedTooltip: HTMLDivElement | null = null;
+ private _hoverRAFId = 0;
+
+ // -----------------------------------------------------------------------
+ // ChartImplementation interface
+ // -----------------------------------------------------------------------
+
+ setGridlineCanvas(canvas: HTMLCanvasElement): void {
+ this._gridlineCanvas = canvas;
+ }
+
+ setChromeCanvas(canvas: HTMLCanvasElement): void {
+ this._chromeCanvas = canvas;
+ }
+
+ attachTooltip(glCanvas: HTMLCanvasElement): void {
+ this._glCanvas = glCanvas;
+
+ this._mouseMoveHandler = (e: MouseEvent) => {
+ if (this._hoverRAFId) return;
+ const rect = glCanvas.getBoundingClientRect();
+ const mx = e.clientX - rect.left;
+ const my = e.clientY - rect.top;
+ this._hoverRAFId = requestAnimationFrame(() => {
+ this._hoverRAFId = 0;
+ this._handleHover(mx, my);
+ });
+ };
+
+ this._mouseLeaveHandler = () => {
+ if (this._hoveredNode && !this._pinnedNode) {
+ this._hoveredNode = null;
+ this._renderChromeOverlay();
+ }
+ };
+
+ this._clickHandler = (e: MouseEvent) => {
+ const rect = glCanvas.getBoundingClientRect();
+ const mx = e.clientX - rect.left;
+ const my = e.clientY - rect.top;
+ this._handleClick(mx, my);
+ };
+
+ this._dblClickHandler = (e: MouseEvent) => {
+ const rect = glCanvas.getBoundingClientRect();
+ const mx = e.clientX - rect.left;
+ const my = e.clientY - rect.top;
+ this._handleDblClick(mx, my);
+ };
+
+ glCanvas.addEventListener("mousemove", this._mouseMoveHandler);
+ glCanvas.addEventListener("mouseleave", this._mouseLeaveHandler);
+ glCanvas.addEventListener("click", this._clickHandler);
+ glCanvas.addEventListener("dblclick", this._dblClickHandler);
+ }
+
+ private _detachTooltip(): void {
+ if (this._glCanvas) {
+ if (this._mouseMoveHandler) {
+ this._glCanvas.removeEventListener(
+ "mousemove",
+ this._mouseMoveHandler,
+ );
+ }
+ if (this._mouseLeaveHandler) {
+ this._glCanvas.removeEventListener(
+ "mouseleave",
+ this._mouseLeaveHandler,
+ );
+ }
+ if (this._clickHandler) {
+ this._glCanvas.removeEventListener("click", this._clickHandler);
+ }
+ if (this._dblClickHandler) {
+ this._glCanvas.removeEventListener(
+ "dblclick",
+ this._dblClickHandler,
+ );
+ }
+ }
+ this._mouseMoveHandler = null;
+ this._mouseLeaveHandler = null;
+ this._clickHandler = null;
+ this._dblClickHandler = null;
+ }
+
+ setColumnSlots(slots: (string | null)[]): void {
+ this._columnSlots = slots;
+ }
+
+ setViewPivots(groupBy: string[], splitBy: string[]): void {
+ this._groupBy = groupBy;
+ this._splitBy = splitBy;
+ }
+
+ setColumnTypes(schema: Record): void {
+ this._columnTypes = schema;
+ }
+
+ uploadAndRender(
+ glManager: WebGLContextManager,
+ columns: ColumnDataMap,
+ startRow: number,
+ _endRow: number,
+ ): void {
+ this._glManager = glManager;
+
+ // Reset on first chunk
+ if (startRow === 0) {
+ // Cancel any pending RAF from the previous stream.
+ if (this._renderRAFId) {
+ cancelAnimationFrame(this._renderRAFId);
+ this._renderRAFId = 0;
+ this._renderScheduled = false;
+ }
+
+ this._bufferedRows = [];
+ this._colorMin = Infinity;
+ this._colorMax = -Infinity;
+ this._uniqueColorLabels = new Map();
+ this._root = null;
+ this._visibleNodes = [];
+
+ this._allColumns = Array.from(columns.keys()).filter(
+ (k) => !k.startsWith("__"),
+ );
+
+ const slots = this._columnSlots;
+ this._sizeName = slots[0] || this._allColumns[0] || "";
+ this._colorName = slots[1] || this._sizeName;
+ this._colorIsString = false;
+ if (this._colorName) {
+ const col = columns.get(this._colorName);
+ this._colorIsString = col?.type === "string";
+ }
+ }
+
+ // Buffer this chunk's rows
+ this._bufferChunkRows(columns);
+
+ // Rebuild tree and render
+ this._rebuildAndRender(glManager);
+ }
+
+ redraw(glManager: WebGLContextManager): void {
+ this._glManager = glManager;
+ if (this._root) {
+ this._layoutAndRender(glManager);
+ }
+ }
+
+ destroy(): void {
+ this._detachTooltip();
+ this._dismissPinnedTooltip();
+ if (this._renderRAFId) {
+ cancelAnimationFrame(this._renderRAFId);
+ this._renderRAFId = 0;
+ this._renderScheduled = false;
+ }
+ if (this._hoverRAFId) {
+ cancelAnimationFrame(this._hoverRAFId);
+ this._hoverRAFId = 0;
+ }
+ this._chromeCache?.close();
+ this._chromeCache = null;
+ const gl = this._glManager?.gl;
+ if (gl) {
+ if (this._positionBuffer) gl.deleteBuffer(this._positionBuffer);
+ if (this._colorBuffer) gl.deleteBuffer(this._colorBuffer);
+ }
+ this._positionBuffer = null;
+ this._colorBuffer = null;
+ this._program = null;
+ this._locations = null;
+ this._root = null;
+ this._currentRoot = null;
+ this._bufferedRows = [];
+ this._visibleNodes = [];
+ this._breadcrumbRegions = [];
+ }
+
+ // -----------------------------------------------------------------------
+ // Data buffering
+ // -----------------------------------------------------------------------
+
+ private _bufferChunkRows(columns: ColumnDataMap): void {
+ // Detect row path format: either __ROW_PATH__ (list column) or
+ // individual "Column (Group by N)" string columns
+ const rowPathCol = columns.get("__ROW_PATH__");
+
+ // Find group-by columns: "Name (Group by N)" pattern
+ const groupByCols: { name: string; col: ColumnData; index: number }[] =
+ [];
+ const groupByPattern = /^(.+) \(Group by (\d+)\)$/;
+ for (const [key, col] of columns) {
+ const m = key.match(groupByPattern);
+ if (m && col.type === "string" && col.labels) {
+ groupByCols.push({ name: m[1], col, index: parseInt(m[2]) });
+ }
+ }
+ groupByCols.sort((a, b) => a.index - b.index);
+
+ const hasRowPath =
+ rowPathCol?.type === "list-string" && rowPathCol.listValues;
+ const hasGroupByCols = groupByCols.length > 0;
+
+ if (!hasRowPath && !hasGroupByCols) {
+ this._bufferFlatRows(columns);
+ return;
+ }
+
+ const sizeCol = this._sizeName ? columns.get(this._sizeName) : null;
+ const colorCol = this._colorName ? columns.get(this._colorName) : null;
+
+ // Determine row count from whichever path source we have
+ const numRows = hasRowPath
+ ? rowPathCol!.listValues!.length
+ : (sizeCol?.values?.length ??
+ groupByCols[0]?.col.labels?.length ??
+ 0);
+
+ for (let i = 0; i < numRows; i++) {
+ // Build the path for this row
+ let path: string[];
+ if (hasRowPath) {
+ path = rowPathCol!.listValues![i];
+ if (path.length === 0) continue; // skip total row
+ } else {
+ // Build path from group-by columns; empty labels mean
+ // this is an aggregation row at a higher level
+ path = [];
+ for (const gbc of groupByCols) {
+ const label = gbc.col.labels![i];
+ if (!label && label !== "0") break; // stop at first empty level
+ path.push(label);
+ }
+ if (path.length === 0) continue; // skip total row
+ }
+
+ const sizeValue = sizeCol?.values
+ ? (sizeCol.values[i] as number)
+ : 1;
+
+ let colorValue = NaN;
+ let colorLabel = "";
+ if (colorCol) {
+ if (this._colorIsString && colorCol.labels) {
+ colorLabel = colorCol.labels[i];
+ } else if (colorCol.values) {
+ colorValue = colorCol.values[i] as number;
+ }
+ }
+
+ // Collect tooltip data from all non-groupby columns
+ const tooltipData = new Map();
+ for (const [name, col] of columns) {
+ if (name.startsWith("__")) continue;
+ if (groupByPattern.test(name)) continue;
+ if (col.type === "string" && col.labels) {
+ tooltipData.set(name, col.labels[i]);
+ } else if (col.values) {
+ tooltipData.set(name, col.values[i] as number);
+ }
+ }
+
+ this._bufferedRows.push({
+ rowPath: path,
+ sizeValue,
+ colorValue,
+ colorLabel,
+ tooltipData,
+ });
+ }
+ }
+
+ private _bufferFlatRows(columns: ColumnDataMap): void {
+ // When no group_by, create rows from column data as flat entries
+ const sizeCol = this._sizeName ? columns.get(this._sizeName) : null;
+ if (!sizeCol?.values) return;
+
+ const colorCol = this._colorName ? columns.get(this._colorName) : null;
+ const numRows = sizeCol.values.length;
+
+ // Use a label column if available (first string column that isn't the size/color)
+ let labelCol: ColumnData | undefined;
+ let labelName = "";
+ for (const [name, col] of columns) {
+ if (name.startsWith("__")) continue;
+ if (name === this._sizeName || name === this._colorName) continue;
+ if (col.type === "string" && col.labels) {
+ labelCol = col;
+ labelName = name;
+ break;
+ }
+ }
+
+ for (let i = 0; i < numRows; i++) {
+ const label = labelCol?.labels
+ ? labelCol.labels[i]
+ : `Row ${this._bufferedRows.length + i}`;
+
+ const tooltipData = new Map();
+ for (const [name, col] of columns) {
+ if (name.startsWith("__")) continue;
+ if (col.type === "string" && col.labels) {
+ tooltipData.set(name, col.labels[i]);
+ } else if (col.values) {
+ tooltipData.set(name, col.values[i] as number);
+ }
+ }
+
+ let colorValue = NaN;
+ let colorLabel = "";
+ if (colorCol) {
+ if (this._colorIsString && colorCol.labels) {
+ colorLabel = colorCol.labels[i];
+ } else if (colorCol.values) {
+ colorValue = colorCol.values[i] as number;
+ }
+ }
+
+ this._bufferedRows.push({
+ rowPath: [label],
+ sizeValue: Math.max(0, sizeCol.values[i] as number),
+ colorValue,
+ colorLabel,
+ tooltipData,
+ });
+ }
+ }
+
+ // -----------------------------------------------------------------------
+ // Tree building
+ // -----------------------------------------------------------------------
+
+ private _buildTree(): void {
+ const root = createNode("Total", null);
+ root.depth = 0;
+
+ const groupByLen = this._groupBy.length || 1;
+
+ for (const row of this._bufferedRows) {
+ let current = root;
+ for (let d = 0; d < row.rowPath.length; d++) {
+ const segment = row.rowPath[d];
+ let child = current.children.find((c) => c.name === segment);
+ if (!child) {
+ child = createNode(segment, current);
+ current.children.push(child);
+ }
+
+ if (d === row.rowPath.length - 1) {
+ // This is the deepest level for this row
+ child.rowPath = row.rowPath.slice();
+ child.tooltipData = row.tooltipData;
+
+ if (row.rowPath.length === groupByLen) {
+ // Leaf row
+ child.size = Math.max(0, row.sizeValue);
+ }
+
+ // Color
+ if (!isNaN(row.colorValue)) {
+ child.colorValue = row.colorValue;
+ }
+ if (row.colorLabel) {
+ child.colorLabel = row.colorLabel;
+ }
+ }
+
+ current = child;
+ }
+ }
+
+ // Compute aggregated values
+ sumValues(root);
+
+ // Track color domain from leaf nodes using percentiles to
+ // avoid outliers washing out the color range
+ this._colorMin = Infinity;
+ this._colorMax = -Infinity;
+ this._uniqueColorLabels = new Map();
+ const colorValues: number[] = [];
+ this._walkNodes(root, (n) => {
+ if (n.children.length === 0 || !isNaN(n.colorValue)) {
+ if (!isNaN(n.colorValue)) {
+ colorValues.push(n.colorValue);
+ }
+ if (
+ n.colorLabel &&
+ !this._uniqueColorLabels.has(n.colorLabel)
+ ) {
+ this._uniqueColorLabels.set(
+ n.colorLabel,
+ this._uniqueColorLabels.size,
+ );
+ }
+ }
+ });
+ if (colorValues.length > 0) {
+ colorValues.sort((a, b) => a - b);
+ const p05 = colorValues[Math.floor(colorValues.length * 0.05)];
+ const p95 = colorValues[Math.ceil(colorValues.length * 0.95) - 1];
+ this._colorMin = p05;
+ this._colorMax = p95;
+ if (this._colorMin >= this._colorMax) {
+ this._colorMin = colorValues[0];
+ this._colorMax = colorValues[colorValues.length - 1];
+ }
+ }
+
+ this._root = root;
+
+ // Preserve drill-down if the path still exists
+ if (this._currentRoot && this._breadcrumbs.length > 1) {
+ const path = this._breadcrumbs.map((b) => b.name);
+ let node = root;
+ let valid = true;
+ for (let i = 1; i < path.length; i++) {
+ const child = node.children.find((c) => c.name === path[i]);
+ if (!child) {
+ valid = false;
+ break;
+ }
+ node = child;
+ }
+ if (valid && node.children.length > 0) {
+ this._currentRoot = node;
+ this._rebuildBreadcrumbs(node);
+ return;
+ }
+ }
+
+ this._currentRoot = root;
+ this._breadcrumbs = [root];
+ }
+
+ private _rebuildBreadcrumbs(node: TreeNode): void {
+ const crumbs: TreeNode[] = [];
+ let n: TreeNode | null = node;
+ while (n) {
+ crumbs.unshift(n);
+ n = n.parent;
+ }
+ this._breadcrumbs = crumbs;
+ }
+
+ private _walkNodes(node: TreeNode, fn: (n: TreeNode) => void): void {
+ fn(node);
+ for (const child of node.children) {
+ this._walkNodes(child, fn);
+ }
+ }
+
+ // -----------------------------------------------------------------------
+ // Layout and render
+ // -----------------------------------------------------------------------
+
+ private _rebuildAndRender(glManager: WebGLContextManager): void {
+ this._buildTree();
+ if (!this._root) return;
+ this._layoutAndRender(glManager);
+ }
+
+ private _layoutAndRender(glManager: WebGLContextManager): void {
+ if (!this._renderScheduled) {
+ this._renderScheduled = true;
+ this._renderRAFId = requestAnimationFrame(() => {
+ this._renderScheduled = false;
+ this._renderRAFId = 0;
+ this._fullRender(glManager);
+ });
+ }
+ }
+
+ private _fullRender(glManager: WebGLContextManager): void {
+ if (!this._currentRoot) return;
+
+ const gl = glManager.gl;
+ const dpr = window.devicePixelRatio || 1;
+ // Use the GL canvas's physical size (set by glManager.resize via getBoundingClientRect)
+ const cssWidth = (
+ gl.canvas as HTMLCanvasElement
+ ).getBoundingClientRect().width;
+ const cssHeight = (
+ gl.canvas as HTMLCanvasElement
+ ).getBoundingClientRect().height;
+ if (cssWidth <= 0 || cssHeight <= 0) return;
+
+ // Reserve space for breadcrumbs (top) and legend (right)
+ const breadcrumbH = this._breadcrumbs.length > 1 ? 28 : 0;
+ const hasColor =
+ this._colorName !== "" &&
+ (this._colorIsString
+ ? this._uniqueColorLabels.size > 0
+ : this._colorMin < this._colorMax);
+ const legendW = hasColor ? 90 : 0;
+
+ // Layout the current subtree (baseDepth = currentRoot.depth so
+ // the drilled-in view fills the canvas like the top level)
+ squarify(
+ this._currentRoot,
+ 0,
+ breadcrumbH,
+ cssWidth - legendW,
+ cssHeight,
+ this._currentRoot.depth,
+ );
+
+ // Collect visible nodes (show all depths from currentRoot)
+ this._visibleNodes = [];
+ collectVisible(
+ this._currentRoot,
+ 100, // show all depths
+ this._currentRoot.depth,
+ this._visibleNodes,
+ );
+
+ // Ensure shader program
+ if (!this._program) {
+ this._program = glManager.shaders.getOrCreate(
+ "treemap",
+ treemapVert,
+ treemapFrag,
+ );
+ this._locations = {
+ u_resolution: gl.getUniformLocation(
+ this._program,
+ "u_resolution",
+ ),
+ a_position: gl.getAttribLocation(this._program, "a_position"),
+ a_color: gl.getAttribLocation(this._program, "a_color"),
+ };
+ }
+
+ // Resolve theme colors
+ const themeEl = this._gridlineCanvas || this._chromeCanvas!;
+ const colorStart = parseCSSColorToVec3(
+ getCSSVar(themeEl, "--psp-webgl--gradient-start--color", "#0366d6"),
+ );
+ const colorEnd = parseCSSColorToVec3(
+ getCSSVar(themeEl, "--psp-webgl--gradient-end--color", "#ff7f0e"),
+ );
+
+ // Clear gridline canvas (treemap doesn't use gridlines)
+ if (this._gridlineCanvas) {
+ const gCtx = this._gridlineCanvas.getContext("2d");
+ if (gCtx) {
+ gCtx.clearRect(
+ 0,
+ 0,
+ this._gridlineCanvas.width,
+ this._gridlineCanvas.height,
+ );
+ }
+ }
+
+ // Mark chrome cache dirty so labels/legend are redrawn
+ this._chromeCacheDirty = true;
+
+ // Generate and upload vertices
+ this._generateAndUpload(gl, colorStart, colorEnd);
+
+ // Draw WebGL - use theme-aware background for gap color
+ const bgColor = parseCSSColorToVec3(
+ getCSSVar(
+ themeEl,
+ "--psp-webgl--gridline--color",
+ "rgba(128,128,128,0.8)",
+ ),
+ );
+ gl.clearColor(
+ bgColor[0] * 0.3,
+ bgColor[1] * 0.3,
+ bgColor[2] * 0.3,
+ 1.0,
+ );
+ gl.clear(gl.COLOR_BUFFER_BIT);
+ gl.enable(gl.BLEND);
+ gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
+ gl.useProgram(this._program);
+ gl.uniform2f(this._locations!.u_resolution, cssWidth, cssHeight);
+
+ // Bind position buffer
+ gl.bindBuffer(gl.ARRAY_BUFFER, this._positionBuffer);
+ gl.enableVertexAttribArray(this._locations!.a_position);
+ gl.vertexAttribPointer(
+ this._locations!.a_position,
+ 2,
+ gl.FLOAT,
+ false,
+ 0,
+ 0,
+ );
+
+ // Bind color buffer
+ gl.bindBuffer(gl.ARRAY_BUFFER, this._colorBuffer);
+ gl.enableVertexAttribArray(this._locations!.a_color);
+ gl.vertexAttribPointer(
+ this._locations!.a_color,
+ 3,
+ gl.FLOAT,
+ false,
+ 0,
+ 0,
+ );
+
+ gl.drawArrays(gl.TRIANGLES, 0, this._vertexCount);
+
+ // Chrome overlay (labels, breadcrumbs, tooltips)
+ this._renderChromeOverlay();
+ }
+
+ // -----------------------------------------------------------------------
+ // Vertex generation
+ // -----------------------------------------------------------------------
+
+ private _generateAndUpload(
+ gl: WebGL2RenderingContext | WebGLRenderingContext,
+ colorStart: [number, number, number],
+ colorEnd: [number, number, number],
+ ): void {
+ // Only render leaf-level and one-above nodes with fills
+ const nodes = this._visibleNodes;
+ const baseDepth = this._currentRoot?.depth ?? 0;
+
+ // Count rectangles: leaf nodes get solid fill, relDepth-1 branches get border
+ let rectCount = 0;
+ for (const n of nodes) {
+ if (n === this._currentRoot) continue;
+ const w = n.x1 - n.x0;
+ const h = n.y1 - n.y0;
+ if (w < 1 || h < 1) continue;
+ if (n.children.length === 0) {
+ rectCount++;
+ } else if (n.depth - baseDepth === 1) {
+ rectCount += 2;
+ }
+ }
+
+ const positions = new Float32Array(rectCount * 6 * 2); // 6 verts * 2 components
+ const colors = new Float32Array(rectCount * 6 * 3); // 6 verts * 3 components
+ let vi = 0; // vertex index
+
+ const hasColor =
+ this._colorName !== "" &&
+ (this._colorIsString
+ ? this._uniqueColorLabels.size > 0
+ : this._colorMin < this._colorMax);
+
+ for (const n of nodes) {
+ if (n === this._currentRoot) continue;
+ // Coordinates are already pixel-snapped by the layout
+ const sx0 = n.x0;
+ const sy0 = n.y0;
+ const sx1 = n.x1;
+ const sy1 = n.y1;
+ const w = sx1 - sx0;
+ const h = sy1 - sy0;
+ if (w < 1 || h < 1) continue;
+
+ if (n.children.length === 0) {
+ // Leaf node: solid fill with 1px inset for visible gaps
+ let color: [number, number, number];
+ if (hasColor && this._colorIsString && n.colorLabel) {
+ const idx = this._uniqueColorLabels.get(n.colorLabel) ?? 0;
+ const maxIdx = Math.max(
+ 1,
+ this._uniqueColorLabels.size - 1,
+ );
+ color = lerpColor(colorStart, colorEnd, idx / maxIdx);
+ } else if (
+ hasColor &&
+ !isNaN(n.colorValue) &&
+ this._colorMax > this._colorMin
+ ) {
+ const t =
+ (n.colorValue - this._colorMin) /
+ (this._colorMax - this._colorMin);
+ color = lerpColor(colorStart, colorEnd, t);
+ } else {
+ color = colorStart;
+ }
+
+ vi = this._emitRect(
+ positions,
+ colors,
+ vi,
+ sx0,
+ sy0,
+ sx1 - 1,
+ sy1 - 1,
+ color,
+ );
+ } else {
+ // Only draw borders for direct children of current root
+ const relDepth = n.depth - baseDepth;
+ if (relDepth === 1) {
+ // Only right + bottom edges (like leaf gaps) to avoid doubling
+ const borderColor: [number, number, number] = [
+ 0.25, 0.25, 0.25,
+ ];
+ vi = this._emitRect(
+ positions,
+ colors,
+ vi,
+ sx0,
+ sy1 - 1,
+ sx1,
+ sy1,
+ borderColor,
+ );
+ vi = this._emitRect(
+ positions,
+ colors,
+ vi,
+ sx1 - 1,
+ sy0,
+ sx1,
+ sy1,
+ borderColor,
+ );
+ }
+ }
+ }
+
+ this._vertexCount = vi;
+
+ // Upload
+ if (!this._positionBuffer) {
+ this._positionBuffer = gl.createBuffer();
+ }
+ gl.bindBuffer(gl.ARRAY_BUFFER, this._positionBuffer);
+ gl.bufferData(
+ gl.ARRAY_BUFFER,
+ positions.subarray(0, vi * 2),
+ gl.DYNAMIC_DRAW,
+ );
+
+ if (!this._colorBuffer) {
+ this._colorBuffer = gl.createBuffer();
+ }
+ gl.bindBuffer(gl.ARRAY_BUFFER, this._colorBuffer);
+ gl.bufferData(
+ gl.ARRAY_BUFFER,
+ colors.subarray(0, vi * 3),
+ gl.DYNAMIC_DRAW,
+ );
+ }
+
+ private _emitRect(
+ positions: Float32Array,
+ colors: Float32Array,
+ vi: number,
+ x0: number,
+ y0: number,
+ x1: number,
+ y1: number,
+ color: [number, number, number],
+ ): number {
+ // Triangle 1: top-left, top-right, bottom-left
+ const pi = vi * 2;
+ const ci = vi * 3;
+
+ positions[pi + 0] = x0;
+ positions[pi + 1] = y0;
+ positions[pi + 2] = x1;
+ positions[pi + 3] = y0;
+ positions[pi + 4] = x0;
+ positions[pi + 5] = y1;
+
+ // Triangle 2: top-right, bottom-right, bottom-left
+ positions[pi + 6] = x1;
+ positions[pi + 7] = y0;
+ positions[pi + 8] = x1;
+ positions[pi + 9] = y1;
+ positions[pi + 10] = x0;
+ positions[pi + 11] = y1;
+
+ for (let v = 0; v < 6; v++) {
+ colors[ci + v * 3 + 0] = color[0];
+ colors[ci + v * 3 + 1] = color[1];
+ colors[ci + v * 3 + 2] = color[2];
+ }
+
+ return vi + 6;
+ }
+
+ // -----------------------------------------------------------------------
+ // Chrome overlay (labels, breadcrumbs, tooltip)
+ // -----------------------------------------------------------------------
+
+ /**
+ * Render the chrome overlay. On layout changes, draws static content
+ * (labels, breadcrumbs, legend) directly, then snapshots it into a
+ * cached bitmap for fast hover frames. On hover-only updates,
+ * composites the cached bitmap + tooltip without redrawing labels.
+ */
+ private _renderChromeOverlay(): void {
+ if (!this._chromeCanvas || !this._currentRoot) return;
+
+ const canvas = this._chromeCanvas;
+ const dpr = window.devicePixelRatio || 1;
+
+ const domRect = canvas.getBoundingClientRect();
+ const cssWidth = domRect.width;
+ const cssHeight = domRect.height;
+ const targetW = Math.round(cssWidth * dpr);
+ const targetH = Math.round(cssHeight * dpr);
+ if (canvas.width !== targetW || canvas.height !== targetH) {
+ canvas.width = targetW;
+ canvas.height = targetH;
+ this._chromeCacheDirty = true;
+ }
+
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return;
+
+ if (this._chromeCacheDirty) {
+ // Full redraw: render static content directly to the canvas,
+ // then snapshot it async for future hover frames.
+ this._chromeCache?.close();
+ this._chromeCache = null;
+ this._chromeCacheDirty = false;
+ this._drawStaticChrome(ctx, dpr, cssWidth, cssHeight);
+
+ // Snapshot for fast hover compositing (async, non-blocking)
+ createImageBitmap(canvas).then((bmp) => {
+ // Only use if we haven't been invalidated again
+ if (!this._chromeCacheDirty) {
+ this._chromeCache = bmp;
+ } else {
+ bmp.close();
+ }
+ });
+ } else if (this._chromeCache) {
+ // Fast path: blit cached bitmap, draw only tooltip on top
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ ctx.drawImage(this._chromeCache, 0, 0);
+ }
+ // else: cache building, canvas already has static content from the
+ // synchronous draw above — just draw tooltip on top.
+
+ // Hover/pin: highlight first, then re-render headers + label on top
+ const highlightNode = this._pinnedNode || this._hoveredNode;
+ if (highlightNode) {
+ ctx.save();
+ ctx.scale(dpr, dpr);
+ const fontFamily = getCSSVar(
+ canvas,
+ "--psp-webgl--font-family",
+ "monospace",
+ );
+ const textColor = getCSSVar(
+ canvas,
+ "--psp-webgl--label--color",
+ "rgba(180, 180, 180, 0.9)",
+ );
+
+ // Draw highlight border first (underneath labels)
+ this._renderHoverHighlight(ctx, highlightNode);
+
+ // Re-render header tabs and nested branch labels over the highlight
+ const baseDepth = this._currentRoot!.depth;
+ for (const n of this._visibleNodes) {
+ if (n === this._currentRoot || n.children.length === 0)
+ continue;
+ const nw = n.x1 - n.x0;
+ const nh = n.y1 - n.y0;
+ const relDepth = n.depth - baseDepth;
+ if (relDepth === 1) {
+ this._renderBranchLabel(
+ ctx,
+ n,
+ nw,
+ nh,
+ fontFamily,
+ textColor,
+ false,
+ );
+ } else if (relDepth === 2) {
+ this._renderBranchLabel(
+ ctx,
+ n,
+ nw,
+ nh,
+ fontFamily,
+ textColor,
+ true,
+ );
+ }
+ }
+
+ // Re-render highlighted leaf label at full opacity
+ if (highlightNode.children.length === 0) {
+ const themeEl = this._gridlineCanvas || canvas;
+ const colorStart = parseCSSColorToVec3(
+ getCSSVar(
+ themeEl,
+ "--psp-webgl--gradient-start--color",
+ "#0366d6",
+ ),
+ );
+ const colorEnd = parseCSSColorToVec3(
+ getCSSVar(
+ themeEl,
+ "--psp-webgl--gradient-end--color",
+ "#ff7f0e",
+ ),
+ );
+ const hasColor =
+ this._colorName !== "" &&
+ (this._colorIsString
+ ? this._uniqueColorLabels.size > 0
+ : this._colorMin < this._colorMax);
+ const hw = highlightNode.x1 - highlightNode.x0;
+ const hh = highlightNode.y1 - highlightNode.y0;
+ this._renderLabel(
+ ctx,
+ highlightNode,
+ hw,
+ hh,
+ fontFamily,
+ colorStart,
+ colorEnd,
+ hasColor,
+ true,
+ );
+ }
+
+ // Only show canvas tooltip on hover, not when pinned (pinned uses DOM tooltip)
+ if (!this._pinnedNode && this._hoveredNode) {
+ this._renderTooltip(
+ ctx,
+ this._hoveredNode,
+ cssWidth,
+ cssHeight,
+ fontFamily,
+ );
+ }
+ ctx.restore();
+ }
+ }
+
+ /** Draw labels, breadcrumbs, legend directly to the canvas context. */
+ private _drawStaticChrome(
+ ctx: CanvasRenderingContext2D,
+ dpr: number,
+ cssWidth: number,
+ cssHeight: number,
+ ): void {
+ const canvas = this._chromeCanvas!;
+
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ ctx.save();
+ ctx.scale(dpr, dpr);
+
+ const fontFamily = getCSSVar(
+ canvas,
+ "--psp-webgl--font-family",
+ "monospace",
+ );
+ const textColor = getCSSVar(
+ canvas,
+ "--psp-webgl--label--color",
+ "rgba(180, 180, 180, 0.9)",
+ );
+
+ const themeEl = this._gridlineCanvas || canvas;
+ const colorStart = parseCSSColorToVec3(
+ getCSSVar(themeEl, "--psp-webgl--gradient-start--color", "#0366d6"),
+ );
+ const colorEnd = parseCSSColorToVec3(
+ getCSSVar(themeEl, "--psp-webgl--gradient-end--color", "#ff7f0e"),
+ );
+ const hasColor =
+ this._colorName !== "" &&
+ (this._colorIsString
+ ? this._uniqueColorLabels.size > 0
+ : this._colorMin < this._colorMax);
+
+ // Labels: draw leaves first, then branches on top
+ const baseDepth = this._currentRoot!.depth;
+ for (const n of this._visibleNodes) {
+ if (n === this._currentRoot || n.children.length > 0) continue;
+ const w = n.x1 - n.x0;
+ const h = n.y1 - n.y0;
+ this._renderLabel(
+ ctx,
+ n,
+ w,
+ h,
+ fontFamily,
+ colorStart,
+ colorEnd,
+ hasColor,
+ );
+ }
+ for (const n of this._visibleNodes) {
+ if (n === this._currentRoot || n.children.length === 0) continue;
+ const w = n.x1 - n.x0;
+ const h = n.y1 - n.y0;
+ const relDepth = n.depth - baseDepth;
+ if (relDepth === 1) {
+ this._renderBranchLabel(
+ ctx,
+ n,
+ w,
+ h,
+ fontFamily,
+ textColor,
+ false,
+ );
+ } else if (relDepth === 2) {
+ this._renderBranchLabel(
+ ctx,
+ n,
+ w,
+ h,
+ fontFamily,
+ textColor,
+ true,
+ );
+ }
+ }
+
+ // Breadcrumbs
+ if (this._breadcrumbs.length > 1) {
+ this._renderBreadcrumbs(ctx, cssWidth, fontFamily, textColor);
+ }
+
+ // Legend
+ if (hasColor) {
+ const legendLayout = new PlotLayout(cssWidth, cssHeight, {
+ hasXLabel: false,
+ hasYLabel: false,
+ hasLegend: true,
+ });
+ if (this._colorIsString && this._uniqueColorLabels.size > 0) {
+ renderCategoricalLegend(
+ canvas,
+ legendLayout,
+ this._uniqueColorLabels,
+ colorStart,
+ colorEnd,
+ );
+ } else if (this._colorMin < this._colorMax) {
+ renderLegend(
+ canvas,
+ legendLayout,
+ {
+ min: this._colorMin,
+ max: this._colorMax,
+ label: this._colorName,
+ },
+ colorStart,
+ colorEnd,
+ );
+ }
+ }
+
+ ctx.restore();
+ }
+
+ private _renderLabel(
+ ctx: CanvasRenderingContext2D,
+ node: TreeNode,
+ w: number,
+ h: number,
+ fontFamily: string,
+ colorStart: [number, number, number],
+ colorEnd: [number, number, number],
+ hasColor: boolean,
+ hovered = false,
+ ): void {
+ const MAX_FONT = 11;
+ const PAD = 4;
+ const LINE_HEIGHT = 1.3;
+
+ if (w < 30 || h < 14) return;
+
+ // Determine fill color for contrast
+ let fillColor: [number, number, number] = colorStart;
+ if (hasColor && this._colorIsString && node.colorLabel) {
+ const idx = this._uniqueColorLabels.get(node.colorLabel) ?? 0;
+ const maxIdx = Math.max(1, this._uniqueColorLabels.size - 1);
+ fillColor = lerpColor(colorStart, colorEnd, idx / maxIdx);
+ } else if (
+ hasColor &&
+ !isNaN(node.colorValue) &&
+ this._colorMax > this._colorMin
+ ) {
+ const t =
+ (node.colorValue - this._colorMin) /
+ (this._colorMax - this._colorMin);
+ fillColor = lerpColor(colorStart, colorEnd, t);
+ }
+
+ const lum = luminance(fillColor[0], fillColor[1], fillColor[2]);
+ const labelColor = hovered
+ ? lum > 0.5
+ ? "rgba(0,0,0,0.85)"
+ : "rgba(255,255,255,0.9)"
+ : lum > 0.5
+ ? "rgba(0,0,0,0.5)"
+ : "rgba(255,255,255,0.55)";
+
+ const fontSize = Math.min(MAX_FONT, Math.floor(h / 2));
+ if (fontSize < 7) return;
+ ctx.font = `${fontSize}px ${fontFamily}`;
+
+ const maxW = w - PAD * 2;
+ const lineH = fontSize * LINE_HEIGHT;
+ const maxLines = Math.max(1, Math.floor((h - PAD * 2) / lineH));
+
+ // Word-wrap the text
+ const lines = this._wrapText(ctx, node.name, maxW, maxLines);
+ if (lines.length === 0) return;
+
+ // Center the block of lines vertically
+ const blockH = lines.length * lineH;
+ const startY = node.y0 + (h - blockH) / 2 + lineH / 2;
+
+ ctx.fillStyle = labelColor;
+ ctx.textAlign = "center";
+ ctx.textBaseline = "middle";
+ const cx = node.x0 + w / 2;
+ for (let i = 0; i < lines.length; i++) {
+ ctx.fillText(lines[i], cx, startY + i * lineH);
+ }
+ }
+
+ private _wrapText(
+ ctx: CanvasRenderingContext2D,
+ text: string,
+ maxW: number,
+ maxLines: number,
+ ): string[] {
+ if (maxLines <= 0 || maxW <= 0) return [];
+
+ // If it fits on one line, done
+ if (ctx.measureText(text).width <= maxW) {
+ return [text];
+ }
+
+ const lines: string[] = [];
+ let remaining = text;
+
+ while (remaining.length > 0 && lines.length < maxLines) {
+ const isLastLine = lines.length === maxLines - 1;
+
+ // Find how many chars fit on this line
+ let fitLen = remaining.length;
+ while (
+ fitLen > 0 &&
+ ctx.measureText(remaining.slice(0, fitLen)).width > maxW
+ ) {
+ fitLen--;
+ }
+ if (fitLen === 0) fitLen = 1; // at least 1 char
+
+ if (fitLen === remaining.length) {
+ // Rest fits on this line
+ lines.push(remaining);
+ break;
+ }
+
+ // Try to break at a word boundary
+ let breakAt = fitLen;
+ const spaceIdx = remaining.lastIndexOf(" ", fitLen);
+ if (spaceIdx > 0) {
+ breakAt = spaceIdx;
+ }
+
+ if (isLastLine) {
+ // Truncate with ellipsis
+ lines.push(this._truncateWithEllipsis(ctx, remaining, maxW));
+ break;
+ }
+
+ lines.push(remaining.slice(0, breakAt));
+ remaining = remaining.slice(breakAt).trimStart();
+ }
+
+ if (lines.length === 1 && lines[0].length <= 2) return [];
+ return lines;
+ }
+
+ private _truncateWithEllipsis(
+ ctx: CanvasRenderingContext2D,
+ text: string,
+ maxW: number,
+ ): string {
+ if (ctx.measureText(text).width <= maxW) return text;
+ while (text.length > 1) {
+ text = text.slice(0, -1);
+ if (ctx.measureText(text + "\u2026").width <= maxW) {
+ return text + "\u2026";
+ }
+ }
+ return text;
+ }
+
+ private _renderBranchLabel(
+ ctx: CanvasRenderingContext2D,
+ node: TreeNode,
+ w: number,
+ h: number,
+ fontFamily: string,
+ textColor: string,
+ nested: boolean,
+ ): void {
+ if (nested) {
+ // Need enough room for readable centered text
+ if (w < 60 || h < 30) return;
+
+ const fontSize = 12;
+ ctx.font = `bold ${fontSize}px ${fontFamily}`;
+
+ let text = node.name;
+ const maxW = w - 16;
+ let textW = ctx.measureText(text).width;
+ if (textW > maxW) {
+ while (text.length > 1) {
+ text = text.slice(0, -1);
+ if (ctx.measureText(text + "\u2026").width <= maxW) {
+ text += "\u2026";
+ break;
+ }
+ }
+ }
+ if (text.length <= 3) return;
+
+ // Clip to node rect so text never bleeds into neighbors
+ ctx.save();
+ ctx.beginPath();
+ ctx.rect(node.x0, node.y0, w, h);
+ ctx.clip();
+
+ const cx = node.x0 + w / 2;
+ const cy = node.y0 + h / 2;
+ ctx.textAlign = "center";
+ ctx.textBaseline = "middle";
+ ctx.lineWidth = 3;
+ ctx.strokeStyle = "rgba(0, 0, 0, 0.7)";
+ ctx.lineJoin = "round";
+ ctx.strokeText(text, cx, cy);
+ ctx.fillStyle = "rgba(255, 255, 255, 0.95)";
+ ctx.fillText(text, cx, cy);
+
+ ctx.restore();
+ } else {
+ // Header tab: top-left
+ if (w < 40 || h < 22) return;
+
+ const fontSize = 11;
+ ctx.font = `bold ${fontSize}px ${fontFamily}`;
+
+ let text = node.name;
+ const maxW = w - 10;
+ let textW = ctx.measureText(text).width;
+ if (textW > maxW) {
+ while (text.length > 1) {
+ text = text.slice(0, -1);
+ if (ctx.measureText(text + "\u2026").width <= maxW) {
+ text += "\u2026";
+ break;
+ }
+ }
+ }
+
+ ctx.fillStyle = textColor;
+ ctx.globalAlpha = 0.85;
+ ctx.textAlign = "left";
+ ctx.textBaseline = "top";
+ ctx.fillText(text, node.x0 + 5, node.y0 + 4);
+ ctx.globalAlpha = 1.0;
+ }
+ }
+
+ private _renderBreadcrumbs(
+ ctx: CanvasRenderingContext2D,
+ cssWidth: number,
+ fontFamily: string,
+ textColor: string,
+ ): void {
+ this._breadcrumbRegions = [];
+
+ const bgColor = getCSSVar(
+ this._chromeCanvas!,
+ "--psp-webgl--tooltip--background",
+ "rgba(155,155,155,0.8)",
+ );
+
+ // Background bar
+ ctx.fillStyle = bgColor;
+ ctx.fillRect(0, 0, cssWidth, 24);
+
+ ctx.font = `11px ${fontFamily}`;
+ ctx.textAlign = "left";
+ ctx.textBaseline = "middle";
+
+ let x = 8;
+ const y = 12;
+
+ for (let i = 0; i < this._breadcrumbs.length; i++) {
+ const crumb = this._breadcrumbs[i];
+ const isLast = i === this._breadcrumbs.length - 1;
+ const label = crumb.name;
+
+ ctx.fillStyle = isLast ? textColor : textColor;
+ ctx.font = isLast
+ ? `bold 11px ${fontFamily}`
+ : `11px ${fontFamily}`;
+
+ const textW = ctx.measureText(label).width;
+ ctx.fillText(label, x, y);
+
+ this._breadcrumbRegions.push({
+ node: crumb,
+ x0: x - 2,
+ y0: 0,
+ x1: x + textW + 2,
+ y1: 24,
+ });
+
+ x += textW;
+
+ if (!isLast) {
+ ctx.fillStyle = textColor;
+ ctx.font = `11px ${fontFamily}`;
+ const sep = " \u203A ";
+ ctx.fillText(sep, x, y);
+ x += ctx.measureText(sep).width;
+ }
+ }
+ }
+
+ private _renderHoverHighlight(
+ ctx: CanvasRenderingContext2D,
+ node: TreeNode,
+ ): void {
+ ctx.strokeStyle = "rgba(255,255,255,0.9)";
+ ctx.lineWidth = 2;
+ ctx.strokeRect(node.x0, node.y0, node.x1 - node.x0, node.y1 - node.y0);
+ }
+
+ private _renderTooltip(
+ ctx: CanvasRenderingContext2D,
+ node: TreeNode,
+ cssWidth: number,
+ cssHeight: number,
+ fontFamily: string,
+ ): void {
+ const tooltipBg = getCSSVar(
+ this._chromeCanvas!,
+ "--psp-webgl--tooltip--background",
+ "rgba(155,155,155,0.8)",
+ );
+ const tooltipText = getCSSVar(
+ this._chromeCanvas!,
+ "--psp-webgl--tooltip--color",
+ "#161616",
+ );
+ const tooltipBorder = getCSSVar(
+ this._chromeCanvas!,
+ "--psp-webgl--tooltip--border-color",
+ "#fff",
+ );
+
+ const lines = this._buildTooltipLines(node);
+ if (lines.length === 0) return;
+
+ ctx.font = `11px ${fontFamily}`;
+ const lineHeight = 16;
+ const padding = 8;
+ let maxWidth = 0;
+ for (const line of lines) {
+ const w = ctx.measureText(line).width;
+ if (w > maxWidth) maxWidth = w;
+ }
+ const boxW = maxWidth + padding * 2;
+ const boxH = lines.length * lineHeight + padding * 2 - 4;
+
+ // Position near the center of the hovered node
+ const cx = (node.x0 + node.x1) / 2;
+ const cy = (node.y0 + node.y1) / 2;
+ let tx = cx + 12;
+ let ty = cy - boxH - 8;
+ if (tx + boxW > cssWidth) tx = cx - boxW - 12;
+ if (tx < 0) tx = 4;
+ if (ty < 0) ty = cy + 12;
+ if (ty + boxH > cssHeight) ty = cssHeight - boxH - 4;
+
+ ctx.fillStyle = tooltipBg;
+ ctx.strokeStyle = tooltipBorder;
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ ctx.roundRect(tx, ty, boxW, boxH, 4);
+ ctx.fill();
+ ctx.stroke();
+
+ ctx.fillStyle = tooltipText;
+ ctx.textAlign = "left";
+ ctx.textBaseline = "top";
+ for (let i = 0; i < lines.length; i++) {
+ ctx.fillText(lines[i], tx + padding, ty + padding + i * lineHeight);
+ }
+ }
+
+ private _buildTooltipLines(node: TreeNode): string[] {
+ const lines: string[] = [];
+
+ // Path
+ if (node.rowPath.length > 0) {
+ lines.push(node.rowPath.join(" \u203A "));
+ } else {
+ lines.push(node.name);
+ }
+
+ // Value
+ lines.push(`Value: ${formatTickValue(node.value)}`);
+
+ // Size
+ if (this._sizeName && node.tooltipData.has(this._sizeName)) {
+ const val = node.tooltipData.get(this._sizeName)!;
+ lines.push(
+ `${this._sizeName}: ${typeof val === "number" ? formatTickValue(val) : val}`,
+ );
+ }
+
+ // Color
+ if (this._colorName && !isNaN(node.colorValue)) {
+ lines.push(
+ `${this._colorName}: ${formatTickValue(node.colorValue)}`,
+ );
+ } else if (this._colorName && node.colorLabel) {
+ lines.push(`${this._colorName}: ${node.colorLabel}`);
+ }
+
+ // Additional tooltip columns
+ for (const [name, val] of node.tooltipData) {
+ if (name === this._sizeName || name === this._colorName) continue;
+ const formatted =
+ typeof val === "number" ? formatTickValue(val) : val;
+ lines.push(`${name}: ${formatted}`);
+ }
+
+ if (node.children.length > 0) {
+ lines.push(`Children: ${node.children.length}`);
+ }
+
+ return lines;
+ }
+
+ // -----------------------------------------------------------------------
+ // Interaction
+ // -----------------------------------------------------------------------
+
+ /**
+ * Find the node at (mx, my). Returns the smallest leaf AND the
+ * smallest (deepest) branch separately, so callers can decide.
+ */
+ private _hitTest(
+ mx: number,
+ my: number,
+ ): { leaf: TreeNode | null; branch: TreeNode | null; inHeader: boolean } {
+ let bestLeaf: TreeNode | null = null;
+ let bestLeafArea = Infinity;
+ let bestBranch: TreeNode | null = null;
+ let bestBranchArea = Infinity;
+ let labelBranch: TreeNode | null = null;
+ const baseDepth = this._currentRoot?.depth ?? 0;
+
+ for (const n of this._visibleNodes) {
+ if (n === this._currentRoot) continue;
+ if (mx >= n.x0 && mx <= n.x1 && my >= n.y0 && my <= n.y1) {
+ const area = (n.x1 - n.x0) * (n.y1 - n.y0);
+ if (n.children.length > 0) {
+ if (area < bestBranchArea) {
+ bestBranchArea = area;
+ bestBranch = n;
+ }
+ // Check if mouse is in ANY branch's label zone
+ const relDepth = n.depth - baseDepth;
+ if (relDepth === 1 && my <= n.y0 + PADDING_LABEL) {
+ labelBranch = n;
+ }
+ if (relDepth === 2) {
+ const nw = n.x1 - n.x0;
+ const nh = n.y1 - n.y0;
+ if (nw >= 60 && nh >= 30) {
+ const cy = n.y0 + nh / 2;
+ const cx = n.x0 + nw / 2;
+ if (
+ Math.abs(my - cy) < 10 &&
+ Math.abs(mx - cx) < nw * 0.4
+ ) {
+ labelBranch = n;
+ }
+ }
+ }
+ } else {
+ if (area < bestLeafArea) {
+ bestLeafArea = area;
+ bestLeaf = n;
+ }
+ }
+ }
+ }
+
+ // If mouse is over a label zone, use that branch
+ if (labelBranch) {
+ return { leaf: null, branch: labelBranch, inHeader: true };
+ }
+
+ return { leaf: bestLeaf, branch: bestBranch, inHeader: false };
+ }
+
+ private _handleHover(mx: number, my: number): void {
+ if (this._pinnedNode) return;
+
+ // Check breadcrumbs first
+ for (const region of this._breadcrumbRegions) {
+ if (
+ mx >= region.x0 &&
+ mx <= region.x1 &&
+ my >= region.y0 &&
+ my <= region.y1
+ ) {
+ if (this._glCanvas) this._glCanvas.style.cursor = "pointer";
+ this._hoveredNode = null;
+ this._renderChromeOverlay();
+ return;
+ }
+ }
+
+ // In a header tab zone, show the branch as hovered (not the leaf under it)
+ const { leaf, branch, inHeader } = this._hitTest(mx, my);
+ const best = inHeader ? branch : leaf || branch;
+
+ if (best !== this._hoveredNode) {
+ this._hoveredNode = best;
+ if (this._glCanvas) {
+ this._glCanvas.style.cursor = branch ? "pointer" : "default";
+ }
+ this._renderChromeOverlay();
+ }
+ }
+
+ private _handleClick(mx: number, my: number): void {
+ // Dismiss pinned tooltip on any click
+ if (this._pinnedNode) {
+ this._dismissPinnedTooltip();
+ return;
+ }
+
+ // Check breadcrumbs
+ for (const region of this._breadcrumbRegions) {
+ if (
+ mx >= region.x0 &&
+ mx <= region.x1 &&
+ my >= region.y0 &&
+ my <= region.y1
+ ) {
+ if (region.node !== this._currentRoot) {
+ this._drillTo(region.node);
+ }
+ return;
+ }
+ }
+
+ // Only drill when clicking header/label zones; otherwise pin leaf tooltip
+ const { leaf, branch, inHeader } = this._hitTest(mx, my);
+
+ if (branch && inHeader) {
+ this._drillTo(branch);
+ } else if (leaf) {
+ this._showPinnedTooltip(leaf);
+ } else if (branch) {
+ this._drillTo(branch);
+ }
+ }
+
+ private _handleDblClick(mx: number, my: number): void {
+ this._dismissPinnedTooltip();
+ const { leaf, branch } = this._hitTest(mx, my);
+ const target =
+ branch ||
+ (leaf?.parent !== this._currentRoot ? leaf?.parent : null);
+ if (
+ target &&
+ target !== this._currentRoot &&
+ target.children.length > 0
+ ) {
+ this._drillTo(target);
+ // Re-find and pin the leaf in the new layout
+ if (leaf && leaf.children.length === 0) {
+ // After drill + re-layout, the leaf still exists in the tree
+ // but has new coordinates. Pin it.
+ this._showPinnedTooltip(leaf);
+ }
+ }
+ }
+
+ private _drillTo(node: TreeNode): void {
+ this._currentRoot = node;
+ this._rebuildBreadcrumbs(node);
+ this._hoveredNode = null;
+ if (this._glManager) {
+ this._fullRender(this._glManager);
+ }
+ }
+
+ private _showPinnedTooltip(node: TreeNode): void {
+ this._dismissPinnedTooltip();
+ this._pinnedNode = node;
+
+ const themeEl = this._gridlineCanvas || this._chromeCanvas;
+ const tooltipBg = themeEl
+ ? getCSSVar(
+ themeEl,
+ "--psp-webgl--tooltip--background",
+ "rgba(155,155,155,0.8)",
+ )
+ : "rgba(155,155,155,0.8)";
+ const tooltipText = themeEl
+ ? getCSSVar(themeEl, "--psp-webgl--tooltip--color", "#161616")
+ : "#161616";
+ const tooltipBorder = themeEl
+ ? getCSSVar(themeEl, "--psp-webgl--tooltip--border-color", "#fff")
+ : "#fff";
+ const fontFamily = themeEl
+ ? getCSSVar(themeEl, "--psp-webgl--font-family", "monospace")
+ : "monospace";
+
+ const lines = this._buildTooltipLines(node);
+ if (lines.length === 0) return;
+
+ const div = document.createElement("div");
+ div.style.cssText = [
+ "position:absolute",
+ "pointer-events:auto",
+ `font:11px ${fontFamily}`,
+ `background:${tooltipBg}`,
+ `color:${tooltipText}`,
+ `border:1px solid ${tooltipBorder}`,
+ "border-radius:4px",
+ "padding:8px",
+ "overflow-y:auto",
+ "white-space:pre",
+ "z-index:10",
+ "line-height:16px",
+ ].join(";");
+ div.textContent = lines.join("\n");
+
+ const parent = this._glCanvas?.parentElement;
+ if (!parent) return;
+ parent.style.position = "relative";
+ div.style.left = "-9999px";
+ div.style.top = "0px";
+ parent.appendChild(div);
+ this._pinnedTooltip = div;
+
+ const cx = (node.x0 + node.x1) / 2;
+ const cy = (node.y0 + node.y1) / 2;
+ const dpr = window.devicePixelRatio || 1;
+ const cssWidth = (this._glCanvas?.width || 100) / dpr;
+ const cssHeight = (this._glCanvas?.height || 100) / dpr;
+
+ const divW = div.getBoundingClientRect().width;
+ const divH = div.getBoundingClientRect().height;
+ let tx = cx + 12;
+ let ty = cy - divH - 8;
+ if (tx + divW > cssWidth) tx = cx - divW - 12;
+ if (tx < 0) tx = 4;
+ if (ty < 0) ty = cy + 12;
+ if (ty + divH > cssHeight) ty = cssHeight - divH - 4;
+
+ div.style.left = `${tx}px`;
+ div.style.top = `${ty}px`;
+
+ this._hoveredNode = null;
+ this._renderChromeOverlay();
+ }
+
+ private _dismissPinnedTooltip(): void {
+ if (this._pinnedTooltip) {
+ this._pinnedTooltip.remove();
+ this._pinnedTooltip = null;
+ }
+ this._pinnedNode = null;
+ }
+}
diff --git a/packages/viewer-webgl/src/ts/data/arrow-reader.ts b/packages/viewer-webgl/src/ts/data/arrow-reader.ts
new file mode 100644
index 0000000000..4256c336d6
--- /dev/null
+++ b/packages/viewer-webgl/src/ts/data/arrow-reader.ts
@@ -0,0 +1,125 @@
+// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
+// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
+// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
+// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
+// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
+// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
+// ┃ Copyright (c) 2017, the Perspective Authors. ┃
+// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
+// ┃ This file is part of the Perspective library, distributed under the terms ┃
+// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
+// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
+
+import { tableFromIPC, Float64, Int32, Utf8, Dictionary } from "apache-arrow";
+
+export interface ColumnData {
+ type: "float32" | "int32" | "string" | "list-string";
+ values?: Float32Array | Int32Array;
+ labels?: string[];
+ listValues?: string[][];
+ /** Per-row null bitmap: true = valid, false = null. */
+ valid?: Uint8Array;
+}
+
+export type ColumnDataMap = Map;
+
+function buildValidBitmap(
+ column: { nullCount: number; isValid(i: number): boolean },
+ numRows: number,
+): Uint8Array | undefined {
+ if (column.nullCount === 0) return undefined;
+ const valid = new Uint8Array(numRows);
+ for (let i = 0; i < numRows; i++) {
+ valid[i] = column.isValid(i) ? 1 : 0;
+ }
+ return valid;
+}
+
+export function arrowToTypedArrays(buffer: ArrayBuffer): ColumnDataMap {
+ const table = tableFromIPC(buffer);
+ const result: ColumnDataMap = new Map();
+
+ for (const field of table.schema.fields) {
+ const column = table.getChild(field.name);
+ if (!column) continue;
+
+ const numRows = column.length;
+
+ if (
+ field.type instanceof Float64 ||
+ field.type.typeId === 3 /* Float */
+ ) {
+ // Use toArray() to get the underlying typed array directly,
+ // then narrow Float64→Float32 for WebGL compatibility.
+ const raw = column.toArray();
+ const f32 =
+ raw instanceof Float32Array ? raw : new Float32Array(raw);
+ const valid = buildValidBitmap(column, numRows);
+ result.set(field.name, { type: "float32", values: f32, valid });
+ } else if (
+ field.type instanceof Int32 ||
+ field.type.typeId === 2 /* Int */
+ ) {
+ const raw = column.toArray();
+ let i32: Int32Array;
+ if (raw instanceof Int32Array) {
+ i32 = raw;
+ } else if (
+ raw instanceof BigInt64Array ||
+ raw instanceof BigUint64Array
+ ) {
+ i32 = new Int32Array(numRows);
+ for (let j = 0; j < numRows; j++) {
+ i32[j] = Number(raw[j]);
+ }
+ } else {
+ i32 = new Int32Array(raw);
+ }
+ const valid = buildValidBitmap(column, numRows);
+ result.set(field.name, { type: "int32", values: i32, valid });
+ } else if (
+ field.type instanceof Utf8 ||
+ field.type instanceof Dictionary ||
+ field.type.typeId === 5 /* Utf8 */
+ ) {
+ const labels: string[] = new Array(numRows);
+ for (let i = 0; i < numRows; i++) {
+ labels[i] = String(column.get(i) ?? "");
+ }
+ result.set(field.name, { type: "string", labels });
+ } else if (field.type.typeId === 12 /* List */) {
+ const listValues: string[][] = new Array(numRows);
+ for (let i = 0; i < numRows; i++) {
+ const val = column.get(i);
+ if (val == null) {
+ listValues[i] = [];
+ } else {
+ const arr: string[] = [];
+ for (let j = 0; j < val.length; j++) {
+ arr.push(String(val[j] ?? ""));
+ }
+ listValues[i] = arr;
+ }
+ }
+ result.set(field.name, { type: "list-string", listValues });
+ } else {
+ // For other types (bool, date, datetime), convert to float32
+ const raw = column.toArray();
+ const f32 = new Float32Array(numRows);
+ for (let i = 0; i < numRows; i++) {
+ const val = raw[i];
+ f32[i] =
+ val instanceof Date
+ ? val.getTime()
+ : typeof val === "boolean"
+ ? val
+ ? 1
+ : 0
+ : Number(val) || 0;
+ }
+ result.set(field.name, { type: "float32", values: f32 });
+ }
+ }
+
+ return result;
+}
diff --git a/packages/viewer-webgl/src/ts/data/chunk-iterator.ts b/packages/viewer-webgl/src/ts/data/chunk-iterator.ts
new file mode 100644
index 0000000000..f6dec5e739
--- /dev/null
+++ b/packages/viewer-webgl/src/ts/data/chunk-iterator.ts
@@ -0,0 +1,67 @@
+// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
+// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
+// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
+// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
+// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
+// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
+// ┃ Copyright (c) 2017, the Perspective Authors. ┃
+// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
+// ┃ This file is part of the Perspective library, distributed under the terms ┃
+// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
+// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
+
+import type { View } from "@perspective-dev/client";
+
+export interface ChunkResult {
+ arrow: ArrayBuffer;
+ start: number;
+ end: number;
+}
+
+export class ChunkIterator {
+ private _view: View;
+ private _totalRows: number;
+ private _chunkSize: number;
+
+ set chunkSize(size: number) {
+ this._chunkSize = size;
+ }
+ private _endCol: number | undefined;
+ private _cursor: number;
+
+ constructor(
+ view: View,
+ totalRows: number,
+ chunkSize: number,
+ endCol?: number,
+ ) {
+ this._view = view;
+ this._totalRows = totalRows;
+ this._chunkSize = chunkSize;
+ this._endCol = endCol;
+ this._cursor = 0;
+ }
+
+ async nextChunk(): Promise {
+ if (this._cursor >= this._totalRows) {
+ return null;
+ }
+
+ const start = this._cursor;
+ const end = Math.min(start + this._chunkSize, this._totalRows);
+
+ const window: Record = {
+ start_row: start,
+ end_row: end,
+ };
+
+ if (this._endCol !== undefined) {
+ window.end_col = this._endCol;
+ }
+
+ const arrow = await this._view.to_arrow(window);
+ this._cursor = end;
+
+ return { arrow, start, end };
+ }
+}
diff --git a/packages/viewer-webgl/src/ts/index.ts b/packages/viewer-webgl/src/ts/index.ts
new file mode 100644
index 0000000000..7b680fda6d
--- /dev/null
+++ b/packages/viewer-webgl/src/ts/index.ts
@@ -0,0 +1,58 @@
+// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
+// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
+// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
+// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
+// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
+// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
+// ┃ Copyright (c) 2017, the Perspective Authors. ┃
+// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
+// ┃ This file is part of the Perspective library, distributed under the terms ┃
+// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
+// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
+
+import CHARTS from "./plugin/charts";
+import { HTMLPerspectiveViewerWebGLPluginElement } from "./plugin/plugin";
+import { ScatterChart } from "./charts/scatter";
+import { LineChart } from "./charts/line";
+import { TreemapChart } from "./charts/treemap";
+
+const CHART_IMPLS: Record<(typeof CHARTS)[number]["tag"], new () => any> = {
+ scatter: ScatterChart,
+ line: LineChart,
+ treemap: TreemapChart,
+};
+
+export function register(...plugin_names: string[]) {
+ const plugins = new Set(
+ plugin_names.length > 0
+ ? plugin_names
+ : CHARTS.map((chart) => chart.name),
+ );
+
+ CHARTS.forEach((chart) => {
+ if (plugins.has(chart.name)) {
+ const tagName = `perspective-viewer-webgl-${chart.tag}`;
+ const ImplClass = CHART_IMPLS[chart.tag];
+
+ customElements.define(
+ tagName,
+ class extends HTMLPerspectiveViewerWebGLPluginElement {
+ _chartType = chart;
+ static _chartType = chart;
+
+ constructor() {
+ super();
+ (this as any)._chartImpl = new ImplClass();
+ }
+ },
+ );
+
+ customElements.whenDefined("perspective-viewer").then(async () => {
+ const Viewer = customElements.get("perspective-viewer") as any;
+ await Viewer.registerPlugin(tagName);
+ });
+ }
+ });
+}
+
+register();
diff --git a/packages/viewer-webgl/src/ts/interaction/spatial-grid.ts b/packages/viewer-webgl/src/ts/interaction/spatial-grid.ts
new file mode 100644
index 0000000000..78d2b1bcc1
--- /dev/null
+++ b/packages/viewer-webgl/src/ts/interaction/spatial-grid.ts
@@ -0,0 +1,96 @@
+// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
+// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
+// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
+// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
+// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
+// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
+// ┃ Copyright (c) 2017, the Perspective Authors. ┃
+// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
+// ┃ This file is part of the Perspective library, distributed under the terms ┃
+// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
+// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
+
+export class SpatialGrid {
+ private _cells: Map = new Map();
+ private _xMin: number;
+ private _yMin: number;
+ private _cellSize: number;
+ private _cols: number;
+
+ constructor(
+ xMin: number,
+ xMax: number,
+ yMin: number,
+ yMax: number,
+ cellSize: number,
+ ) {
+ this._xMin = xMin;
+ this._yMin = yMin;
+ this._cellSize = cellSize;
+ this._cols = Math.max(1, Math.ceil((xMax - xMin) / cellSize));
+ }
+
+ private _cellKey(cx: number, cy: number): number {
+ return cy * this._cols + cx;
+ }
+
+ insert(index: number, x: number, y: number): void {
+ const cx = Math.floor((x - this._xMin) / this._cellSize);
+ const cy = Math.floor((y - this._yMin) / this._cellSize);
+ const key = this._cellKey(cx, cy);
+ let cell = this._cells.get(key);
+ if (!cell) {
+ cell = [];
+ this._cells.set(key, cell);
+ }
+ cell.push(index);
+ }
+
+ /**
+ * Find the nearest point to (dataX, dataY) within the given radius,
+ * measured in pixel distance using the provided scale factors.
+ */
+ query(
+ dataX: number,
+ dataY: number,
+ radiusPx: number,
+ pxPerDataX: number,
+ pxPerDataY: number,
+ xData: Float32Array,
+ yData: Float32Array,
+ ): number {
+ const cellRadiusX = Math.ceil(radiusPx / pxPerDataX / this._cellSize);
+ const cellRadiusY = Math.ceil(radiusPx / pxPerDataY / this._cellSize);
+ const centerCX = Math.floor((dataX - this._xMin) / this._cellSize);
+ const centerCY = Math.floor((dataY - this._yMin) / this._cellSize);
+
+ let bestIdx = -1;
+ let bestDistSq = radiusPx * radiusPx;
+
+ for (
+ let cy = centerCY - cellRadiusY;
+ cy <= centerCY + cellRadiusY;
+ cy++
+ ) {
+ for (
+ let cx = centerCX - cellRadiusX;
+ cx <= centerCX + cellRadiusX;
+ cx++
+ ) {
+ const cell = this._cells.get(this._cellKey(cx, cy));
+ if (!cell) continue;
+ for (const i of cell) {
+ const dx = (xData[i] - dataX) * pxPerDataX;
+ const dy = (yData[i] - dataY) * pxPerDataY;
+ const distSq = dx * dx + dy * dy;
+ if (distSq < bestDistSq) {
+ bestDistSq = distSq;
+ bestIdx = i;
+ }
+ }
+ }
+ }
+
+ return bestIdx;
+ }
+}
diff --git a/packages/viewer-webgl/src/ts/interaction/zoom-controller.ts b/packages/viewer-webgl/src/ts/interaction/zoom-controller.ts
new file mode 100644
index 0000000000..b41026748c
--- /dev/null
+++ b/packages/viewer-webgl/src/ts/interaction/zoom-controller.ts
@@ -0,0 +1,260 @@
+// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
+// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
+// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
+// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
+// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
+// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
+// ┃ Copyright (c) 2017, the Perspective Authors. ┃
+// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
+// ┃ This file is part of the Perspective library, distributed under the terms ┃
+// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
+// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
+
+import type { PlotLayout } from "../layout/plot-layout";
+
+export interface ZoomState {
+ scaleX: number;
+ scaleY: number;
+ // Translate as fraction of base domain range (0 = centered, ±0.5 = edge)
+ normTranslateX: number;
+ normTranslateY: number;
+}
+
+const MAX_ZOOM = 50;
+const MIN_ZOOM = 1;
+
+export class ZoomController {
+ private _scaleX = 1;
+ private _scaleY = 1;
+ // Normalized translate: fraction of base domain range
+ private _normTX = 0;
+ private _normTY = 0;
+
+ private _baseXMin = 0;
+ private _baseXMax = 1;
+ private _baseYMin = 0;
+ private _baseYMax = 1;
+
+ private _element: HTMLElement | null = null;
+ private _layout: PlotLayout | null = null;
+ private _onUpdate: (() => void) | null = null;
+
+ private _pointerDown = false;
+ private _lastPointerX = 0;
+ private _lastPointerY = 0;
+
+ private _onWheel: ((e: WheelEvent) => void) | null = null;
+ private _onPointerDown: ((e: PointerEvent) => void) | null = null;
+ private _onPointerMove: ((e: PointerEvent) => void) | null = null;
+ private _onPointerUp: ((e: PointerEvent) => void) | null = null;
+
+ setBaseDomain(
+ xMin: number,
+ xMax: number,
+ yMin: number,
+ yMax: number,
+ ): void {
+ this._baseXMin = xMin;
+ this._baseXMax = xMax;
+ this._baseYMin = yMin;
+ this._baseYMax = yMax;
+ }
+
+ isDefault(): boolean {
+ return (
+ this._scaleX === 1 &&
+ this._scaleY === 1 &&
+ this._normTX === 0 &&
+ this._normTY === 0
+ );
+ }
+
+ getVisibleDomain(): {
+ xMin: number;
+ xMax: number;
+ yMin: number;
+ yMax: number;
+ } {
+ const bxRange = this._baseXMax - this._baseXMin;
+ const byRange = this._baseYMax - this._baseYMin;
+ const vxRange = bxRange / this._scaleX;
+ const vyRange = byRange / this._scaleY;
+
+ // Center = base midpoint + normalized translate * base range
+ const cx =
+ (this._baseXMin + this._baseXMax) / 2 + this._normTX * bxRange;
+ const cy =
+ (this._baseYMin + this._baseYMax) / 2 + this._normTY * byRange;
+
+ return {
+ xMin: cx - vxRange / 2,
+ xMax: cx + vxRange / 2,
+ yMin: cy - vyRange / 2,
+ yMax: cy + vyRange / 2,
+ };
+ }
+
+ attach(
+ element: HTMLElement,
+ layout: PlotLayout,
+ onUpdate: () => void,
+ ): void {
+ this.detach();
+ this._element = element;
+ this._layout = layout;
+ this._onUpdate = onUpdate;
+
+ this._onWheel = (e: WheelEvent) => {
+ e.preventDefault();
+ const rect = element.getBoundingClientRect();
+ const mouseX = e.clientX - rect.left;
+ const mouseY = e.clientY - rect.top;
+ const plot = this._layout!.plotRect;
+
+ if (
+ mouseX < plot.x ||
+ mouseX > plot.x + plot.width ||
+ mouseY < plot.y ||
+ mouseY > plot.y + plot.height
+ )
+ return;
+
+ // Data coordinate under cursor before zoom
+ const domain = this.getVisibleDomain();
+ const dataX =
+ domain.xMin +
+ ((mouseX - plot.x) / plot.width) * (domain.xMax - domain.xMin);
+ const dataY =
+ domain.yMax -
+ ((mouseY - plot.y) / plot.height) * (domain.yMax - domain.yMin);
+
+ // Zoom factor
+ const factor = Math.pow(1.1, -e.deltaY / 100);
+ this._scaleX = Math.max(
+ MIN_ZOOM,
+ Math.min(MAX_ZOOM, this._scaleX * factor),
+ );
+ this._scaleY = Math.max(
+ MIN_ZOOM,
+ Math.min(MAX_ZOOM, this._scaleY * factor),
+ );
+
+ // Adjust translate so the data point under cursor stays put
+ const newDomain = this.getVisibleDomain();
+ const newDataX =
+ newDomain.xMin +
+ ((mouseX - plot.x) / plot.width) *
+ (newDomain.xMax - newDomain.xMin);
+ const newDataY =
+ newDomain.yMax -
+ ((mouseY - plot.y) / plot.height) *
+ (newDomain.yMax - newDomain.yMin);
+
+ const bxRange = this._baseXMax - this._baseXMin;
+ const byRange = this._baseYMax - this._baseYMin;
+ if (bxRange > 0) this._normTX += (dataX - newDataX) / bxRange;
+ if (byRange > 0) this._normTY += (dataY - newDataY) / byRange;
+
+ this._onUpdate!();
+ };
+
+ this._onPointerDown = (e: PointerEvent) => {
+ const rect = element.getBoundingClientRect();
+ const mouseX = e.clientX - rect.left;
+ const mouseY = e.clientY - rect.top;
+ const plot = this._layout!.plotRect;
+
+ if (
+ mouseX >= plot.x &&
+ mouseX <= plot.x + plot.width &&
+ mouseY >= plot.y &&
+ mouseY <= plot.y + plot.height
+ ) {
+ this._pointerDown = true;
+ this._lastPointerX = e.clientX;
+ this._lastPointerY = e.clientY;
+ element.setPointerCapture(e.pointerId);
+ }
+ };
+
+ this._onPointerMove = (e: PointerEvent) => {
+ if (!this._pointerDown) return;
+ const dx = e.clientX - this._lastPointerX;
+ const dy = e.clientY - this._lastPointerY;
+ this._lastPointerX = e.clientX;
+ this._lastPointerY = e.clientY;
+
+ const domain = this.getVisibleDomain();
+ const plot = this._layout!.plotRect;
+ const dataPerPixelX = (domain.xMax - domain.xMin) / plot.width;
+ const dataPerPixelY = (domain.yMax - domain.yMin) / plot.height;
+
+ const bxRange = this._baseXMax - this._baseXMin;
+ const byRange = this._baseYMax - this._baseYMin;
+ if (bxRange > 0) this._normTX -= (dx * dataPerPixelX) / bxRange;
+ if (byRange > 0) this._normTY += (dy * dataPerPixelY) / byRange;
+
+ this._onUpdate!();
+ };
+
+ this._onPointerUp = () => {
+ this._pointerDown = false;
+ };
+
+ element.addEventListener("wheel", this._onWheel, { passive: false });
+ element.addEventListener("pointerdown", this._onPointerDown);
+ element.addEventListener("pointermove", this._onPointerMove);
+ element.addEventListener("pointerup", this._onPointerUp);
+ }
+
+ updateLayout(layout: PlotLayout): void {
+ this._layout = layout;
+ }
+
+ detach(): void {
+ if (this._element) {
+ if (this._onWheel)
+ this._element.removeEventListener("wheel", this._onWheel);
+ if (this._onPointerDown)
+ this._element.removeEventListener(
+ "pointerdown",
+ this._onPointerDown,
+ );
+ if (this._onPointerMove)
+ this._element.removeEventListener(
+ "pointermove",
+ this._onPointerMove,
+ );
+ if (this._onPointerUp)
+ this._element.removeEventListener(
+ "pointerup",
+ this._onPointerUp,
+ );
+ }
+ this._element = null;
+ this._onUpdate = null;
+ }
+
+ reset(): void {
+ this._scaleX = 1;
+ this._scaleY = 1;
+ this._normTX = 0;
+ this._normTY = 0;
+ }
+
+ serialize(): ZoomState {
+ return {
+ scaleX: this._scaleX,
+ scaleY: this._scaleY,
+ normTranslateX: this._normTX,
+ normTranslateY: this._normTY,
+ };
+ }
+
+ restore(state: ZoomState): void {
+ this._scaleX = state.scaleX;
+ this._scaleY = state.scaleY;
+ this._normTX = state.normTranslateX;
+ this._normTY = state.normTranslateY;
+ }
+}
diff --git a/packages/viewer-webgl/src/ts/layout/axes.ts b/packages/viewer-webgl/src/ts/layout/axes.ts
new file mode 100644
index 0000000000..1f8ac97b72
--- /dev/null
+++ b/packages/viewer-webgl/src/ts/layout/axes.ts
@@ -0,0 +1,222 @@
+// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
+// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
+// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
+// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
+// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
+// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
+// ┃ Copyright (c) 2017, the Perspective Authors. ┃
+// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
+// ┃ This file is part of the Perspective library, distributed under the terms ┃
+// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
+// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
+
+import { PlotLayout } from "./plot-layout";
+import {
+ computeNiceTicks,
+ formatTickValue,
+ formatDateTickValue,
+} from "./ticks";
+
+export interface AxisDomain {
+ min: number;
+ max: number;
+ label: string;
+ isDate?: boolean;
+}
+
+export interface TickResult {
+ xTicks: number[];
+ yTicks: number[];
+}
+
+function getCSSColor(
+ element: HTMLElement,
+ prop: string,
+ fallback: string,
+): string {
+ const style = getComputedStyle(element);
+ return style.getPropertyValue(prop).trim() || fallback;
+}
+
+function initCanvas(
+ canvas: HTMLCanvasElement,
+ layout: PlotLayout,
+): CanvasRenderingContext2D | null {
+ const dpr = window.devicePixelRatio || 1;
+ canvas.width = Math.round(layout.cssWidth * dpr);
+ canvas.height = Math.round(layout.cssHeight * dpr);
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return null;
+ ctx.scale(dpr, dpr);
+ ctx.clearRect(0, 0, layout.cssWidth, layout.cssHeight);
+ return ctx;
+}
+
+/**
+ * Compute tick positions for both axes.
+ */
+export function computeTicks(
+ xDomain: AxisDomain,
+ yDomain: AxisDomain,
+ layout: PlotLayout,
+): TickResult {
+ const { plotRect: plot } = layout;
+ const targetXTicks = Math.max(2, Math.floor(plot.width / 90));
+ const targetYTicks = Math.max(2, Math.floor(plot.height / 60));
+ return {
+ xTicks: computeNiceTicks(xDomain.min, xDomain.max, targetXTicks),
+ yTicks: computeNiceTicks(yDomain.min, yDomain.max, targetYTicks),
+ };
+}
+
+/**
+ * Render gridlines on the BOTTOM canvas (behind WebGL points).
+ */
+export function renderGridlines(
+ canvas: HTMLCanvasElement,
+ layout: PlotLayout,
+ xTicks: number[],
+ yTicks: number[],
+ styleElement: HTMLElement,
+): void {
+ const ctx = initCanvas(canvas, layout);
+ if (!ctx) return;
+
+ const gridColor = getCSSColor(
+ styleElement,
+ "--psp-webgl--gridline--color",
+ "rgba(128, 128, 128, 0.15)",
+ );
+
+ const { plotRect: plot } = layout;
+ const xToPixel = (val: number) => layout.dataToPixel(val, 0).px;
+ const yToPixel = (val: number) => layout.dataToPixel(0, val).py;
+
+ ctx.strokeStyle = gridColor;
+ ctx.lineWidth = 1;
+
+ for (const tick of xTicks) {
+ const px = Math.round(xToPixel(tick)) + 0.5;
+ if (px < plot.x || px > plot.x + plot.width) continue;
+ ctx.beginPath();
+ ctx.moveTo(px, plot.y);
+ ctx.lineTo(px, plot.y + plot.height);
+ ctx.stroke();
+ }
+ for (const tick of yTicks) {
+ const py = Math.round(yToPixel(tick)) + 0.5;
+ if (py < plot.y || py > plot.y + plot.height) continue;
+ ctx.beginPath();
+ ctx.moveTo(plot.x, py);
+ ctx.lineTo(plot.x + plot.width, py);
+ ctx.stroke();
+ }
+}
+
+/**
+ * Render axis lines, tick marks, tick labels, and axis labels on the
+ * TOP canvas (above WebGL points).
+ */
+export function renderAxesChrome(
+ canvas: HTMLCanvasElement,
+ xDomain: AxisDomain,
+ yDomain: AxisDomain,
+ layout: PlotLayout,
+ xTicks: number[],
+ yTicks: number[],
+): void {
+ const ctx = initCanvas(canvas, layout);
+ if (!ctx) return;
+
+ const tickColor = getCSSColor(
+ canvas,
+ "--psp-webgl--axis-ticks--color",
+ "rgba(160, 160, 160, 0.8)",
+ );
+ const labelColor = getCSSColor(
+ canvas,
+ "--psp-webgl--label--color",
+ "rgba(180, 180, 180, 0.9)",
+ );
+ const lineColor = getCSSColor(
+ canvas,
+ "--psp-webgl--axis-lines--color",
+ "rgba(160, 160, 160, 0.4)",
+ );
+ const fontFamily = getCSSColor(
+ canvas,
+ "--psp-webgl--font-family",
+ "monospace",
+ );
+
+ const { plotRect: plot } = layout;
+ const TICK_SIZE = 5;
+
+ const xToPixel = (val: number) => layout.dataToPixel(val, 0).px;
+ const yToPixel = (val: number) => layout.dataToPixel(0, val).py;
+
+ // Axis lines
+ ctx.strokeStyle = lineColor;
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ ctx.moveTo(plot.x, plot.y);
+ ctx.lineTo(plot.x, plot.y + plot.height);
+ ctx.lineTo(plot.x + plot.width, plot.y + plot.height);
+ ctx.stroke();
+
+ // X tick marks and labels
+ ctx.fillStyle = tickColor;
+ ctx.font = `11px ${fontFamily}`;
+ ctx.textAlign = "center";
+ ctx.textBaseline = "top";
+ ctx.strokeStyle = tickColor;
+
+ const xStep = xTicks.length > 1 ? xTicks[1] - xTicks[0] : 0;
+ const yStep = yTicks.length > 1 ? yTicks[1] - yTicks[0] : 0;
+ const fmtX = xDomain.isDate
+ ? (v: number) => formatDateTickValue(v, xStep)
+ : formatTickValue;
+ const fmtY = yDomain.isDate
+ ? (v: number) => formatDateTickValue(v, yStep)
+ : formatTickValue;
+
+ for (const tick of xTicks) {
+ const px = xToPixel(tick);
+ if (px < plot.x - 1 || px > plot.x + plot.width + 1) continue;
+ ctx.beginPath();
+ ctx.moveTo(px, plot.y + plot.height);
+ ctx.lineTo(px, plot.y + plot.height + TICK_SIZE);
+ ctx.stroke();
+ ctx.fillText(fmtX(tick), px, plot.y + plot.height + TICK_SIZE + 3);
+ }
+
+ // Y tick marks and labels
+ ctx.textAlign = "right";
+ ctx.textBaseline = "middle";
+
+ for (const tick of yTicks) {
+ const py = yToPixel(tick);
+ if (py < plot.y - 1 || py > plot.y + plot.height + 1) continue;
+ ctx.beginPath();
+ ctx.moveTo(plot.x - TICK_SIZE, py);
+ ctx.lineTo(plot.x, py);
+ ctx.stroke();
+ ctx.fillText(fmtY(tick), plot.x - TICK_SIZE - 3, py);
+ }
+
+ // Axis labels
+ ctx.fillStyle = labelColor;
+ ctx.font = `13px ${fontFamily}`;
+
+ ctx.textAlign = "center";
+ ctx.textBaseline = "bottom";
+ ctx.fillText(xDomain.label, plot.x + plot.width / 2, layout.cssHeight - 2);
+
+ ctx.save();
+ ctx.translate(14, plot.y + plot.height / 2);
+ ctx.rotate(-Math.PI / 2);
+ ctx.textAlign = "center";
+ ctx.textBaseline = "bottom";
+ ctx.fillText(yDomain.label, 0, 0);
+ ctx.restore();
+}
diff --git a/packages/viewer-webgl/src/ts/layout/legend.ts b/packages/viewer-webgl/src/ts/layout/legend.ts
new file mode 100644
index 0000000000..53e4a1939c
--- /dev/null
+++ b/packages/viewer-webgl/src/ts/layout/legend.ts
@@ -0,0 +1,140 @@
+// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
+// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
+// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
+// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
+// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
+// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
+// ┃ Copyright (c) 2017, the Perspective Authors. ┃
+// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
+// ┃ This file is part of the Perspective library, distributed under the terms ┃
+// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
+// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
+
+import type { PlotLayout } from "./plot-layout";
+import { formatTickValue } from "./ticks";
+
+/**
+ * Render a vertical color gradient legend on the Canvas2D overlay.
+ * Only call when a color column is active.
+ */
+export function renderLegend(
+ canvas: HTMLCanvasElement,
+ layout: PlotLayout,
+ colorDomain: { min: number; max: number; label: string },
+ colorStart: [number, number, number],
+ colorEnd: [number, number, number],
+): void {
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return;
+
+ const style = getComputedStyle(canvas);
+ const textColor =
+ style.getPropertyValue("--psp-webgl--legend--color").trim() ||
+ "rgba(180, 180, 180, 0.9)";
+ const borderColor =
+ style.getPropertyValue("--psp-webgl--legend-border--color").trim() ||
+ "rgba(128,128,128,0.3)";
+ const fontFamily =
+ style.getPropertyValue("--psp-webgl--font-family").trim() ||
+ "monospace";
+
+ const barWidth = 16;
+ const barHeight = Math.min(120, layout.plotRect.height * 0.4);
+ const x = layout.plotRect.x + layout.plotRect.width + 12;
+ const y = layout.margins.top + 20;
+
+ // Column label
+ ctx.fillStyle = textColor;
+ ctx.font = `11px ${fontFamily}`;
+ ctx.textAlign = "left";
+ ctx.textBaseline = "bottom";
+ ctx.fillText(colorDomain.label, x, y - 4);
+
+ // Gradient bar (top = max, bottom = min)
+ const gradient = ctx.createLinearGradient(0, y, 0, y + barHeight);
+ gradient.addColorStop(
+ 0,
+ `rgb(${colorEnd[0] * 255},${colorEnd[1] * 255},${colorEnd[2] * 255})`,
+ );
+ gradient.addColorStop(
+ 1,
+ `rgb(${colorStart[0] * 255},${colorStart[1] * 255},${colorStart[2] * 255})`,
+ );
+ ctx.fillStyle = gradient;
+ ctx.fillRect(x, y, barWidth, barHeight);
+
+ // Border
+ ctx.strokeStyle = borderColor;
+ ctx.lineWidth = 1;
+ ctx.strokeRect(x, y, barWidth, barHeight);
+
+ // Tick labels alongside the bar
+ ctx.fillStyle = textColor;
+ ctx.font = `10px ${fontFamily}`;
+ ctx.textAlign = "left";
+ ctx.textBaseline = "middle";
+
+ const labelX = x + barWidth + 5;
+ ctx.fillText(formatTickValue(colorDomain.max), labelX, y + 2);
+ ctx.fillText(
+ formatTickValue((colorDomain.min + colorDomain.max) / 2),
+ labelX,
+ y + barHeight / 2,
+ );
+ ctx.fillText(formatTickValue(colorDomain.min), labelX, y + barHeight - 2);
+}
+
+/**
+ * Render a categorical legend with discrete colored swatches.
+ * Used when split_by or string color columns produce distinct categories.
+ */
+export function renderCategoricalLegend(
+ canvas: HTMLCanvasElement,
+ layout: PlotLayout,
+ labels: Map,
+ colorStart: [number, number, number],
+ colorEnd: [number, number, number],
+): void {
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return;
+ if (labels.size === 0) return;
+
+ const style = getComputedStyle(canvas);
+ const textColor =
+ style.getPropertyValue("--psp-webgl--legend--color").trim() ||
+ "rgba(180, 180, 180, 0.9)";
+ const fontFamily =
+ style.getPropertyValue("--psp-webgl--font-family").trim() ||
+ "monospace";
+
+ const swatchSize = 10;
+ const lineHeight = 18;
+ const x = layout.plotRect.x + layout.plotRect.width + 12;
+ let y = layout.margins.top + 10;
+ const maxIdx = labels.size - 1;
+
+ ctx.font = `11px ${fontFamily}`;
+ ctx.textAlign = "left";
+ ctx.textBaseline = "middle";
+
+ for (const [label, idx] of labels) {
+ const t = maxIdx > 0 ? idx / maxIdx : 0;
+ const r = Math.round(
+ (colorStart[0] + t * (colorEnd[0] - colorStart[0])) * 255,
+ );
+ const g = Math.round(
+ (colorStart[1] + t * (colorEnd[1] - colorStart[1])) * 255,
+ );
+ const b = Math.round(
+ (colorStart[2] + t * (colorEnd[2] - colorStart[2])) * 255,
+ );
+
+ ctx.fillStyle = `rgb(${r},${g},${b})`;
+ ctx.fillRect(x, y - swatchSize / 2, swatchSize, swatchSize);
+
+ ctx.fillStyle = textColor;
+ ctx.fillText(label, x + swatchSize + 6, y);
+
+ y += lineHeight;
+ }
+}
diff --git a/packages/viewer-webgl/src/ts/layout/plot-layout.ts b/packages/viewer-webgl/src/ts/layout/plot-layout.ts
new file mode 100644
index 0000000000..052db20109
--- /dev/null
+++ b/packages/viewer-webgl/src/ts/layout/plot-layout.ts
@@ -0,0 +1,141 @@
+// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
+// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
+// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
+// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
+// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
+// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
+// ┃ Copyright (c) 2017, the Perspective Authors. ┃
+// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
+// ┃ This file is part of the Perspective library, distributed under the terms ┃
+// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
+// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
+
+export interface PlotMargins {
+ top: number;
+ right: number;
+ bottom: number;
+ left: number;
+}
+
+export interface PlotRect {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+}
+
+export interface PlotLayoutOptions {
+ hasXLabel: boolean;
+ hasYLabel: boolean;
+ hasLegend: boolean;
+}
+
+/**
+ * Coordinates margins and coordinate transforms between WebGL and Canvas2D.
+ * All measurements are in CSS pixels (not physical/DPR-scaled pixels).
+ */
+export class PlotLayout {
+ readonly margins: PlotMargins;
+ readonly plotRect: PlotRect;
+ readonly cssWidth: number;
+ readonly cssHeight: number;
+
+ // Padded domain set by buildProjectionMatrix, used by dataToPixel
+ // and pixelToData for tooltip hit-testing.
+ paddedXMin = 0;
+ paddedXMax = 1;
+ paddedYMin = 0;
+ paddedYMax = 1;
+
+ constructor(
+ cssWidth: number,
+ cssHeight: number,
+ options: PlotLayoutOptions,
+ ) {
+ this.cssWidth = cssWidth;
+ this.cssHeight = cssHeight;
+
+ const left = 55 + (options.hasYLabel ? 16 : 0);
+ const bottom = 24 + (options.hasXLabel ? 18 : 0);
+ const top = 12;
+ const right = options.hasLegend ? 80 : 16;
+
+ this.margins = { top, right, bottom, left };
+ this.plotRect = {
+ x: left,
+ y: top,
+ width: Math.max(1, cssWidth - left - right),
+ height: Math.max(1, cssHeight - top - bottom),
+ };
+ }
+
+ /**
+ * Build an orthographic projection matrix that maps data coordinates
+ * [xMin..xMax, yMin..yMax] to the plot area sub-region of clip space [-1, 1].
+ *
+ * The matrix bakes margin offsets into the transform so that gl.viewport
+ * remains full-canvas and no scissor/sub-viewport is needed.
+ */
+ buildProjectionMatrix(
+ xMin: number,
+ xMax: number,
+ yMin: number,
+ yMax: number,
+ ): Float32Array {
+ // Add 5% padding to data range
+ let xRange = xMax - xMin;
+ let yRange = yMax - yMin;
+ if (xRange === 0) xRange = 1;
+ if (yRange === 0) yRange = 1;
+ const xPad = xRange * 0.02;
+ const yPad = yRange * 0.02;
+ xMin -= xPad;
+ xMax += xPad;
+ yMin -= yPad;
+ yMax += yPad;
+
+ // Store padded domain for dataToPixel
+ this.paddedXMin = xMin;
+ this.paddedXMax = xMax;
+ this.paddedYMin = yMin;
+ this.paddedYMax = yMax;
+
+ // Clip-space bounds for the plot area
+ const clipLeft = (2 * this.margins.left) / this.cssWidth - 1;
+ const clipRight = 1 - (2 * this.margins.right) / this.cssWidth;
+ const clipBottom = (2 * this.margins.bottom) / this.cssHeight - 1;
+ const clipTop = 1 - (2 * this.margins.top) / this.cssHeight;
+
+ // Scale and translate: data [min,max] → clip [clipMin, clipMax]
+ const sx = (clipRight - clipLeft) / (xMax - xMin);
+ const sy = (clipTop - clipBottom) / (yMax - yMin);
+ const tx = clipLeft - sx * xMin;
+ const ty = clipBottom - sy * yMin;
+
+ // Column-major 4x4 matrix
+ // prettier-ignore
+ return new Float32Array([
+ sx, 0, 0, 0,
+ 0, sy, 0, 0,
+ 0, 0, -1, 0,
+ tx, ty, 0, 1,
+ ]);
+ }
+
+ /**
+ * Convert data coordinates to CSS pixel coordinates on the overlay canvas.
+ * Uses the padded domain from the last `buildProjectionMatrix` call so
+ * that pixel positions align exactly with the WebGL projection.
+ */
+ dataToPixel(dataX: number, dataY: number): { px: number; py: number } {
+ const { x, y, width, height } = this.plotRect;
+ const tx =
+ (dataX - this.paddedXMin) / (this.paddedXMax - this.paddedXMin);
+ const ty =
+ (dataY - this.paddedYMin) / (this.paddedYMax - this.paddedYMin);
+ return {
+ px: x + tx * width,
+ py: y + (1 - ty) * height, // Y is flipped (CSS Y goes down)
+ };
+ }
+}
diff --git a/packages/viewer-webgl/src/ts/layout/ticks.ts b/packages/viewer-webgl/src/ts/layout/ticks.ts
new file mode 100644
index 0000000000..2541403156
--- /dev/null
+++ b/packages/viewer-webgl/src/ts/layout/ticks.ts
@@ -0,0 +1,128 @@
+// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
+// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
+// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
+// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
+// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
+// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
+// ┃ Copyright (c) 2017, the Perspective Authors. ┃
+// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
+// ┃ This file is part of the Perspective library, distributed under the terms ┃
+// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
+// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
+
+function niceNum(value: number, round: boolean): number {
+ const exp = Math.floor(Math.log10(value));
+ const frac = value / Math.pow(10, exp);
+ let nice: number;
+ if (round) {
+ if (frac < 1.5) nice = 1;
+ else if (frac < 3) nice = 2;
+ else if (frac < 7) nice = 5;
+ else nice = 10;
+ } else {
+ if (frac <= 1) nice = 1;
+ else if (frac <= 2) nice = 2;
+ else if (frac <= 5) nice = 5;
+ else nice = 10;
+ }
+ return nice * Math.pow(10, exp);
+}
+
+/**
+ * Generate an array of "nice" tick values spanning [min, max].
+ * @param min - Domain minimum
+ * @param max - Domain maximum
+ * @param targetCount - Desired number of ticks (approximate)
+ */
+export function computeNiceTicks(
+ min: number,
+ max: number,
+ targetCount: number,
+): number[] {
+ if (targetCount < 1) targetCount = 1;
+ const range = niceNum(max - min, false);
+ const step = niceNum(range / targetCount, true);
+ const tickMin = Math.ceil(min / step) * step;
+ const tickMax = Math.floor(max / step) * step;
+
+ const ticks: number[] = [];
+ // Use epsilon to avoid floating point overshoot
+ for (let t = tickMin; t <= tickMax + step * 0.001; t += step) {
+ ticks.push(t);
+ }
+ return ticks;
+}
+
+/**
+ * Format a numeric tick value for display.
+ * Uses K/M/B suffixes for large numbers, fixed decimals for small.
+ */
+export function formatTickValue(val: number): string {
+ const abs = Math.abs(val);
+ if (abs === 0) return "0";
+ if (abs >= 1e9) return (val / 1e9).toFixed(1) + "B";
+ if (abs >= 1e6) return (val / 1e6).toFixed(1) + "M";
+ if (abs >= 1e3) return (val / 1e3).toFixed(1) + "K";
+ if (Number.isInteger(val)) return val.toString();
+ if (abs >= 1) return val.toFixed(1);
+ return val.toFixed(2);
+}
+
+/**
+ * Format a timestamp (ms since epoch) as a human-readable date/time label.
+ * Adapts precision based on the tick spacing.
+ */
+export function formatDateTickValue(val: number, stepMs?: number): string {
+ const d = new Date(val);
+ if (isNaN(d.getTime())) return formatTickValue(val);
+
+ // If step is provided, choose precision based on tick interval
+ if (stepMs !== undefined && stepMs > 0) {
+ const DAY = 86_400_000;
+ const HOUR = 3_600_000;
+ const MINUTE = 60_000;
+
+ if (stepMs >= DAY * 28) {
+ // Monthly or longer — show year-month
+ return d.toLocaleDateString(undefined, {
+ year: "numeric",
+ month: "short",
+ });
+ }
+ if (stepMs >= DAY) {
+ // Daily — show month and day
+ return d.toLocaleDateString(undefined, {
+ month: "short",
+ day: "numeric",
+ });
+ }
+ if (stepMs >= HOUR) {
+ // Hourly
+ return d.toLocaleString(undefined, {
+ month: "short",
+ day: "numeric",
+ hour: "numeric",
+ });
+ }
+ if (stepMs >= MINUTE) {
+ // Minutes
+ return d.toLocaleTimeString(undefined, {
+ hour: "numeric",
+ minute: "2-digit",
+ });
+ }
+ // Sub-minute
+ return d.toLocaleTimeString(undefined, {
+ hour: "numeric",
+ minute: "2-digit",
+ second: "2-digit",
+ });
+ }
+
+ // Default: show date only
+ return d.toLocaleDateString(undefined, {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ });
+}
diff --git a/packages/viewer-webgl/src/ts/plugin/charts.ts b/packages/viewer-webgl/src/ts/plugin/charts.ts
new file mode 100644
index 0000000000..e0c402b2eb
--- /dev/null
+++ b/packages/viewer-webgl/src/ts/plugin/charts.ts
@@ -0,0 +1,65 @@
+// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
+// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
+// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
+// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
+// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
+// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
+// ┃ Copyright (c) 2017, the Perspective Authors. ┃
+// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
+// ┃ This file is part of the Perspective library, distributed under the terms ┃
+// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
+// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
+
+export interface ChartTypeConfig {
+ name: string;
+ tag: string;
+ category: string;
+ selectMode: "select" | "toggle";
+ initial: {
+ count: number;
+ names: string[];
+ };
+ max_cells: number;
+ max_columns: number;
+}
+
+const CHARTS = [
+ {
+ name: "GPU Scatter",
+ tag: "scatter",
+ category: "GPU Charts",
+ selectMode: "toggle",
+ initial: {
+ count: 2,
+ names: ["X Axis", "Y Axis", "Color", "Size", "Tooltip"],
+ },
+ max_cells: 10_000_000,
+ max_columns: 50,
+ },
+ {
+ name: "GPU Line",
+ tag: "line",
+ category: "GPU Charts",
+ selectMode: "select",
+ initial: {
+ count: 2,
+ names: ["X Axis", "Y Axis"],
+ },
+ max_cells: 10_000_000,
+ max_columns: 50,
+ },
+ {
+ name: "GPU Treemap",
+ tag: "treemap",
+ category: "GPU Charts",
+ selectMode: "toggle",
+ initial: {
+ count: 1,
+ names: ["Size", "Color", "Tooltip"],
+ },
+ max_cells: 50_000,
+ max_columns: 10,
+ },
+] as const satisfies readonly ChartTypeConfig[];
+
+export default CHARTS;
diff --git a/packages/viewer-webgl/src/ts/plugin/plugin.ts b/packages/viewer-webgl/src/ts/plugin/plugin.ts
new file mode 100644
index 0000000000..1fc70735f1
--- /dev/null
+++ b/packages/viewer-webgl/src/ts/plugin/plugin.ts
@@ -0,0 +1,437 @@
+// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
+// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
+// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
+// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
+// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
+// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
+// ┃ Copyright (c) 2017, the Perspective Authors. ┃
+// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
+// ┃ This file is part of the Perspective library, distributed under the terms ┃
+// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
+// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
+
+import type { View } from "@perspective-dev/client";
+import type {
+ StreamingRenderHandle,
+ RenderChunk,
+} from "@perspective-dev/viewer";
+import { ChartTypeConfig } from "./charts";
+import style from "../../css/perspective-viewer-webgl.css";
+import { WebGLContextManager } from "../webgl/context-manager";
+import { ChunkIterator } from "../data/chunk-iterator";
+import { arrowToTypedArrays, ColumnDataMap } from "../data/arrow-reader";
+import { ChartImplementation } from "../charts/chart";
+import { ZoomController } from "../interaction/zoom-controller";
+import { PlotLayout } from "../layout/plot-layout";
+
+const GLOBAL_STYLES = (() => {
+ const sheet = new CSSStyleSheet();
+ sheet.replaceSync(style);
+ return [sheet];
+})();
+
+const FIRST_CHUNK_SIZE = 10_000;
+
+function computeChunkSize(totalRows: number): number {
+ if (totalRows <= 100_000) return 10_000;
+ if (totalRows <= 1_000_000) return 50_000;
+ if (totalRows <= 10_000_000) return 200_000;
+ return 500_000;
+}
+
+export class HTMLPerspectiveViewerWebGLPluginElement extends HTMLElement {
+ _chartType: ChartTypeConfig;
+ static _chartType: ChartTypeConfig;
+
+ private _initialized = false;
+ private _glCanvas: HTMLCanvasElement;
+ private _gridlineCanvas: HTMLCanvasElement;
+ private _chromeCanvas: HTMLCanvasElement;
+ private _glManager: WebGLContextManager | null = null;
+ private _chartImpl: ChartImplementation | null = null;
+ private _zoomController: ZoomController | null = null;
+ private _generation = 0;
+
+ connectedCallback() {
+ if (!this._initialized) {
+ this.attachShadow({ mode: "open" });
+ for (const sheet of GLOBAL_STYLES) {
+ this.shadowRoot!.adoptedStyleSheets.push(sheet);
+ }
+
+ this.shadowRoot!.innerHTML = `
+
+ `;
+ this._glCanvas = this.shadowRoot!.querySelector(
+ ".webgl-canvas",
+ ) as HTMLCanvasElement;
+ this._gridlineCanvas = this.shadowRoot!.querySelector(
+ ".webgl-gridlines",
+ ) as HTMLCanvasElement;
+ this._chromeCanvas = this.shadowRoot!.querySelector(
+ ".webgl-chrome",
+ ) as HTMLCanvasElement;
+ this._initialized = true;
+ }
+ }
+
+ private _ensureGL(): WebGLContextManager {
+ if (!this._initialized) {
+ this.connectedCallback();
+ }
+ if (!this._glManager) {
+ this._glManager = new WebGLContextManager(this._glCanvas);
+ this._setupChartIntegration();
+ }
+ return this._glManager;
+ }
+
+ private _setupChartIntegration(): void {
+ if (!this._chartImpl) return;
+
+ // Wire overlay and tooltip canvases
+ if (this._chartImpl.setGridlineCanvas) {
+ this._chartImpl.setGridlineCanvas(this._gridlineCanvas);
+ }
+ if (this._chartImpl.setChromeCanvas) {
+ this._chartImpl.setChromeCanvas(this._chromeCanvas);
+ }
+
+ // Create and wire zoom controller
+ if (this._chartImpl.setZoomController && !this._zoomController) {
+ this._zoomController = new ZoomController();
+ this._chartImpl.setZoomController(this._zoomController);
+
+ // Create a dummy layout for initial attachment; it will be
+ // updated on each render via scatter's _fullRender.
+ const rect = this._glCanvas.getBoundingClientRect();
+ const layout = new PlotLayout(
+ rect.width || 100,
+ rect.height || 100,
+ {
+ hasXLabel: true,
+ hasYLabel: true,
+ hasLegend: false,
+ },
+ );
+
+ const zoomControls = this.shadowRoot!.querySelector(
+ ".zoom-controls",
+ ) as HTMLDivElement;
+
+ this._zoomController.attach(this._glCanvas, layout, () => {
+ if (this._chartImpl && this._glManager) {
+ this._chartImpl.redraw(this._glManager);
+ }
+ // Show reset button when zoomed/panned
+ if (zoomControls && this._zoomController) {
+ zoomControls.classList.toggle(
+ "visible",
+ !this._zoomController.isDefault(),
+ );
+ }
+ });
+
+ // Wire reset button
+ const resetBtn = this.shadowRoot!.querySelector(".zoom-reset");
+ if (resetBtn) {
+ resetBtn.addEventListener("click", () => {
+ if (this._zoomController) {
+ this._zoomController.reset();
+ if (zoomControls) {
+ zoomControls.classList.remove("visible");
+ }
+ if (this._chartImpl && this._glManager) {
+ this._chartImpl.redraw(this._glManager);
+ }
+ }
+ });
+ }
+ }
+
+ // Attach tooltip
+ if (this._chartImpl.attachTooltip) {
+ this._chartImpl.attachTooltip(this._glCanvas);
+ }
+ }
+
+ get name() {
+ return this._chartType.name;
+ }
+
+ get category() {
+ return this._chartType.category;
+ }
+
+ get select_mode() {
+ return this._chartType.selectMode;
+ }
+
+ get min_config_columns() {
+ return this._chartType.initial.count;
+ }
+
+ get config_column_names() {
+ return this._chartType.initial.names;
+ }
+
+ get max_cells() {
+ return this._chartType.max_cells;
+ }
+
+ get max_columns() {
+ return this._chartType.max_columns;
+ }
+
+ get priority() {
+ return 0;
+ }
+
+ get supports_streaming() {
+ return true;
+ }
+
+ get render_warning() {
+ return false;
+ }
+
+ set render_warning(_value: boolean) {
+ // No-op: viewer toggles this after draw
+ }
+
+ can_render_column_styles() {
+ return false;
+ }
+
+ column_style_controls() {
+ return {};
+ }
+
+ draw_streaming(
+ view: View,
+ end_col?: number,
+ end_row?: number,
+ ): StreamingRenderHandle {
+ const gen = ++this._generation;
+ const glManager = this._ensureGL();
+
+ let isFirst = true;
+ let totalRows = 0;
+ let chunkIter: ChunkIterator | null = null;
+ let cancelled = false;
+
+ return {
+ next: async (): Promise => {
+ if (cancelled || this._generation !== gen) {
+ return null;
+ }
+
+ if (isFirst) {
+ glManager.resize();
+ glManager.clear();
+ glManager.bufferPool.maxCapacity =
+ this._chartType.max_cells;
+
+ const viewer = this.parentElement as any;
+ const [numRows, schema, viewerConfig] = await Promise.all([
+ view.num_rows(),
+ view.schema(),
+ viewer?.getViewConfig?.() ?? {},
+ ]);
+
+ if (cancelled || this._generation !== gen) {
+ return null;
+ }
+
+ // Pass pivot config to chart
+ const groupBy: string[] = viewerConfig?.group_by ?? [];
+ const splitBy: string[] = viewerConfig?.split_by ?? [];
+ if (this._chartImpl?.setViewPivots) {
+ this._chartImpl.setViewPivots(groupBy, splitBy);
+ }
+
+ // Pass column type schema to chart
+ if (this._chartImpl?.setColumnTypes && schema) {
+ this._chartImpl.setColumnTypes(
+ schema as Record,
+ );
+ }
+
+ // Pass column slots (with nulls) to chart for
+ // proper slot assignment
+ const columnSlots: (string | null)[] =
+ viewerConfig?.columns ?? [];
+ if (this._chartImpl?.setColumnSlots) {
+ this._chartImpl.setColumnSlots(columnSlots);
+ }
+
+ const numCols =
+ Object.keys(schema as Record).length ||
+ 1;
+ const maxRows = Math.floor(
+ this._chartType.max_cells / numCols,
+ );
+ totalRows = Math.min(
+ end_row ? Math.min(numRows, end_row) : numRows,
+ maxRows,
+ );
+
+ chunkIter = new ChunkIterator(
+ view,
+ totalRows,
+ FIRST_CHUNK_SIZE,
+ end_col,
+ );
+
+ glManager.ensureBufferCapacity(totalRows);
+
+ const chunk = await chunkIter.nextChunk();
+ if (!chunk || cancelled || this._generation !== gen) {
+ return null;
+ }
+
+ const columns = arrowToTypedArrays(chunk.arrow);
+ this._renderChunkData(columns, chunk.start, chunk.end);
+
+ isFirst = false;
+
+ // Scale up chunk size after fast first paint
+ chunkIter.chunkSize = computeChunkSize(totalRows);
+
+ const complete = chunk.end >= totalRows;
+ return {
+ isFirst: true,
+ isComplete: complete,
+ progress: chunk.end / totalRows,
+ };
+ }
+
+ const chunk = await chunkIter!.nextChunk();
+ if (!chunk || cancelled || this._generation !== gen) {
+ return null;
+ }
+
+ const columns = arrowToTypedArrays(chunk.arrow);
+ this._renderChunkData(columns, chunk.start, chunk.end);
+
+ const complete = chunk.end >= totalRows;
+ return {
+ isFirst: false,
+ isComplete: complete,
+ progress: chunk.end / totalRows,
+ };
+ },
+
+ cancel: () => {
+ cancelled = true;
+ },
+ };
+ }
+
+ update_streaming(
+ view: View,
+ end_col?: number,
+ end_row?: number,
+ ): StreamingRenderHandle {
+ return this.draw_streaming(view, end_col, end_row);
+ }
+
+ async draw(view: View): Promise {
+ const handle = this.draw_streaming(view);
+ let chunk: RenderChunk | null;
+ while ((chunk = await handle.next()) !== null) {
+ if (chunk.isComplete) break;
+ }
+ }
+
+ async update(view: View): Promise {
+ return this.draw(view);
+ }
+
+ async clear(): Promise {
+ this._generation++;
+ if (this._glManager) {
+ this._glManager.clear();
+ }
+ // Clear overlay
+ if (this._gridlineCanvas) {
+ const ctx = this._gridlineCanvas.getContext("2d");
+ if (ctx) {
+ ctx.clearRect(
+ 0,
+ 0,
+ this._gridlineCanvas.width,
+ this._gridlineCanvas.height,
+ );
+ }
+ }
+ }
+
+ async resize(): Promise {
+ if (this._glManager) {
+ this._glManager.resize();
+ if (this._chartImpl) {
+ this._chartImpl.redraw(this._glManager);
+ }
+ }
+ }
+
+ async restyle(): Promise {
+ await this.resize();
+ }
+
+ async save(): Promise {
+ const state: any = {};
+ if (this._zoomController) {
+ state.zoom = this._zoomController.serialize();
+ }
+ return state;
+ }
+
+ async restore(config: any): Promise {
+ if (config?.zoom && this._zoomController) {
+ this._zoomController.restore(config.zoom);
+ }
+ }
+
+ async delete(): Promise {
+ this._generation++;
+ // Destroy chart first — it may need the GL context for cleanup.
+ if (this._chartImpl) {
+ this._chartImpl.destroy();
+ this._chartImpl = null;
+ }
+ if (this._zoomController) {
+ this._zoomController.detach();
+ this._zoomController = null;
+ }
+ if (this._glManager) {
+ this._glManager.destroy();
+ this._glManager = null;
+ }
+ }
+
+ private _renderChunkData(
+ columns: ColumnDataMap,
+ startRow: number,
+ endRow: number,
+ ): void {
+ if (!this._glManager) return;
+
+ if (this._chartImpl) {
+ this._chartImpl.uploadAndRender(
+ this._glManager,
+ columns,
+ startRow,
+ endRow,
+ );
+ }
+ }
+}
diff --git a/packages/viewer-webgl/src/ts/shaders/gridline.frag.glsl b/packages/viewer-webgl/src/ts/shaders/gridline.frag.glsl
new file mode 100644
index 0000000000..f2980b7ba9
--- /dev/null
+++ b/packages/viewer-webgl/src/ts/shaders/gridline.frag.glsl
@@ -0,0 +1,6 @@
+precision mediump float;
+uniform vec4 u_color;
+
+void main() {
+ gl_FragColor = u_color;
+}
diff --git a/packages/viewer-webgl/src/ts/shaders/gridline.vert.glsl b/packages/viewer-webgl/src/ts/shaders/gridline.vert.glsl
new file mode 100644
index 0000000000..d8894a1e8e
--- /dev/null
+++ b/packages/viewer-webgl/src/ts/shaders/gridline.vert.glsl
@@ -0,0 +1,6 @@
+attribute vec2 a_position;
+uniform mat4 u_projection;
+
+void main() {
+ gl_Position = u_projection * vec4(a_position, 0.0, 1.0);
+}
diff --git a/packages/viewer-webgl/src/ts/shaders/line.frag.glsl b/packages/viewer-webgl/src/ts/shaders/line.frag.glsl
new file mode 100644
index 0000000000..2716e4c97b
--- /dev/null
+++ b/packages/viewer-webgl/src/ts/shaders/line.frag.glsl
@@ -0,0 +1,16 @@
+precision highp float;
+
+uniform vec4 u_color;
+uniform float u_line_width;
+
+varying float v_edge_dist;
+
+void main() {
+ // |v_edge_dist| ranges from 0 (line centre) to 1 (outer AA fringe edge).
+ // The solid core of the line extends to coreEdge; beyond that we fade out.
+ float dist = abs(v_edge_dist);
+ float coreEdge = u_line_width / (u_line_width + 1.5);
+ float alpha = 1.0 - smoothstep(coreEdge, 1.0, dist);
+
+ gl_FragColor = vec4(u_color.rgb, u_color.a * alpha);
+}
diff --git a/packages/viewer-webgl/src/ts/shaders/line.vert.glsl b/packages/viewer-webgl/src/ts/shaders/line.vert.glsl
new file mode 100644
index 0000000000..097acff0f3
--- /dev/null
+++ b/packages/viewer-webgl/src/ts/shaders/line.vert.glsl
@@ -0,0 +1,44 @@
+// Per-instance attributes (advance once per segment via divisor=1)
+attribute vec2 a_start;
+attribute vec2 a_end;
+
+// Per-vertex attribute (advance every vertex, divisor=0)
+// 0 = start+left, 1 = start+right, 2 = end+left, 3 = end+right
+attribute float a_corner;
+
+uniform mat4 u_projection;
+uniform vec2 u_resolution;
+uniform float u_line_width;
+
+varying float v_edge_dist;
+
+void main() {
+ // Project both segment endpoints to clip space
+ vec4 clipStart = u_projection * vec4(a_start, 0.0, 1.0);
+ vec4 clipEnd = u_projection * vec4(a_end, 0.0, 1.0);
+
+ // Segment direction in pixel space (always correct, no data-space
+ // scale issues because the projection is already applied)
+ vec2 pixelStart = clipStart.xy * u_resolution * 0.5;
+ vec2 pixelEnd = clipEnd.xy * u_resolution * 0.5;
+
+ vec2 dir = pixelEnd - pixelStart;
+ float segLen = length(dir);
+ dir = segLen > 0.001 ? dir / segLen : vec2(1.0, 0.0);
+
+ // Perpendicular in pixel space
+ vec2 normal = vec2(-dir.y, dir.x);
+
+ // Decode corner index
+ float isEnd = step(1.5, a_corner); // 0 for start, 1 for end
+ float side = 1.0 - mod(a_corner, 2.0) * 2.0; // +1 left, -1 right
+
+ vec4 clipPos = mix(clipStart, clipEnd, isEnd);
+
+ // Offset perpendicular to segment, constant pixel width
+ float halfWidth = (u_line_width + 1.5) * 0.5;
+ vec2 clipOffset = (normal * side * halfWidth) / (u_resolution * 0.5);
+
+ gl_Position = clipPos + vec4(clipOffset, 0.0, 0.0);
+ v_edge_dist = side;
+}
diff --git a/packages/viewer-webgl/src/ts/shaders/scatter.frag.glsl b/packages/viewer-webgl/src/ts/shaders/scatter.frag.glsl
new file mode 100644
index 0000000000..750a297421
--- /dev/null
+++ b/packages/viewer-webgl/src/ts/shaders/scatter.frag.glsl
@@ -0,0 +1,27 @@
+precision highp float;
+
+varying float v_color_t;
+varying float v_point_size;
+
+uniform vec4 u_color_start;
+uniform vec4 u_color_end;
+
+void main() {
+ // Distance from center of point sprite in [0, 0.5] space
+ vec2 coord = gl_PointCoord - vec2(0.5);
+ float dist = length(coord);
+
+ // Discard fragments clearly outside the circle
+ if (dist > 0.5) {
+ discard;
+ }
+
+ // Anti-alias: smooth falloff over ~1.5 screen pixels at the edge.
+ // In point-coord space, 1 pixel = 1/v_point_size.
+ float pixelWidth = 1.5 / max(v_point_size, 1.0);
+ float alpha = 1.0 - smoothstep(0.5 - pixelWidth, 0.5, dist);
+
+ // Interpolate color
+ vec4 color = mix(u_color_start, u_color_end, clamp(v_color_t, 0.0, 1.0));
+ gl_FragColor = vec4(color.rgb, color.a * alpha);
+}
diff --git a/packages/viewer-webgl/src/ts/shaders/scatter.vert.glsl b/packages/viewer-webgl/src/ts/shaders/scatter.vert.glsl
new file mode 100644
index 0000000000..6b4da0c6fe
--- /dev/null
+++ b/packages/viewer-webgl/src/ts/shaders/scatter.vert.glsl
@@ -0,0 +1,34 @@
+attribute vec2 a_position;
+attribute float a_color_value;
+attribute float a_size_value;
+
+uniform mat4 u_projection;
+uniform float u_point_size;
+uniform vec2 u_color_range;
+uniform vec2 u_size_range;
+uniform vec2 u_point_size_range;
+
+varying float v_color_t;
+varying float v_point_size;
+
+void main() {
+ gl_Position = u_projection * vec4(a_position, 0.0, 1.0);
+
+ // Per-vertex point size from size attribute
+ float sizeRange = u_size_range.y - u_size_range.x;
+ if (sizeRange > 0.0) {
+ float size_t = clamp((a_size_value - u_size_range.x) / sizeRange, 0.0, 1.0);
+ gl_PointSize = mix(u_point_size_range.x, u_point_size_range.y, size_t);
+ } else {
+ gl_PointSize = u_point_size;
+ }
+
+ // Pass point size to fragment shader for AA calculation
+ v_point_size = gl_PointSize;
+
+ // Normalize color value to 0..1 range
+ float range = u_color_range.y - u_color_range.x;
+ v_color_t = range > 0.0
+ ? (a_color_value - u_color_range.x) / range
+ : 0.5;
+}
diff --git a/packages/viewer-webgl/src/ts/shaders/treemap.frag.glsl b/packages/viewer-webgl/src/ts/shaders/treemap.frag.glsl
new file mode 100644
index 0000000000..aacdd9f89a
--- /dev/null
+++ b/packages/viewer-webgl/src/ts/shaders/treemap.frag.glsl
@@ -0,0 +1,7 @@
+precision highp float;
+
+varying vec3 v_color;
+
+void main() {
+ gl_FragColor = vec4(v_color, 1.0);
+}
diff --git a/packages/viewer-webgl/src/ts/shaders/treemap.vert.glsl b/packages/viewer-webgl/src/ts/shaders/treemap.vert.glsl
new file mode 100644
index 0000000000..30f0fed04e
--- /dev/null
+++ b/packages/viewer-webgl/src/ts/shaders/treemap.vert.glsl
@@ -0,0 +1,13 @@
+attribute vec2 a_position;
+attribute vec3 a_color;
+
+uniform vec2 u_resolution;
+
+varying vec3 v_color;
+
+void main() {
+ vec2 clip = (a_position / u_resolution) * 2.0 - 1.0;
+ clip.y = -clip.y;
+ gl_Position = vec4(clip, 0.0, 1.0);
+ v_color = a_color;
+}
diff --git a/packages/viewer-webgl/src/ts/utils/css.ts b/packages/viewer-webgl/src/ts/utils/css.ts
new file mode 100644
index 0000000000..65eab0bf37
--- /dev/null
+++ b/packages/viewer-webgl/src/ts/utils/css.ts
@@ -0,0 +1,34 @@
+// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
+// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
+// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
+// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
+// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
+// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
+// ┃ Copyright (c) 2017, the Perspective Authors. ┃
+// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
+// ┃ This file is part of the Perspective library, distributed under the terms ┃
+// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
+// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
+
+export function parseCSSColorToVec3(
+ colorStr: string,
+): [number, number, number] {
+ const s = colorStr.trim();
+ if (s.startsWith("#")) {
+ let hex = s.slice(1);
+ if (hex.length === 3)
+ hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
+ return [
+ parseInt(hex.slice(0, 2), 16) / 255,
+ parseInt(hex.slice(2, 4), 16) / 255,
+ parseInt(hex.slice(4, 6), 16) / 255,
+ ];
+ }
+ const m = s.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
+ if (m) return [+m[1] / 255, +m[2] / 255, +m[3] / 255];
+ return [0.5, 0.5, 0.5];
+}
+
+export function getCSSVar(el: Element, prop: string, fallback: string): string {
+ return getComputedStyle(el).getPropertyValue(prop).trim() || fallback;
+}
diff --git a/packages/viewer-webgl/src/ts/webgl/buffer-pool.ts b/packages/viewer-webgl/src/ts/webgl/buffer-pool.ts
new file mode 100644
index 0000000000..ac1a71cb09
--- /dev/null
+++ b/packages/viewer-webgl/src/ts/webgl/buffer-pool.ts
@@ -0,0 +1,94 @@
+// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
+// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
+// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
+// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
+// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
+// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
+// ┃ Copyright (c) 2017, the Perspective Authors. ┃
+// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
+// ┃ This file is part of the Perspective library, distributed under the terms ┃
+// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
+// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
+
+type GL = WebGL2RenderingContext | WebGLRenderingContext;
+
+export interface ManagedBuffer {
+ buffer: WebGLBuffer;
+ byteCapacity: number;
+}
+
+export class BufferPool {
+ private _gl: GL;
+ private _buffers: Map = new Map();
+ private _totalCapacity = 0;
+ private _maxCapacity = 0;
+
+ constructor(gl: GL) {
+ this._gl = gl;
+ }
+
+ get totalCapacity(): number {
+ return this._totalCapacity;
+ }
+
+ set maxCapacity(cap: number) {
+ this._maxCapacity = cap;
+ }
+
+ ensureCapacity(totalRows: number): void {
+ this._totalCapacity =
+ this._maxCapacity > 0
+ ? Math.min(totalRows, this._maxCapacity)
+ : totalRows;
+ }
+
+ getOrCreate(
+ name: string,
+ componentsPerVertex: number,
+ bytesPerElement: number,
+ ): ManagedBuffer {
+ const requiredBytes =
+ this._totalCapacity * componentsPerVertex * bytesPerElement;
+ let managed = this._buffers.get(name);
+ if (managed && managed.byteCapacity >= requiredBytes) return managed;
+
+ const gl = this._gl;
+ if (managed) {
+ gl.deleteBuffer(managed.buffer);
+ }
+ const buffer = gl.createBuffer()!;
+ gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
+ gl.bufferData(gl.ARRAY_BUFFER, requiredBytes, gl.DYNAMIC_DRAW);
+
+ managed = { buffer, byteCapacity: requiredBytes };
+ this._buffers.set(name, managed);
+ return managed;
+ }
+
+ upload(
+ name: string,
+ data: Float32Array | Int32Array,
+ byteOffset: number,
+ componentsPerVertex: number = 1,
+ ): WebGLBuffer {
+ const gl = this._gl;
+ const managed = this.getOrCreate(
+ name,
+ componentsPerVertex,
+ data.BYTES_PER_ELEMENT,
+ );
+
+ gl.bindBuffer(gl.ARRAY_BUFFER, managed.buffer);
+ gl.bufferSubData(gl.ARRAY_BUFFER, byteOffset, data);
+
+ return managed.buffer;
+ }
+
+ releaseAll(): void {
+ for (const managed of this._buffers.values()) {
+ this._gl.deleteBuffer(managed.buffer);
+ }
+ this._buffers.clear();
+ this._totalCapacity = 0;
+ }
+}
diff --git a/packages/viewer-webgl/src/ts/webgl/context-manager.ts b/packages/viewer-webgl/src/ts/webgl/context-manager.ts
new file mode 100644
index 0000000000..a2da1c8c9a
--- /dev/null
+++ b/packages/viewer-webgl/src/ts/webgl/context-manager.ts
@@ -0,0 +1,119 @@
+// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
+// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
+// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
+// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
+// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
+// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
+// ┃ Copyright (c) 2017, the Perspective Authors. ┃
+// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
+// ┃ This file is part of the Perspective library, distributed under the terms ┃
+// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
+// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
+
+import { ShaderRegistry } from "./shader-registry";
+import { BufferPool } from "./buffer-pool";
+
+export class WebGLContextManager {
+ private _canvas: HTMLCanvasElement;
+ private _gl: WebGL2RenderingContext | WebGLRenderingContext;
+ private _isWebGL2: boolean;
+ private _shaders: ShaderRegistry;
+ private _buffers: BufferPool;
+ private _uploadedCount = 0;
+
+ constructor(canvas: HTMLCanvasElement) {
+ this._canvas = canvas;
+ const gl2 = canvas.getContext("webgl2", {
+ antialias: true,
+ alpha: true,
+ premultipliedAlpha: false,
+ });
+ if (gl2) {
+ this._gl = gl2;
+ this._isWebGL2 = true;
+ } else {
+ const gl1 = canvas.getContext("webgl", {
+ antialias: true,
+ alpha: true,
+ premultipliedAlpha: false,
+ });
+ if (!gl1) {
+ throw new Error("WebGL is not supported");
+ }
+ this._gl = gl1;
+ this._isWebGL2 = false;
+ }
+
+ this._shaders = new ShaderRegistry(this._gl);
+ this._buffers = new BufferPool(this._gl);
+
+ // Handle context loss
+ canvas.addEventListener("webglcontextlost", (e) => {
+ e.preventDefault();
+ });
+
+ canvas.addEventListener("webglcontextrestored", () => {
+ this._shaders.releaseAll();
+ this._buffers.releaseAll();
+ this._shaders = new ShaderRegistry(this._gl);
+ this._buffers = new BufferPool(this._gl);
+ this._uploadedCount = 0;
+ });
+ }
+
+ get gl(): WebGL2RenderingContext | WebGLRenderingContext {
+ return this._gl;
+ }
+
+ get isWebGL2(): boolean {
+ return this._isWebGL2;
+ }
+
+ get shaders(): ShaderRegistry {
+ return this._shaders;
+ }
+
+ get bufferPool(): BufferPool {
+ return this._buffers;
+ }
+
+ get uploadedCount(): number {
+ return this._uploadedCount;
+ }
+
+ set uploadedCount(count: number) {
+ this._uploadedCount = count;
+ }
+
+ resize(): void {
+ const dpr = window.devicePixelRatio || 1;
+ const rect = this._canvas.getBoundingClientRect();
+ const width = Math.round(rect.width * dpr);
+ const height = Math.round(rect.height * dpr);
+
+ if (this._canvas.width !== width || this._canvas.height !== height) {
+ this._canvas.width = width;
+ this._canvas.height = height;
+ this._gl.viewport(0, 0, width, height);
+ }
+ }
+
+ clear(): void {
+ this._gl.clearColor(0, 0, 0, 0);
+ this._gl.clear(this._gl.COLOR_BUFFER_BIT | this._gl.DEPTH_BUFFER_BIT);
+ this._uploadedCount = 0;
+ }
+
+ ensureBufferCapacity(totalRows: number): void {
+ this._buffers.ensureCapacity(totalRows);
+ }
+
+ destroy(): void {
+ this._buffers.releaseAll();
+ this._shaders.releaseAll();
+ const ext = this._gl.getExtension("WEBGL_lose_context");
+ if (ext) {
+ ext.loseContext();
+ }
+ }
+}
diff --git a/packages/viewer-webgl/src/ts/webgl/shader-registry.ts b/packages/viewer-webgl/src/ts/webgl/shader-registry.ts
new file mode 100644
index 0000000000..e9257106cd
--- /dev/null
+++ b/packages/viewer-webgl/src/ts/webgl/shader-registry.ts
@@ -0,0 +1,75 @@
+// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
+// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
+// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
+// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
+// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
+// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
+// ┃ Copyright (c) 2017, the Perspective Authors. ┃
+// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
+// ┃ This file is part of the Perspective library, distributed under the terms ┃
+// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
+// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
+
+type GL = WebGL2RenderingContext | WebGLRenderingContext;
+
+export class ShaderRegistry {
+ private _gl: GL;
+ private _programs: Map = new Map();
+
+ constructor(gl: GL) {
+ this._gl = gl;
+ }
+
+ getOrCreate(name: string, vertSrc: string, fragSrc: string): WebGLProgram {
+ let program = this._programs.get(name);
+ if (program) return program;
+
+ const gl = this._gl;
+
+ const vert = gl.createShader(gl.VERTEX_SHADER)!;
+ gl.shaderSource(vert, vertSrc);
+ gl.compileShader(vert);
+ if (!gl.getShaderParameter(vert, gl.COMPILE_STATUS)) {
+ const info = gl.getShaderInfoLog(vert);
+ gl.deleteShader(vert);
+ throw new Error(`Vertex shader compile error [${name}]: ${info}`);
+ }
+
+ const frag = gl.createShader(gl.FRAGMENT_SHADER)!;
+ gl.shaderSource(frag, fragSrc);
+ gl.compileShader(frag);
+ if (!gl.getShaderParameter(frag, gl.COMPILE_STATUS)) {
+ const info = gl.getShaderInfoLog(frag);
+ gl.deleteShader(vert);
+ gl.deleteShader(frag);
+ throw new Error(`Fragment shader compile error [${name}]: ${info}`);
+ }
+
+ program = gl.createProgram()!;
+ gl.attachShader(program, vert);
+ gl.attachShader(program, frag);
+ gl.linkProgram(program);
+
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
+ const info = gl.getProgramInfoLog(program);
+ gl.deleteProgram(program);
+ gl.deleteShader(vert);
+ gl.deleteShader(frag);
+ throw new Error(`Shader link error [${name}]: ${info}`);
+ }
+
+ // Shaders can be deleted after linking
+ gl.deleteShader(vert);
+ gl.deleteShader(frag);
+
+ this._programs.set(name, program);
+ return program;
+ }
+
+ releaseAll(): void {
+ for (const program of this._programs.values()) {
+ this._gl.deleteProgram(program);
+ }
+ this._programs.clear();
+ }
+}
diff --git a/packages/viewer-webgl/tsconfig.json b/packages/viewer-webgl/tsconfig.json
new file mode 100644
index 0000000000..ac3b3caa92
--- /dev/null
+++ b/packages/viewer-webgl/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "compilerOptions": {
+ "module": "ESNext",
+ "target": "ESNext",
+ "declaration": true,
+ "emitDeclarationOnly": true,
+ "outDir": "./dist/esm",
+ "rootDir": "./src/ts",
+ "moduleResolution": "bundler",
+ "skipLibCheck": true,
+ "resolveJsonModule": true
+ },
+ "include": ["./src/ts/**/*", "./types.d.ts"]
+}
diff --git a/packages/viewer-webgl/types.d.ts b/packages/viewer-webgl/types.d.ts
new file mode 100644
index 0000000000..208ab4991b
--- /dev/null
+++ b/packages/viewer-webgl/types.d.ts
@@ -0,0 +1,21 @@
+// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
+// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
+// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
+// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
+// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
+// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
+// ┃ Copyright (c) 2017, the Perspective Authors. ┃
+// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
+// ┃ This file is part of the Perspective library, distributed under the terms ┃
+// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
+// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
+
+declare module "*.css" {
+ const value: string;
+ export default value;
+}
+
+declare module "*.glsl" {
+ const value: string;
+ export default value;
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5552db5de0..5e74133491 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -238,6 +238,9 @@ importers:
'@perspective-dev/viewer-openlayers':
specifier: workspace:^
version: link:packages/viewer-openlayers
+ '@perspective-dev/viewer-webgl':
+ specifier: workspace:^
+ version: link:packages/viewer-webgl
'@perspective-dev/workspace':
specifier: workspace:^
version: link:packages/workspace
@@ -646,6 +649,34 @@ importers:
specifier: 'catalog:'
version: 6.4.1(@types/node@24.9.1)(jiti@1.21.7)(less@4.4.2)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)
+ examples/webgl-example:
+ dependencies:
+ '@perspective-dev/client':
+ specifier: 'workspace:'
+ version: link:../../rust/perspective-js
+ '@perspective-dev/viewer':
+ specifier: 'workspace:'
+ version: link:../../rust/perspective-viewer
+ '@perspective-dev/viewer-d3fc':
+ specifier: 'workspace:'
+ version: link:../../packages/viewer-d3fc
+ '@perspective-dev/viewer-datagrid':
+ specifier: 'workspace:'
+ version: link:../../packages/viewer-datagrid
+ '@perspective-dev/viewer-webgl':
+ specifier: 'workspace:'
+ version: link:../../packages/viewer-webgl
+ superstore-arrow:
+ specifier: 'catalog:'
+ version: 3.2.0
+ devDependencies:
+ esbuild:
+ specifier: 'catalog:'
+ version: 0.25.11
+ http-server:
+ specifier: 'catalog:'
+ version: 14.1.1
+
examples/webpack-example:
dependencies:
'@perspective-dev/client':
@@ -923,6 +954,28 @@ importers:
specifier: 'catalog:'
version: 1.32.0
+ packages/viewer-webgl:
+ dependencies:
+ '@perspective-dev/client':
+ specifier: 'workspace:'
+ version: link:../../rust/perspective-js
+ '@perspective-dev/viewer':
+ specifier: 'workspace:'
+ version: link:../../rust/perspective-viewer
+ apache-arrow:
+ specifier: 'catalog:'
+ version: 17.0.0
+ devDependencies:
+ '@perspective-dev/esbuild-plugin':
+ specifier: 'workspace:'
+ version: link:../../tools/esbuild-plugin
+ lightningcss:
+ specifier: 'catalog:'
+ version: 1.32.0
+ typescript:
+ specifier: 'catalog:'
+ version: 5.9.3
+
packages/workspace:
dependencies:
'@lumino/algorithm':
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index bfcbd257da..a49713a772 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -7,6 +7,7 @@ packages:
- "packages/viewer-datagrid"
- "packages/viewer-d3fc"
- "packages/viewer-openlayers"
+ - "packages/viewer-webgl"
- "packages/workspace"
- "packages/jupyterlab"
- "packages/react"
diff --git a/rust/perspective-viewer/src/rust/js/plugin.rs b/rust/perspective-viewer/src/rust/js/plugin.rs
index aab27a5588..13bcdb8b53 100644
--- a/rust/perspective-viewer/src/rust/js/plugin.rs
+++ b/rust/perspective-viewer/src/rust/js/plugin.rs
@@ -111,6 +111,34 @@ extern "C" {
#[wasm_bindgen(method, catch)]
pub async fn resize(this: &JsPerspectiveViewerPlugin) -> ApiResult;
+ #[wasm_bindgen(method, getter)]
+ pub fn supports_streaming(this: &JsPerspectiveViewerPlugin) -> bool;
+
+ #[wasm_bindgen(method, catch)]
+ pub fn draw_streaming(
+ this: &JsPerspectiveViewerPlugin,
+ view: perspective_js::View,
+ column_limit: Option,
+ row_limit: Option,
+ ) -> ApiResult;
+
+ #[wasm_bindgen(method, catch)]
+ pub fn update_streaming(
+ this: &JsPerspectiveViewerPlugin,
+ view: perspective_js::View,
+ column_limit: Option,
+ row_limit: Option,
+ ) -> ApiResult;
+
+ #[derive(Clone)]
+ pub type JsStreamingRenderHandle;
+
+ #[wasm_bindgen(method, catch)]
+ pub async fn next(this: &JsStreamingRenderHandle) -> ApiResult;
+
+ #[wasm_bindgen(method)]
+ pub fn cancel(this: &JsStreamingRenderHandle);
+
}
impl JsPerspectiveViewerPlugin {
diff --git a/rust/perspective-viewer/src/rust/renderer.rs b/rust/perspective-viewer/src/rust/renderer.rs
index e7cecf80ba..26662b39cd 100644
--- a/rust/perspective-viewer/src/rust/renderer.rs
+++ b/rust/perspective-viewer/src/rust/renderer.rs
@@ -76,6 +76,7 @@ pub struct RendererMutData {
timer: MovingWindowRenderTimer,
selection: Option,
pending_plugin: Option,
+ active_streaming_handle: Option,
}
/// The state object responsible for the active [`JsPerspectiveViewerPlugin`].
@@ -123,6 +124,7 @@ impl Renderer {
selection: None,
timer: MovingWindowRenderTimer::default(),
pending_plugin: None,
+ active_streaming_handle: None,
}),
draw_lock: Default::default(),
plugin_changed: Default::default(),
@@ -405,16 +407,78 @@ impl Renderer {
}
let viewer_elem = &self.0.borrow().viewer_elem.clone();
- let result = if is_update {
- let task = plugin.update(view.clone().into(), limits.max_cols, limits.max_rows, false);
- activate_plugin(viewer_elem, &plugin, task).await
+
+ if plugin.supports_streaming() {
+ // Cancel any in-flight streaming render from a prior draw.
+ if let Some(old_handle) = self.0.borrow_mut().active_streaming_handle.take() {
+ old_handle.cancel();
+ }
+
+ // Append the plugin to the DOM (with opacity 0) before calling
+ // draw_streaming so that the element has layout dimensions when
+ // the first chunk renders. activate_plugin normally does this,
+ // but streaming plugins need it earlier.
+ let html_plugin = plugin.unchecked_ref::();
+ if html_plugin.parent_node().is_none() {
+ html_plugin.style().set_property("opacity", "0.5")?;
+ viewer_elem.append_child(html_plugin)?;
+ }
+
+ let handle = if is_update {
+ plugin.update_streaming(view.clone().into(), limits.max_cols, limits.max_rows)?
+ } else {
+ plugin.draw_streaming(view.clone().into(), limits.max_cols, limits.max_rows)?
+ };
+
+ // Store the handle so it can be cancelled if a new draw arrives.
+ self.0.borrow_mut().active_streaming_handle = Some(handle.clone());
+
+ // Render the first chunk, then reveal (opacity transition).
+ let first_result = handle.next().await?;
+ html_plugin.style().set_property("opacity", "1")?;
+
+ // Render remaining chunks if the first was not complete.
+ if !first_result.is_falsy() {
+ let is_complete = js_sys::Reflect::get(&first_result, &"isComplete".into())
+ .unwrap_or(JsValue::FALSE)
+ .as_bool()
+ .unwrap_or(false);
+
+ if !is_complete {
+ loop {
+ let chunk = handle.next().await?;
+ if chunk.is_null() || chunk.is_undefined() {
+ break;
+ }
+
+ let done = js_sys::Reflect::get(&chunk, &"isComplete".into())
+ .unwrap_or(JsValue::FALSE)
+ .as_bool()
+ .unwrap_or(false);
+
+ if done {
+ break;
+ }
+ }
+ }
+ }
+
+ // Clear the stored handle now that streaming is complete.
+ self.0.borrow_mut().active_streaming_handle = None;
} else {
- let task = plugin.draw(view.clone().into(), limits.max_cols, limits.max_rows, false);
- activate_plugin(viewer_elem, &plugin, task).await
- };
+ let result = if is_update {
+ let task =
+ plugin.update(view.clone().into(), limits.max_cols, limits.max_rows, false);
+ activate_plugin(viewer_elem, &plugin, task).await
+ } else {
+ let task =
+ plugin.draw(view.clone().into(), limits.max_cols, limits.max_rows, false);
+ activate_plugin(viewer_elem, &plugin, task).await
+ };
- if let Err(error) = result.ignore_view_delete() {
- tracing::warn!("{}", error);
+ if let Err(error) = result.ignore_view_delete() {
+ tracing::warn!("{}", error);
+ }
}
remove_inactive_plugin(
diff --git a/rust/perspective-viewer/src/ts/perspective-viewer.ts b/rust/perspective-viewer/src/ts/perspective-viewer.ts
index 0b789f6bc4..1e91c5848d 100644
--- a/rust/perspective-viewer/src/ts/perspective-viewer.ts
+++ b/rust/perspective-viewer/src/ts/perspective-viewer.ts
@@ -33,6 +33,7 @@
export { IPerspectiveViewerPlugin } from "./plugin";
export { HTMLPerspectiveViewerPluginElement } from "./plugin";
+export type { StreamingRenderHandle, RenderChunk } from "./plugin";
export type * from "./extensions.ts";
export { PerspectiveSelectDetail } from "./extensions.ts";
diff --git a/rust/perspective-viewer/src/ts/plugin.ts b/rust/perspective-viewer/src/ts/plugin.ts
index 4473b2b66f..aec678df4b 100644
--- a/rust/perspective-viewer/src/ts/plugin.ts
+++ b/rust/perspective-viewer/src/ts/plugin.ts
@@ -14,6 +14,29 @@ import type { View } from "@perspective-dev/client";
// import type * as perspective from "@perspective-dev/client";
+/**
+ * Metadata returned by each iteration of a streaming render.
+ */
+export interface RenderChunk {
+ /** True when this is the first chunk (axes/layout are ready to show). */
+ isFirst: boolean;
+ /** True when all chunks have been rendered. */
+ isComplete: boolean;
+ /** Progress as a value between 0.0 and 1.0. */
+ progress: number;
+}
+
+/**
+ * A handle returned by `drawStreaming()` / `updateStreaming()` that the
+ * renderer uses to drive chunk-by-chunk rendering and cancel in-flight work.
+ */
+export interface StreamingRenderHandle {
+ /** Render the next chunk. Resolves with chunk metadata, or null when done. */
+ next(): Promise;
+ /** Cancel all in-flight work and clean up partial state. */
+ cancel(): void;
+}
+
/**
* The `IPerspectiveViewerPlugin` interface defines the necessary API for a
* `` plugin, which also must be an `HTMLElement` via the
@@ -186,7 +209,38 @@ export interface IPerspectiveViewerPlugin {
/**
* Free any resources acquired by this plugin and prepare to be deleted.
*/
- delete(): void;
+ delete(): Promise;
+
+ /**
+ * Whether this plugin supports the streaming render protocol.
+ * When true, the renderer will call `drawStreaming()` / `updateStreaming()`
+ * instead of `draw()` / `update()`.
+ */
+ get supports_streaming(): boolean;
+
+ /**
+ * Optional streaming draw. Returns a handle that the renderer uses to
+ * drive chunk iteration and cancel in-flight work. The renderer calls
+ * `next()` repeatedly; after the first chunk resolves the plugin becomes
+ * visible (opacity transition). If not implemented, the renderer falls
+ * back to `draw()`.
+ */
+ draw_streaming?(
+ view: View,
+ end_col?: number,
+ end_row?: number,
+ ): StreamingRenderHandle;
+
+ /**
+ * Streaming variant of `update()`. Same semantics as `draw_streaming()`
+ * but called when only the underlying data has changed (not the
+ * `ViewConfig`).
+ */
+ update_streaming?(
+ view: View,
+ end_col?: number,
+ end_row?: number,
+ ): StreamingRenderHandle;
}
/**
@@ -276,4 +330,8 @@ export class HTMLPerspectiveViewerPluginElement
async delete(): Promise {
// Not Implemented
}
+
+ get supports_streaming(): boolean {
+ return false;
+ }
}
diff --git a/tools/scripts/setup.mjs b/tools/scripts/setup.mjs
index 06280e3422..63b805cd34 100644
--- a/tools/scripts/setup.mjs
+++ b/tools/scripts/setup.mjs
@@ -183,6 +183,11 @@ async function focus_package() {
name: "@perspective-dev/viewer-openlayers",
value: "viewer-openlayers",
},
+ {
+ key: "g",
+ name: "@perspective-dev/viewer-webgl",
+ value: "viewer-webgl",
+ },
{
key: "w",
name: "@perspective-dev/workspace",