From f93e251ffb54f166e8cc6ce5a1770e79d50dcf27 Mon Sep 17 00:00:00 2001 From: Timothy Bess Date: Fri, 10 Apr 2026 15:36:18 -0400 Subject: [PATCH] Add WebGL chart plugin with scatter, line, and treemap New viewer-webgl package that renders perspective views on the GPU. Three chart types (scatter, line, treemap), each with custom GLSL shaders, chunked Arrow streaming, and a shared buffer pool for GPU memory reuse. Handles axis/legend/tick layout, gridline rendering, and zoom/pan interaction. Hooks into the viewer's streaming draw lifecycle. Adds the webgl-example app and registers the package in the workspace and setup script. Signed-off-by: Timothy Bess --- examples/webgl-example/build.js | 39 + examples/webgl-example/package.json | 24 + examples/webgl-example/src/index.css | 7 + examples/webgl-example/src/index.html | 17 + examples/webgl-example/src/index.js | 429 ++++ package.json | 2 + packages/viewer-webgl/build.mjs | 56 + packages/viewer-webgl/clean.mjs | 15 + packages/viewer-webgl/package.json | 47 + .../src/css/perspective-viewer-webgl.css | 121 + packages/viewer-webgl/src/ts/charts/chart.ts | 50 + packages/viewer-webgl/src/ts/charts/line.ts | 1073 +++++++++ .../viewer-webgl/src/ts/charts/scatter.ts | 1364 +++++++++++ .../viewer-webgl/src/ts/charts/treemap.ts | 1994 +++++++++++++++++ .../viewer-webgl/src/ts/data/arrow-reader.ts | 125 ++ .../src/ts/data/chunk-iterator.ts | 67 + packages/viewer-webgl/src/ts/index.ts | 58 + .../src/ts/interaction/spatial-grid.ts | 96 + .../src/ts/interaction/zoom-controller.ts | 260 +++ packages/viewer-webgl/src/ts/layout/axes.ts | 222 ++ packages/viewer-webgl/src/ts/layout/legend.ts | 140 ++ .../viewer-webgl/src/ts/layout/plot-layout.ts | 141 ++ packages/viewer-webgl/src/ts/layout/ticks.ts | 128 ++ packages/viewer-webgl/src/ts/plugin/charts.ts | 65 + packages/viewer-webgl/src/ts/plugin/plugin.ts | 437 ++++ .../src/ts/shaders/gridline.frag.glsl | 6 + .../src/ts/shaders/gridline.vert.glsl | 6 + .../src/ts/shaders/line.frag.glsl | 16 + .../src/ts/shaders/line.vert.glsl | 44 + .../src/ts/shaders/scatter.frag.glsl | 27 + .../src/ts/shaders/scatter.vert.glsl | 34 + .../src/ts/shaders/treemap.frag.glsl | 7 + .../src/ts/shaders/treemap.vert.glsl | 13 + packages/viewer-webgl/src/ts/utils/css.ts | 34 + .../viewer-webgl/src/ts/webgl/buffer-pool.ts | 94 + .../src/ts/webgl/context-manager.ts | 119 + .../src/ts/webgl/shader-registry.ts | 75 + packages/viewer-webgl/tsconfig.json | 14 + packages/viewer-webgl/types.d.ts | 21 + pnpm-lock.yaml | 53 + pnpm-workspace.yaml | 1 + rust/perspective-viewer/src/rust/js/plugin.rs | 28 + rust/perspective-viewer/src/rust/renderer.rs | 80 +- .../src/ts/perspective-viewer.ts | 1 + rust/perspective-viewer/src/ts/plugin.ts | 60 +- tools/scripts/setup.mjs | 5 + 46 files changed, 7706 insertions(+), 9 deletions(-) create mode 100644 examples/webgl-example/build.js create mode 100644 examples/webgl-example/package.json create mode 100644 examples/webgl-example/src/index.css create mode 100644 examples/webgl-example/src/index.html create mode 100644 examples/webgl-example/src/index.js create mode 100644 packages/viewer-webgl/build.mjs create mode 100644 packages/viewer-webgl/clean.mjs create mode 100644 packages/viewer-webgl/package.json create mode 100644 packages/viewer-webgl/src/css/perspective-viewer-webgl.css create mode 100644 packages/viewer-webgl/src/ts/charts/chart.ts create mode 100644 packages/viewer-webgl/src/ts/charts/line.ts create mode 100644 packages/viewer-webgl/src/ts/charts/scatter.ts create mode 100644 packages/viewer-webgl/src/ts/charts/treemap.ts create mode 100644 packages/viewer-webgl/src/ts/data/arrow-reader.ts create mode 100644 packages/viewer-webgl/src/ts/data/chunk-iterator.ts create mode 100644 packages/viewer-webgl/src/ts/index.ts create mode 100644 packages/viewer-webgl/src/ts/interaction/spatial-grid.ts create mode 100644 packages/viewer-webgl/src/ts/interaction/zoom-controller.ts create mode 100644 packages/viewer-webgl/src/ts/layout/axes.ts create mode 100644 packages/viewer-webgl/src/ts/layout/legend.ts create mode 100644 packages/viewer-webgl/src/ts/layout/plot-layout.ts create mode 100644 packages/viewer-webgl/src/ts/layout/ticks.ts create mode 100644 packages/viewer-webgl/src/ts/plugin/charts.ts create mode 100644 packages/viewer-webgl/src/ts/plugin/plugin.ts create mode 100644 packages/viewer-webgl/src/ts/shaders/gridline.frag.glsl create mode 100644 packages/viewer-webgl/src/ts/shaders/gridline.vert.glsl create mode 100644 packages/viewer-webgl/src/ts/shaders/line.frag.glsl create mode 100644 packages/viewer-webgl/src/ts/shaders/line.vert.glsl create mode 100644 packages/viewer-webgl/src/ts/shaders/scatter.frag.glsl create mode 100644 packages/viewer-webgl/src/ts/shaders/scatter.vert.glsl create mode 100644 packages/viewer-webgl/src/ts/shaders/treemap.frag.glsl create mode 100644 packages/viewer-webgl/src/ts/shaders/treemap.vert.glsl create mode 100644 packages/viewer-webgl/src/ts/utils/css.ts create mode 100644 packages/viewer-webgl/src/ts/webgl/buffer-pool.ts create mode 100644 packages/viewer-webgl/src/ts/webgl/context-manager.ts create mode 100644 packages/viewer-webgl/src/ts/webgl/shader-registry.ts create mode 100644 packages/viewer-webgl/tsconfig.json create mode 100644 packages/viewer-webgl/types.d.ts 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",