chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:25:07 +08:00
commit a26e856398
1681 changed files with 296950 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
# Perspective CLI
```bash
Usage: perspective [options] [command]
A convenient command-line client for Perspective.js. Can convert between Perspective supported format, or host a local web server.
Options:
-V, --version output the version number
-h, --help output usage information
Commands:
convert [options] [filename] Convert a file into a new format. Reads from STDIN if no filename is provided
Options:
-f, --format <format> Which output format to use: arrow, csv, columns, json.
-o, --output <filename> Filename to write to. If not supplied, writes to STDOUT
-h, --help output usage information
host [options] [filename] Host a file on a local Websocket/HTTP server using a server-side Perspective. Reads from STDIN if no filename is provided
Options:
-p, --port <port> Which port to bind to (default: 8080)
-o, --open Open a browser automagically
-h, --help output usage information
```
+36
View File
@@ -0,0 +1,36 @@
{
"name": "@perspective-dev/cli",
"version": "4.5.2",
"description": "Perspective.js CLI",
"main": "src/js/index.js",
"publishConfig": {
"access": "public"
},
"type": "module",
"files": [
"src/**/*",
"perspective"
],
"types": "index.d.ts",
"repository": {
"type": "git",
"url": "https://github.com/perspective-dev/perspective"
},
"author": "",
"license": "Apache-2.0",
"bin": {
"perspective": "perspective"
},
"dependencies": {
"@perspective-dev/client": "workspace:",
"@perspective-dev/viewer": "workspace:",
"@perspective-dev/viewer-charts": "workspace:",
"@perspective-dev/viewer-datagrid": "workspace:",
"@perspective-dev/workspace": "workspace:",
"commander": "catalog:",
"puppeteer": "catalog:"
},
"devDependencies": {
"@perspective-dev/test": "workspace:"
}
}
+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/env node --max-old-space-size=8192
require("@perspective-dev/cli");
+55
View File
@@ -0,0 +1,55 @@
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=no" />
<title>Perspective CLI</title>
<link rel="stylesheet" crossorigin="anonymous" href="/node_modules/@perspective-dev/viewer/dist/css/pro.css" />
<link rel="stylesheet" crossorigin="anonymous" href="/node_modules/@perspective-dev/workspace/dist/css/pro.css" />
<script type="module" src="/node_modules/@perspective-dev/workspace/dist/cdn/perspective-workspace.js"></script>
<script type="module" src="/node_modules/@perspective-dev/viewer-datagrid/dist/cdn/perspective-viewer-datagrid.js"></script>
<script type="module" src="/node_modules/@perspective-dev/viewer-charts/dist/cdn/perspective-viewer-charts.js"></script>
<style>
perspective-workspace {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
@media (max-width: 600px) {
html {
overflow: hidden;
}
body {
position: fixed;
height: 100%;
width: 100%;
margin: 0;
overflow: hidden;
touch-action: none;
}
}
</style>
</head>
<body>
<perspective-workspace id="view1">
<perspective-viewer table="data"></perspective-viewer>
</perspective-workspace>
<script type="module">
import "/node_modules/@perspective-dev/viewer/dist/cdn/perspective-viewer.js";
import perspective from "/node_modules/@perspective-dev/client/dist/cdn/perspective.js";
// const WORKER = worker();
var elem = document.getElementById("view1");
var worker = await perspective.websocket(location.origin.replace("http", "ws"));
elem.tables.set("data", worker.open_table("data_source_one"));
</script>
</body>
</html>
+178
View File
@@ -0,0 +1,178 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { WebSocketServer, table } from "@perspective-dev/client";
import { read_stdin, open_browser } from "./utils.js";
import * as fs from "node:fs";
import * as path from "node:path";
import { program } from "commander";
import puppeteer from "puppeteer";
import { createRequire } from "node:module";
import * as url from "node:url";
const __dirname = url.fileURLToPath(new URL(".", import.meta.url)).slice(0, -1);
const _require = createRequire(import.meta.url);
/**
* Convert data from one format to another.
*
* @param {*} filename
* @param {*} options
*/
async function convert(filename, options) {
let file;
if (filename) {
file = fs.readFileSync(filename).toString();
} else {
file = await read_stdin();
}
try {
file = JSON.parse(file);
} catch {}
let tbl = await table(file);
let view = await tbl.view();
let out;
options.format = options.format || "arrow";
if (options.format === "csv") {
out = await view.to_csv();
} else if (options.format === "columns") {
out = JSON.stringify(await view.to_columns());
} else if (options.format === "json") {
out = JSON.stringify(await view.to_json());
} else {
out = await view.to_arrow();
}
if (options.format === "arrow") {
if (options.output) {
fs.writeFileSync(options.output, Buffer.from(out), "binary");
} else {
console.log(Buffer.from(out).toString());
}
} else {
if (options.output) {
fs.writeFileSync(options.output, out);
} else {
console.log(out);
}
}
view.delete();
tbl.delete();
}
/**
* Host a Perspective on a web server.
*
* @param {*} filename
* @param {*} options
*/
export async function host(filename, options) {
let files = [process.cwd(), path.join(__dirname, "..", "html")];
if (options.assets) {
files = [options.assets, ...files];
}
const server = new WebSocketServer({
assets: files,
port: options.port,
});
let file;
if (filename) {
file = await table(fs.readFileSync(filename).toString(), {
name: "data_source_one",
});
} else {
file = await read_stdin();
}
if (options.open) {
const browser = await puppeteer.launch({
headless: false,
defaultViewport: null,
args: [
"--incognito",
"--noerrdialogs",
"--disable-translate",
"--no-first-run",
"--fast",
"--fast-start",
"--disable-infobars",
"--window-size=1200,800",
"--disable-features=TranslateUI",
"--disk-cache-dir=/dev/null",
`--app=http://localhost:${options.port}/`,
],
});
const pages = await browser.pages();
const page = pages[0];
page.on("close", () => {
browser.close();
process.exit(0);
});
}
return server;
}
program
.version(
JSON.parse(
fs
.readFileSync(path.join(__dirname, "..", "..", "package.json"))
.toString(),
).version,
)
.description(
"A convenient command-line client for Perspective.js. Can convert between Perspective supported format, or host a local web server.",
)
.action(() => program.help());
program
.command("convert [filename]")
.description(
"Convert a file into a new format. Reads from STDIN if no filename is provided.",
)
.option(
"-f, --format <format>",
"Which output format to use: arrow, csv, columns, json.",
/^(arrow|json|columns|csv)$/i,
"arrow",
)
.option(
"-o, --output <filename>",
"Filename to write to. If not supplied, writes to STDOUT.",
)
.action(convert);
program
.command("host [filename]")
.description(
"Host a file on a local Websocket/HTTP server using a server-side Perspective. Reads from STDIN if no filename is provided",
)
.option(
"-p, --port <port>",
"Which port to bind to.",
(x) => parseInt(x),
8080,
)
.option("-a, --assets <path>", "Host from a working directory")
.option("-o, --open", "Open a browser automagically.")
.action(host);
if (_require.main.path.endsWith("perspective-cli")) {
if (!process.argv.slice(2).length) {
program.help();
} else {
program.parse(process.argv);
}
}
+74
View File
@@ -0,0 +1,74 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { exec } from "child_process";
import { table } from "@perspective-dev/client";
const OPEN = (port) => `
if which xdg-open > /dev/null
then
xdg-open http://localhost:${port}/
elif which gnome-open > /dev/null
then
gnome-open http://localhost:${port}/
elif which open > /dev/null
then
open http://localhost:${port}/
fi`;
export function infer_table(buffer) {
if (buffer.slice(0, 6).toString() === "ARROW1") {
console.log("Loaded Arrow");
return table(buffer.buffer);
} else {
let text = buffer.toString();
try {
let json = JSON.parse(text);
console.log("Loaded JSON");
return table(json);
} catch (e) {
console.log("Loaded CSV");
return table(text);
}
}
}
export const read_stdin = function read_stdin() {
const ret = [];
let len = 0;
return new Promise((resolve) => {
process.stdin
.on("readable", function () {
let chunk;
while ((chunk = process.stdin.read())) {
ret.push(Buffer.from(chunk));
len += chunk.length;
}
})
.on("end", function () {
const buffer = Buffer.concat(ret, len);
resolve(infer_table(buffer));
});
});
};
export const execute = function execute(command, callback) {
exec(command, function (error, stdout) {
if (callback) {
callback(stdout);
}
});
};
export const open_browser = function open_browser(port) {
module.exports.execute(OPEN(port), console.log);
};
+4
View File
@@ -0,0 +1,4 @@
x,y
1,2
3,4
5,6
1 x y
2 1 2
3 3 4
4 5 6
+51
View File
@@ -0,0 +1,51 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { test, expect } from "@perspective-dev/test";
import * as path from "node:path";
import { host } from "../../src/js/index.js";
import * as url from "url";
const __dirname = url.fileURLToPath(new URL(".", import.meta.url)).slice(0, -1);
test.describe("CLI", function () {
let server, port;
test.beforeAll(async () => {
const options = { port: 0 };
server = await host(path.join(__dirname, "../csv/test.csv"), options);
port = server._server.address().port;
});
test.afterAll(async (options) => {
await server.close();
});
test("Tests something", async ({ page }) => {
await page.goto(`http://localhost:${port}/`);
await page.waitForSelector(
"perspective-viewer perspective-viewer-datagrid",
);
const json = await page.evaluate(async function () {
const viewer = document.querySelector("perspective-viewer");
await viewer.flush();
const view = await viewer.getView();
return await view.to_json();
});
expect(json).toEqual([
{ x: 1, y: 2 },
{ x: 3, y: 4 },
{ x: 5, y: 6 },
]);
});
});
+16
View File
@@ -0,0 +1,16 @@
## Build
As of March 2025, the build process is a little different from the jupyterlab
extension cookiecutter template. The build works as follows:
### labextension
1. esbuild produces a bundle in `dist/esm` with `src/index.js` as its entrypoint
2. `jupyter labextension build` packages that bundle, which is read from the
`main` field in `package.json`, into `dist/cjs`
3. the `dist/cjs` output is copied to the perspective-python package's `.data`
directory.
This means running `jupyter labextension build` or `watch` out-of-band from the
build script won't rebuild the labextension on its own; the `labextension` step
runs on the output of the esbuild step.
+21
View File
@@ -0,0 +1,21 @@
# Perspective JupyterLab Extension
This extension allows in-lining perspective based charts in jupyterlab
notebooks.
[Examples](https://github.com/perspective-dev/perspective/tree/master/examples/jupyter-notebooks)
## Installation
### From npm
```bash
jupyter labextension install @jupyter-widgets/jupyterlab-manager
jupyter labextension install @perspective-dev/jupyterlab
```
### PIP
```bash
pip install perspective-python
```
+142
View File
@@ -0,0 +1,142 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { WasmPlugin } from "@perspective-dev/esbuild-plugin/wasm.js";
import { WorkerPlugin } from "@perspective-dev/esbuild-plugin/worker.js";
import { build } from "@perspective-dev/esbuild-plugin/build.js";
import * as path from "node:path";
import { bundleAsync as bundleCss, composeVisitors } from "lightningcss";
import * as fs from "node:fs";
import * as url from "node:url";
// import { createRequire } from "node:module";
import { execSync } from "node:child_process";
import {
resolveNPM,
inlineUrlVisitor,
} from "@perspective-dev/viewer/tools.mjs";
// const _require = createRequire(import.meta.url);
const __dirname = url.fileURLToPath(new URL(".", import.meta.url)).slice(0, -1);
const NBEXTENSION_PATH = path.resolve(
__dirname,
"..",
"..",
"python",
"perspective",
"perspective",
"nbextension",
"static",
);
const TEST_BUILD = {
entryPoints: ["src/js/psp_widget.js"],
define: {
global: "window",
},
plugins: [WasmPlugin(true), WorkerPlugin({ inline: true })],
globalName: "PerspectiveLumino",
format: "esm",
loader: {
".html": "text",
".ttf": "file",
".css": "text",
},
outfile: "dist/esm/lumino.js",
};
const LAB_BUILD = {
entryPoints: ["src/js/index.js"],
define: {
global: "window",
},
plugins: [WasmPlugin(true), WorkerPlugin({ inline: true })],
external: ["@jupyter*", "@lumino*"],
format: "esm",
loader: {
".css": "text",
".html": "text",
".ttf": "file",
},
outfile: "dist/esm/perspective-jupyterlab.js",
};
const NB_BUILDS = [
// {
// entryPoints: ["src/js/notebook/extension.js"],
// define: {
// global: "window",
// },
// plugins: [
// WasmPlugin(true),
// WorkerPlugin({ inline: true }),
// AMDLoader([]),
// ],
// loader: {
// ".ttf": "file",
// ".css": "text",
// },
// external: ["@jupyter*", "@lumino*"],
// format: "cjs",
// outfile: path.join(NBEXTENSION_PATH, "extension.js"),
// },
// {
// entryPoints: ["src/js/notebook/index.js"],
// define: {
// global: "window",
// },
// plugins: [
// WasmPlugin(true),
// WorkerPlugin({ inline: true }),
// AMDLoader(["@jupyter-widgets/base"]),
// ],
// external: ["@jupyter*"],
// format: "cjs",
// loader: {
// ".ttf": "file",
// ".css": "text",
// },
// outfile: path.join(NBEXTENSION_PATH, "index.js"),
// },
];
const IS_TEST = process.argv.some((x) => x === "--test");
const BUILD = IS_TEST
? [LAB_BUILD, ...NB_BUILDS, TEST_BUILD]
: [LAB_BUILD, ...NB_BUILDS];
async function build_all() {
fs.mkdirSync("dist/css", { recursive: true });
const filename = path.resolve(__dirname, "src/css/index.css");
const { code } = await bundleCss({
filename,
minify: true,
visitor: inlineUrlVisitor(filename),
resolver: resolveNPM(import.meta.url),
});
fs.writeFileSync("dist/css/perspective-jupyterlab.css", code);
await Promise.all(BUILD.map(build)).catch(() => process.exit(1));
fs.cpSync("src/css", "dist/css/src", { recursive: true });
execSync("jupyter labextension build .", {
stdio: "inherit",
});
const pkg = JSON.parse(fs.readFileSync("../../package.json").toString());
const labext_dest = `../../rust/perspective-python/perspective_python-${pkg.version}.data/data/share/jupyter/labextensions/@perspective-dev/jupyterlab`;
fs.cpSync("dist/cjs", labext_dest, { recursive: true });
if (IS_TEST) {
fs.cpSync("test/arrow", "dist/esm", { recursive: true });
}
}
build_all();
+24
View File
@@ -0,0 +1,24 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 });
fs.rmSync("lib", { recursive: true, force: true });
fs.rmSync("../../rust/perspective-python/perspective/labextension", {
recursive: true,
force: true,
});
fs.rmSync("../../rust/perspective-python/perspective/nbextension/static", {
recursive: true,
force: true,
});
+5
View File
@@ -0,0 +1,5 @@
{
"packageManager": "python",
"packageName": "perspective-python",
"uninstallInstructions": "Use your Python package manager (pip, conda, etc.) to uninstall the package perspective-python"
}
+65
View File
@@ -0,0 +1,65 @@
{
"name": "@perspective-dev/jupyterlab",
"version": "4.5.2",
"description": "A Jupyterlab extension for the Perspective library, designed to be used with perspective-python.",
"files": [
"dist/**/*",
"src/**/*"
],
"type": "commonjs",
"main": "dist/esm/perspective-jupyterlab.js",
"style": "dist/css/perspective-jupyterlab.css",
"directories": {
"dist": "dist/"
},
"license": "Apache-2.0",
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "node ./build.mjs",
"clean": "node ./clean.mjs",
"test:jupyter": "__JUPYTERLAB_PORT__=6538 npx playwright test --config ../../tools/test/playwright.config.ts -- --jupyter",
"test:jupyter:build": "node ./build.mjs --test"
},
"dependencies": {
"@perspective-dev/viewer-charts": "workspace:",
"@perspective-dev/viewer-datagrid": "workspace:",
"@perspective-dev/viewer": "workspace:",
"@perspective-dev/client": "workspace:",
"@perspective-dev/server": "workspace:",
"@jupyter-widgets/base": ">2 <5",
"@jupyterlab/application": ">2 <5",
"@lumino/application": "<3",
"@lumino/widgets": "<3"
},
"devDependencies": {
"@perspective-dev/esbuild-plugin": "workspace:",
"@perspective-dev/test": "workspace:",
"@jupyterlab/builder": "^4",
"lightningcss": "catalog:",
"copy-webpack-plugin": "~12",
"zx": "^8.1.8"
},
"jupyterlab": {
"webpackConfig": "./webpack.config.js",
"extension": true,
"outputDir": "./dist/cjs",
"sharedPackages": {
"@jupyter-widgets/base": {
"bundled": false,
"singleton": true
}
},
"discovery": {
"server": {
"base": {
"name": "perspective-python"
},
"managers": [
"pip"
]
}
}
}
}
+54
View File
@@ -0,0 +1,54 @@
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
* ┃ 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-dev/viewer/dist/css/themes.css";
div.PSPContainer {
overflow: auto;
padding-right: 5px;
padding-bottom: 5px;
height: 100%;
width: 100%;
flex: 1;
}
.jp-Notebook div.PSPContainer {
resize: vertical;
}
/* Widget height for Jupyterlab */
.jp-NotebookPanel-notebook div.PSPContainer {
height: 520px;
}
/* Widget height for Jupyter Notebook */
.jupyter-widgets-view div.PSPContainer {
height: 520px;
}
/* Widget height for Voila */
/* Widget height for VS Code */
body[data-voila="voila"] .jp-OutputArea-output div.PSPContainer,
body[data-vscode-theme-id] .cell-output-ipywidget-background div.PSPContainer {
min-height: 520px;
height: 520px;
}
div.PSPContainer perspective-viewer[theme="Pro Light"] {
--psp-plugin--border: 1px solid #e0e0e0;
}
div.PSPContainer perspective-viewer {
display: block;
height: 100%;
}
+43
View File
@@ -0,0 +1,43 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 server_wasm from "@perspective-dev/server/dist/wasm/perspective-server.wasm";
import client_wasm from "@perspective-dev/viewer/dist/wasm/perspective-viewer.wasm";
await Promise.all([
perspective_viewer.init_client(client_wasm),
perspective.init_server(server_wasm),
]);
export * from "./model";
export * from "./version";
export * from "./view";
export * from "./widget";
import "@perspective-dev/viewer-datagrid";
import "@perspective-dev/viewer-charts";
// NOTE: only expose the widget here
import { PerspectiveJupyterPlugin } from "./plugin";
let plugins = [PerspectiveJupyterPlugin];
// Conditionally import renderers if running in jupyterlab only
if (window && window._JUPYTERLAB) {
const { PerspectiveRenderers } = await import("./renderer");
plugins.push(PerspectiveRenderers);
}
export default plugins;
+43
View File
@@ -0,0 +1,43 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { DOMWidgetModel } from "@jupyter-widgets/base";
import { PERSPECTIVE_VERSION } from "./version";
/**
* TODO: document
*/
export class PerspectiveModel extends DOMWidgetModel {
defaults() {
return {
...super.defaults(),
_model_name: PerspectiveModel.model_name,
_model_module: PerspectiveModel.model_module,
_model_module_version: PerspectiveModel.model_module_version,
_view_name: PerspectiveModel.view_name,
_view_module: PerspectiveModel.view_module,
_view_module_version: PerspectiveModel.view_module_version,
};
}
}
PerspectiveModel.serializers = {
...DOMWidgetModel.serializers,
// Add any extra serializers here
};
PerspectiveModel.model_name = "PerspectiveModel";
PerspectiveModel.model_module = "@perspective-dev/jupyterlab";
PerspectiveModel.model_module_version = PERSPECTIVE_VERSION;
PerspectiveModel.view_name = "PerspectiveView";
PerspectiveModel.view_module = "@perspective-dev/jupyterlab";
PerspectiveModel.view_module_version = PERSPECTIVE_VERSION;
@@ -0,0 +1,20 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 THEMES from "../../../dist/css/perspective-jupyterlab.css";
// Export the required load_ipython_extension
exports.load_css = () => {
const style = document.createElement("style");
style.textContent = THEMES;
document.head.appendChild(style);
};
@@ -0,0 +1,32 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
/* eslint-disable no-underscore-dangle */
import { load_css } from "./css";
// This file contains the javascript that is run when the notebook is loaded.
// It contains some requirejs configuration and the `load_ipython_extension`
// which is required for any notebook extension.
if (window.require) {
window.require.config({
map: {
"*": {
"@perspective-dev/jupyterlab":
"nbextensions/@perspective-dev/jupyterlab/index",
},
},
});
}
exports.load_ipython_extension = () => {
load_css();
};
@@ -0,0 +1,26 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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-dev/viewer-datagrid";
import "@perspective-dev/viewer-charts";
import { load_css } from "./css";
import { PerspectiveView } from "../view";
import { PerspectiveModel } from "../model";
exports.PerspectiveModel = PerspectiveModel;
exports.PerspectiveView = PerspectiveView;
// Run if in vs-code
if (window.vscIPyWidgets) {
load_css();
}
+38
View File
@@ -0,0 +1,38 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { IJupyterWidgetRegistry } from "@jupyter-widgets/base";
import { PerspectiveModel } from "./model";
import { PerspectiveView } from "./view";
import { PERSPECTIVE_VERSION } from "./version";
const EXTENSION_ID = "@perspective-dev/jupyterlab";
/**
* PerspectiveJupyterPlugin Defines the Jupyterlab plugin, and registers `PerspectiveModel` and `PerspectiveView`
* to be called on initialization.
*/
export const PerspectiveJupyterPlugin = {
id: EXTENSION_ID,
// @ts-ignore
requires: [IJupyterWidgetRegistry],
activate: (app, registry) => {
registry.registerWidget({
name: EXTENSION_ID,
version: PERSPECTIVE_VERSION,
exports: {
PerspectiveModel: PerspectiveModel,
PerspectiveView: PerspectiveView,
},
});
},
autoStart: true,
};
+199
View File
@@ -0,0 +1,199 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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-dev/viewer";
import { Widget } from "@lumino/widgets";
import { MIME_TYPE, PSP_CLASS, PSP_CONTAINER_CLASS } from "./utils";
let _increment = 0;
/**
* Class for perspective lumino widget.
*
* @class PerspectiveWidget (name) TODO: document
*/
export class PerspectiveWidget extends Widget {
constructor(name = "Perspective", elem, bindingMode) {
super({
node: elem || document.createElement("div"),
});
this.bindingMode = bindingMode;
this._viewer = PerspectiveWidget.createNode(this.node);
this.title.label = name;
this.title.caption = `${name}`;
this.id = `${name}-` + _increment;
_increment += 1;
}
/**********************/
/* Lumino Overrides */
/**********************/
/**
* Lumino: after visible
*
*/
onAfterShow(msg) {
this.viewer.resize(true);
super.onAfterShow(msg);
}
onActivateRequest(msg) {
if (this.isAttached) {
this.viewer.focus();
}
super.onActivateRequest(msg);
}
async toggleConfig() {
await this.viewer.toggleConfig();
}
async save() {
return await this.viewer.save();
}
async restore(config) {
return await this.viewer.restore(config);
}
/**
* Load a `perspective.table` into the viewer.
*
* @param table A `perspective.table` object.
*/
async load(table) {
await this.viewer.load(table);
this._load_complete = true;
}
/**
* Update the viewer with new data.
*
* @param data
*/
async _update(data) {
const table = await this.viewer.getTable(true);
await table.update(data);
}
/**
* Removes all rows from the viewer's table. Does not reset viewer state.
*/
async clear() {
const table = await this.viewer.getTable(true);
await table.clear();
}
/**
* Replaces the data of the viewer's table with new data. New data must
* conform to the schema of the Table.
*
* @param data
*/
async replace(data) {
const table = await this.viewer.getTable(true);
await table.replace(data);
}
/**
* Deletes this element's data and clears it's internal state (but not its
* user state). This (or the underlying `perspective.table`'s equivalent
* method) must be called in order for its memory to be reclaimed.
*/
async delete() {
await this.viewer.delete();
}
/**
* Returns a promise that resolves to the element's edit port ID, used
* internally when edits are made using datagrid in client/server mode.
*/
async getEditPort() {
return await this.viewer.getEditPort();
}
async getTable() {
return await this.viewer.getTable();
}
/***************************************************************************
*
* Getters
*
*/
/**
* Returns the underlying `PerspectiveViewer` instance.
*
* @returns {PerspectiveViewer} The widget's viewer instance.
*/
get viewer() {
return this._viewer;
}
/**
* Returns the name of the widget.
*
* @returns {string} the widget name - "Perspective" if not set by the user.
*/
get name() {
return this.title.label;
}
get selectable() {
return this.viewer.hasAttribute("selectable");
}
set selectable(row_selection) {
if (row_selection) {
this.viewer.setAttribute("selectable", "");
} else {
this.viewer.removeAttribute("selectable");
}
}
static createNode(node) {
node.classList.add("p-Widget");
node.classList.add(PSP_CONTAINER_CLASS);
const viewer = document.createElement("perspective-viewer");
viewer.classList.add(PSP_CLASS);
viewer.setAttribute("type", MIME_TYPE);
while (node.lastChild) {
node.removeChild(node.lastChild);
}
node.appendChild(viewer);
// allow perspective's event handlers to do their work
viewer.addEventListener(
"contextmenu",
(event) => event.stopPropagation(),
false,
);
const div = document.createElement("div");
div.style.setProperty("display", "flex");
div.style.setProperty("flex-direction", "row");
node.appendChild(div);
return viewer;
}
}
+374
View File
@@ -0,0 +1,374 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { ActivityMonitor } from "@jupyterlab/coreutils";
import { ILayoutRestorer } from "@jupyterlab/application";
import {
IThemeManager,
WidgetTracker,
Dialog,
showDialog,
} from "@jupyterlab/apputils";
import { ABCWidgetFactory, DocumentWidget } from "@jupyterlab/docregistry";
import { PerspectiveWidget } from "./psp_widget";
import perspective from "@perspective-dev/client";
/**
* The name of the factories that creates widgets.
*/
const FACTORY_CSV = "Perspective-CSV";
const FACTORY_JSON = "Perspective-JSON";
const FACTORY_ARROW = "Perspective-Arrow";
const RENDER_TIMEOUT = 1000;
// create here to reuse for exception handling
const baddialog = () => {
showDialog({
body: "Perspective could not render the data",
buttons: [
Dialog.okButton({
label: "Dismiss",
}),
],
focusNodeSelector: "input",
title: "Error",
});
};
export class PerspectiveDocumentWidget extends DocumentWidget {
constructor(options, type = "csv") {
super({
content: new PerspectiveWidget("Perspective"),
context: options.context,
reveal: options.reveal,
});
this._monitor = null;
this._psp = this.content;
this._type = type;
this._context = options.context;
this._context.ready.then(() => {
this._update();
this._monitor = new ActivityMonitor({
signal: this.context.model.contentChanged,
timeout: RENDER_TIMEOUT,
});
this._monitor.activityStopped.connect(this._update, this);
});
}
async _update() {
try {
let data;
if (this._type === "csv") {
// load csv directly
data = this._context.model.toString();
} else if (this._type === "arrow") {
// load arrow directly
data = Uint8Array.from(
atob(this._context.model.toString()),
(c) => c.charCodeAt(0),
).buffer;
} else if (this._type === "json") {
data = this._context.model.toJSON();
if (Array.isArray(data) && data.length > 0) {
// already is records form, load directly
data = data;
} else {
// Column-oriented or single records JSON
// don't handle for now, just need to implement
// a simple transform but we can't handle all
// cases
throw "Not handled";
}
} else {
// don't handle other mimetypes for now
throw "Not handled";
}
try {
const table = await this._psp.viewer.getTable();
table.replace(data);
} catch (e) {
// construct new table
this.worker = await perspective.worker();
const table_promise = this.worker.table(data);
// load data
await this._psp.viewer.load(table_promise);
const table = await this._psp.viewer.getTable();
// create a flat view
const view = await table.view();
view.on_update(async () => {
if (this._type === "csv") {
const result = await view.to_csv();
this.context.model.fromString(result);
this.context.save();
} else if (this._type === "arrow") {
const result = await view.to_arrow();
const resultAsB64 = btoa(
new Uint8Array(result).reduce(
(acc, i) =>
(acc += String.fromCharCode.apply(null, [
i,
])),
"",
),
);
this.context.model.fromString(resultAsB64);
this.context.save();
} else if (this._type === "json") {
const result = await view.to_json();
this.context.model.fromJSON(result);
this.context.save();
}
});
}
} catch (e) {
baddialog();
throw e;
}
// pickup theme from env
this._psp.theme =
document.body.getAttribute("data-jp-theme-light") === "false"
? "Pro Light"
: "Pro Dark";
}
dispose() {
if (this._monitor) {
this._monitor.dispose();
}
this.worker.terminate();
super.dispose();
}
get psp() {
return this._psp;
}
}
/**
* A widget factory for CSV widgets.
*/
export class PerspectiveCSVFactory extends ABCWidgetFactory {
createNewWidget(context) {
return new PerspectiveDocumentWidget(
{
context,
},
"csv",
);
}
}
/**
* A widget factory for JSON widgets.
*/
export class PerspectiveJSONFactory extends ABCWidgetFactory {
createNewWidget(context) {
return new PerspectiveDocumentWidget(
{
context,
},
"json",
);
}
}
/**
* A widget factory for arrow widgets.
*/
export class PerspectiveArrowFactory extends ABCWidgetFactory {
createNewWidget(context) {
return new PerspectiveDocumentWidget(
{
context,
},
"arrow",
);
}
}
/**
* Activate cssviewer extension for CSV files
*/
function activate(app, restorer, themeManager) {
const factorycsv = new PerspectiveCSVFactory({
name: FACTORY_CSV,
fileTypes: ["csv"],
defaultFor: ["csv"],
readOnly: true,
});
const factoryjson = new PerspectiveJSONFactory({
name: FACTORY_JSON,
fileTypes: ["json", "jsonl"],
defaultFor: ["json", "jsonl"],
readOnly: true,
});
try {
app.docRegistry.addFileType({
name: "arrow",
displayName: "arrow",
extensions: [".arrow"],
mimeTypes: ["application/octet-stream"],
contentType: "file",
fileFormat: "base64",
});
} catch (_a) {
// do nothing
}
const factoryarrow = new PerspectiveArrowFactory({
name: FACTORY_ARROW,
fileTypes: ["arrow"],
defaultFor: ["arrow"],
readOnly: true,
modelName: "base64",
});
const trackercsv = new WidgetTracker({
namespace: "csvperspective",
});
const trackerjson = new WidgetTracker({
namespace: "jsonperspective",
});
const trackerarrow = new WidgetTracker({
namespace: "arrowperspective",
});
if (restorer) {
// Handle state restoration.
void restorer.restore(trackercsv, {
command: "docmanager:open",
args: (widget) => ({
path: widget.context.path,
factory: FACTORY_CSV,
}),
name: (widget) => widget.context.path,
});
void restorer.restore(trackerjson, {
command: "docmanager:open",
args: (widget) => ({
path: widget.context.path,
factory: FACTORY_JSON,
}),
name: (widget) => widget.context.path,
});
void restorer.restore(trackerarrow, {
command: "docmanager:open",
args: (widget) => ({
path: widget.context.path,
factory: FACTORY_ARROW,
}),
name: (widget) => widget.context.path,
});
}
app.docRegistry.addWidgetFactory(factorycsv);
app.docRegistry.addWidgetFactory(factoryjson);
app.docRegistry.addWidgetFactory(factoryarrow);
const ftcsv = app.docRegistry.getFileType("csv");
const ftjson = app.docRegistry.getFileType("json");
const ftarrow = app.docRegistry.getFileType("arrow");
factorycsv.widgetCreated.connect((sender, widget) => {
// Track the widget.
void trackercsv.add(widget);
// Notify the widget tracker if restore data needs to update.
widget.context.pathChanged.connect(() => {
void trackercsv.save(widget);
});
if (ftcsv) {
widget.title.iconClass = ftcsv.iconClass || "";
widget.title.iconLabel = ftcsv.iconLabel || "";
}
});
factoryjson.widgetCreated.connect((sender, widget) => {
// Track the widget.
void trackerjson.add(widget);
// Notify the widget tracker if restore data needs to update.
widget.context.pathChanged.connect(() => {
void trackerjson.save(widget);
});
if (ftjson) {
widget.title.iconClass = ftjson.iconClass || "";
widget.title.iconLabel = ftjson.iconLabel || "";
}
});
factoryarrow.widgetCreated.connect((sender, widget) => {
// Track the widget.
void trackerarrow.add(widget);
// Notify the widget tracker if restore data needs to update.
widget.context.pathChanged.connect(() => {
void trackerarrow.save(widget);
});
if (ftarrow) {
widget.title.iconClass = ftarrow.iconClass || "";
widget.title.iconLabel = ftarrow.iconLabel || "";
}
});
// Keep the themes up-to-date.
const updateThemes = () => {
const isLight =
themeManager && themeManager.theme
? themeManager.isLight(themeManager.theme)
: true;
const theme = isLight ? "Pro Light" : "Pro Dark";
trackercsv.forEach((pspDocWidget) => {
pspDocWidget.psp.theme = theme;
});
trackerjson.forEach((pspDocWidget) => {
pspDocWidget.psp.theme = theme;
});
trackerarrow.forEach((pspDocWidget) => {
pspDocWidget.psp.theme = theme;
});
};
if (themeManager) {
themeManager.themeChanged.connect(updateThemes);
}
}
/**
* The perspective extension for files
*/
export const PerspectiveRenderers = {
activate: activate,
id: "@perspective-dev/jupyterlab-renderers",
requires: [],
optional: [ILayoutRestorer, IThemeManager],
autoStart: true,
};
+15
View File
@@ -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). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
export const MIME_TYPE = "application/psp+json";
export const PSP_CLASS = "PSPViewer";
export const PSP_CONTAINER_CLASS = "PSPContainer";
+14
View File
@@ -0,0 +1,14 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 pkg_json = require("../../package.json");
export const PERSPECTIVE_VERSION = pkg_json.version;
+420
View File
@@ -0,0 +1,420 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { DOMWidgetView } from "@jupyter-widgets/base";
import { PerspectiveJupyterWidget } from "./widget";
import perspective from "@perspective-dev/client";
function isEqual(a, b) {
if (a === b) return true;
if (typeof a != "object" || typeof b != "object" || a == null || b == null)
return false;
let keysA = Object.keys(a),
keysB = Object.keys(b);
if (keysA.length != keysB.length) return false;
for (let key of keysA) {
if (!keysB.includes(key)) return false;
if (typeof a[key] === "function" || typeof b[key] === "function") {
if (a[key].toString() != b[key].toString()) return false;
} else {
if (!isEqual(a[key], b[key])) return false;
}
}
return true;
}
async function get_psp_wasm_module() {
let elem = customElements.get("perspective-viewer");
if (!elem) {
await customElements.whenDefined("perspective-viewer");
elem = customElements.get("perspective-viewer");
}
return elem.__wasm_module__;
}
/**
* `PerspectiveView` defines the plugin's DOM and how the plugin interacts with
* the DOM.
*/
export class PerspectiveView extends DOMWidgetView {
#psp_client_id = `${Math.random()}`;
_createElement() {
const bindingMode = this.model.get("binding_mode");
this.luminoWidget = new PerspectiveJupyterWidget(
undefined,
this,
bindingMode,
);
// set up perspective_client
get_psp_wasm_module().then(async (wasm_module) => {
this.send({
type: "connect",
client_id: this.psp_client_id,
});
const { Client } = wasm_module;
// Responses are fed to the client in the widget's msg:custom handler
this.perspective_client = new Client(
async (binary_msg) => {
const buffer = binary_msg.slice().buffer;
this.send(
{ type: "binary_msg", client_id: this.psp_client_id },
[buffer],
);
},
() => {
this.send({
type: "hangup",
client_id: this.psp_client_id,
});
},
);
const tableName = this.model.get("table_name");
if (!tableName) throw new Error("table_name not set in model");
const table = this.perspective_client
.open_table(tableName)
.then(async (table) => {
if (bindingMode === "client-server") {
// TODO make this a global lazy singleton
const client = await perspective.worker();
const remote_view = await table.view();
const local_table = await client.table(remote_view);
return local_table;
} else if (bindingMode === "server") {
return table;
} else {
throw new Error(`unknown binding mode: ${bindingMode}`);
}
});
this.luminoWidget.load(table);
this._restore_from_model();
});
this._synchronize_state_dbg = (event) => {
console.log("perspective-config-update event", event);
this._synchronize_state();
};
this._synchronize_state = this._synchronize_state.bind(this);
// add event handler to synchronize traitlet values
this.luminoWidget.viewer.addEventListener(
"perspective-config-update",
this._synchronize_state_dbg,
);
// bind toggle_editable to this
this._toggle_editable = this._toggle_editable.bind(this);
// return the node against witch pWidget is bound
return this.luminoWidget.node;
}
_setElement(el) {
if (this.el || el !== this.luminoWidget.node) {
// Do not allow the view to be reassigned to a different element.
throw new Error("Cannot reset the DOM element.");
}
this.el = this.luminoWidget.node;
}
/**
* When state changes on the viewer DOM, apply it to the widget state.
*
* @param mutations
*/
async _synchronize_state(event) {
if (!this.luminoWidget._load_complete) {
return;
}
const config = await this.luminoWidget.viewer.save();
for (const name of Object.keys(config)) {
let new_value = config[name];
const current_value = this.model.get(name);
if (typeof new_value === "undefined") {
continue;
}
if (
new_value &&
typeof new_value === "string" &&
name !== "plugin" &&
name !== "theme" &&
name !== "title" &&
name !== "version"
) {
new_value = JSON.parse(new_value);
}
if (new_value === null && name === "plugin_config") {
new_value = {};
}
if (!isEqual(new_value, current_value)) {
this.model.set(name, new_value);
}
}
// propagate changes back to Python
this.touch();
}
get psp_client_id() {
return this.#psp_client_id;
}
/**
* Attach event handlers to the model for state changes in order to
* reflect them back to the DOM.
*/
render() {
super.render();
this.model.on("msg:custom", this._handle_message, this);
this.model.on("change:plugin", this.plugin_changed, this);
this.model.on("change:columns", this.columns_changed, this);
this.model.on("change:group_by", this.group_by_changed, this);
this.model.on("change:split_by", this.split_by_changed, this);
this.model.on("change:aggregates", this.aggregates_changed, this);
this.model.on("change:sort", this.sort_changed, this);
this.model.on("change:filter", this.filter_changed, this);
this.model.on("change:expressions", this.expressions_changed, this);
this.model.on("change:plugin_config", this.plugin_config_changed, this);
this.model.on("change:theme", this.theme_changed, this);
this.model.on("change:settings", this.settings_changed, this);
this.model.on("change:title", this.title_changed, this);
this.model.on("change:table_name", this.table_name_changed, this);
}
/**
* Handle messages from the Python PerspectiveViewer instance.
*/
_handle_message(msg, buffers) {
if (msg.type === "binary_msg" && msg.client_id === this.psp_client_id) {
const [dataview] = buffers;
this.perspective_client.handle_response(dataview.buffer);
}
}
get client_worker() {
if (!this._client_worker) {
this._client_worker = perspective.worker();
}
return this._client_worker;
}
async _restore_from_model() {
await this.luminoWidget.restore({
plugin: this.model.get("plugin"),
columns: this.model.get("columns"),
group_by: this.model.get("group_by"),
split_by: this.model.get("split_by"),
aggregates: this.model.get("aggregates"),
sort: this.model.get("sort"),
filter: this.model.get("filter"),
expressions: this.model.get("expressions"),
plugin_config: this.model.get("plugin_config"),
theme: this.model.get("theme"),
settings: this.model.get("settings"),
title: this.model.get("title"),
version: this.model.get("version"),
});
}
// XXX(tom): haven't looked at this, needs testing. used in client-server mode
async _toggle_editable() {
// Need to await the table and get the instance
// separately as load() only takes a promise
// to a table and not the instance itself.
const table = await this.luminoWidget.getTable();
// Setup ports in advance
if (!this._client_edit_port) {
this._client_edit_port = await this.luminoWidget.getEditPort();
}
// if (!this._kernel_edit_port) {
// this._kernel_edit_port = await this._kernel_table.make_port();
// }
const { plugin_config } = await this.luminoWidget.viewer.save();
if (plugin_config?.editable) {
// TODO only evaluated during initial load.
// Toggling from python after initial load won't
// cause edits to propagate
// Allow edits from the client Perspective to
// feed back to the kernel.
// When the client updates, if the update
// comes through the edit port then forward
// it to the server.
this._client_view_update_callback = (updated) => {
if (updated.port_id === this._client_edit_port) {
this._kernel_table.update(updated.delta, {
port_id: this._kernel_edit_port,
});
}
};
// If the server updates, and the edit is
// not coming from the server edit port,
// then synchronize state with the client.
this._kernel_view_update_callback = (updated) => {
if (updated.port_id !== this._kernel_edit_port) {
table.update(updated.delta); // any port, we dont care
}
};
} else {
// ignore
this._client_view_update_callback = () => {};
// Load the table and mirror updates from the
// kernel.
this._kernel_view_update_callback = (updated) =>
table.update(updated.delta);
}
if (this._client_view) {
// NOTE: if `plugin_config_changed` called before
// `_handle_load_message`, this will be undefined
// Ignore, as `_handle_load_message` is sure to
// follow.
this._client_view.on_update(
(updated) => this._client_view_update_callback(updated),
{ mode: "row" },
);
}
// this._kernel_view.on_update(
// (updated) => this._kernel_view_update_callback(updated),
// { mode: "row" }
// );
}
/**
* When the View is removed after the widget terminates, clean up the
* client viewer and Web Worker.
*/
remove() {
// Delete the <perspective-viewer> but do not terminate the shared
// worker as it is shared across other widgets.
this.perspective_client.terminate(); // invokes the close callback we wired up in constructor
this.luminoWidget.delete();
this.luminoWidget.viewer.removeEventListener(
"perspective-config-update",
this._synchronize_state_dbg,
);
}
/**
* When traitlets are updated in python, update the corresponding value on
* the front-end viewer. `client` and `server` are not included, as they
* are not properties in `<perspective-viewer>`.
*/
plugin_changed() {
this.luminoWidget.restore({
plugin: this.model.get("plugin"),
});
}
columns_changed() {
this.luminoWidget.restore({
columns: this.model.get("columns"),
});
}
group_by_changed() {
this.luminoWidget.restore({
group_by: this.model.get("group_by"),
});
}
split_by_changed() {
this.luminoWidget.restore({
split_by: this.model.get("split_by"),
});
}
aggregates_changed() {
this.luminoWidget.restore({
aggregates: this.model.get("aggregates"),
});
}
sort_changed() {
this.luminoWidget.restore({
sort: this.model.get("sort"),
});
}
filter_changed() {
this.luminoWidget.restore({
filter: this.model.get("filter"),
});
}
expressions_changed() {
this.luminoWidget.restore({
expressions: this.model.get("expressions"),
});
}
plugin_config_changed() {
this.luminoWidget.restore({
plugin_config: this.model.get("plugin_config"),
});
this._toggle_editable();
}
theme_changed() {
this.luminoWidget.restore({
theme: this.model.get("theme"),
});
}
settings_changed() {
this.luminoWidget.restore({
settings: this.model.get("settings"),
});
}
title_changed() {
this.luminoWidget.restore({
title: this.model.get("title"),
});
}
version_changed() {
this.luminoWidget.restore({
version: this.model.get("version"),
});
}
table_name_changed() {
// nop
// XXX(tom): we may want to re-load the viewer in this instance
}
}
+54
View File
@@ -0,0 +1,54 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { PerspectiveWidget } from "./psp_widget";
/**
* PerspectiveJupyterWidget is the ipywidgets front-end for the Perspective Jupyterlab plugin.
*/
export class PerspectiveJupyterWidget extends PerspectiveWidget {
constructor(name = "Perspective", view, bindingMode) {
super(name, view.el, bindingMode);
this._view = view;
}
/**
* Process the lumino message.
*
* Any custom lumino widget used inside a Jupyter widget should override
* the processMessage function like this.
*/
processMessage(msg) {
super.processMessage(msg);
this._view.processLuminoMessage(msg);
}
/**
* Dispose the widget.
*
* This causes the view to be destroyed as well with 'remove'
*/
dispose() {
if (this.isDisposed) {
return;
}
super.dispose();
if (this._view) {
this._view.remove();
}
this._view = null;
}
}
Binary file not shown.
@@ -0,0 +1,26 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { start_jlab, kill_jlab } from "./jlab_start.ts";
async function globalSetup() {
// Start Jupyterlab in the background
await start_jlab();
// At this point, Jupyterlab has already been started by the main test
// runner, so all we need to do is set up the signal listeners to
// clean up the Jupyter process if we Ctrl-C.
process.on("SIGINT", kill_jlab);
process.on("SIGABRT", kill_jlab);
}
export default globalSetup;
@@ -0,0 +1,19 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { kill_jlab } from "./jlab_start.ts";
async function globalTeardown() {
await kill_jlab();
}
export default globalTeardown;
@@ -0,0 +1,118 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 path from "path";
import { get } from "http";
import { spawn } from "child_process";
import "zx/globals";
const PACKAGE_ROOT = path.join(__dirname, "..", "..", "..");
/**
* Kill the Jupyterlab process created by the tests.
*/
export const kill_jlab = () => {
console.log("\n-- Cleaning up Jupyterlab process");
$`ps aux | grep -i '[j]upyter-lab --no-browser' | awk '{print $2}' | xargs kill -9 && echo "[perspective-jupyterlab] JupyterLab process terminated"`;
};
/**
* Block until the Jupyterlab server is ready.
*/
const wait_for_jlab = async function () {
let num_errors = 0;
let loaded = false;
while (!loaded) {
get(
`http://127.0.0.1:${process.env.__JUPYTERLAB_PORT__}/lab?`,
(res) => {
if (res.statusCode !== 200) {
throw new Error(`${res.statusCode} not 200!`);
}
console.log(
`Jupyterlab server has started on ${process.env.__JUPYTERLAB_PORT__}`,
);
loaded = true;
},
).on("error", (err) => {
if (num_errors > 50) {
kill_jlab();
throw new Error(`Could not launch Jupyterlab: ${err}`);
}
num_errors++;
});
await new Promise((resolve) => setTimeout(resolve, 500));
}
};
export function start_jlab() {
/*
* Spawn the Jupyterlab server.
*/
try {
// Does not alter the global env, only the env for this process
process.env.JUPYTER_CONFIG_DIR = path.join(
PACKAGE_ROOT,
"test",
"config",
"jupyter",
);
process.env.JUPYTERLAB_SETTINGS_DIR = path.join(
PACKAGE_ROOT,
"test",
"config",
"jupyter",
"user_settings",
);
// Start jupyterlab with a root to dist/esm where the notebooks will be.
process.chdir(path.join(PACKAGE_ROOT, "dist", "esm"));
console.log("Spawning Jupyterlab process");
// Jupyterlab is spawned with the default $PYTHONPATH of the shell it
// is running in. For local testing during devlopment you may need to
// run it with the $PYTHONPATH set to ./python/perspective
const proc = spawn(
"jupyter",
[
"lab",
"--no-browser",
"--log-level=CRITICAL",
`--port=${process.env.__JUPYTERLAB_PORT__}`,
`--config=${process.env.JUPYTER_CONFIG_DIR}/jupyter_notebook_config.json`,
],
{
env: {
...process.env,
// https://github.com/microsoft/playwright/issues/24516
NODE_OPTIONS: undefined,
},
// stdio: "inherit",
},
);
// Wait for Jupyterlab to start up
return wait_for_jlab().then(() => {
return proc;
});
} catch (e) {
console.error(e);
kill_jlab();
process.exit(1);
}
}
@@ -0,0 +1,18 @@
{
"NotebookApp": {
"token": "",
"password": "",
"default_url": "/lab?reset",
"allow_remote_access": true,
"disable_check_xsrf": true,
"enable_mathjax": false
},
"LabApp": {
"tornado_settings": {
"page_config_data": {
"buildCheck": false,
"buildAvailable": false
}
}
}
}
@@ -0,0 +1,10 @@
{
"ServerApp": {
"token": "",
"password": "",
"answer_yes": true,
"allow_remote_access": true,
"disable_check_xsrf": true,
"allow_password_change": false
}
}
@@ -0,0 +1,12 @@
{
"shortcuts": [
{
"command": "runmenu:run-all",
"keys": [
"R",
"R"
],
"selector": "[data-jp-kernel-user]:focus"
}
]
}
+26
View File
@@ -0,0 +1,26 @@
<!doctype html>
<html>
<head>
<script type="module" src="/node_modules/@perspective-dev/viewer-datagrid/dist/cdn/perspective-viewer-datagrid.js"></script>
<link rel="stylesheet" href="/node_modules/@perspective-dev/jupyterlab/dist/css/perspective-jupyterlab.css" />
<script type="module">
import { PerspectiveWidget } from "/node_modules/@perspective-dev/jupyterlab/dist/esm/lumino.js";
window.__WIDGET__ = new PerspectiveWidget();
document.getElementById("container").appendChild(window.__WIDGET__.node);
await import("/node_modules/@perspective-dev/test/load-viewer-csv.js");
</script>
<style>
perspective-viewer {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
</style>
</head>
<body>
<div id="container">hello</div>
</body>
</html>
+114
View File
@@ -0,0 +1,114 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { test } from "@perspective-dev/test";
import { compareContentsToSnapshot } from "@perspective-dev/test";
test.beforeEach(async ({ page }) => {
await page.goto(
"/node_modules/@perspective-dev/jupyterlab/test/html/resize.html",
);
await page.evaluate(async () => {
while (!window["__TEST_PERSPECTIVE_READY__"]) {
await new Promise((x) => setTimeout(x, 10));
}
});
});
test.describe("JupyterLab resize", () => {
test("Config should be hidden by default", async ({ page }) => {
// Snapshot is viewer contents
const contents = await page.evaluate(async () => {
await window.__WIDGET__.viewer.getTable();
await window.__WIDGET__.viewer.flush();
// Linux returns ever-so-slightly different auto width
// column values so we need to strip these.
for (const elem of document.querySelectorAll(
"perspective-viewer *",
)) {
elem.removeAttribute("style");
}
return window.__WIDGET__.viewer.innerHTML;
});
await compareContentsToSnapshot(contents, [
"jupyterlab-resize-config-hidden.txt",
]);
});
test("Resize the container causes the widget to resize", async ({
page,
}) => {
await page.evaluate(async () => {
await document.querySelector("perspective-viewer").toggleConfig();
await document.querySelector("perspective-viewer").getTable();
});
await page.evaluate(async () => {
document
.querySelector(".PSPContainer")
.setAttribute(
"style",
"position:absolute;top:0;left:0;width:300px;height:300px",
);
await document.querySelector("perspective-viewer").resize();
});
// Snapshot is viewer contents
const contents = await page.evaluate(async () => {
document.querySelector(".PSPContainer").style =
"position:absolute;top:0;left:0;width:800px;height:600px";
await document.querySelector("perspective-viewer").resize();
for (const elem of document.querySelectorAll(
"perspective-viewer *",
)) {
elem.removeAttribute("style");
}
return window.__WIDGET__.viewer.innerHTML;
});
await compareContentsToSnapshot(contents, [
"jupyterlab-resize-config-shown.txt",
]);
});
test("group_by traitlet works", async ({ page }) => {
await page.evaluate(async () => {
await document.querySelector("perspective-viewer").toggleConfig();
await document.querySelector("perspective-viewer").getTable();
await document.querySelector("perspective-viewer").flush();
});
// Snapshot is datagrid contents
const contents = await page.evaluate(async () => {
await window.__WIDGET__.restore({ group_by: ["State"] });
for (const elem of document.querySelectorAll(
"perspective-viewer *",
)) {
elem.removeAttribute("style");
}
const datagrid = window.__WIDGET__.viewer.querySelector(
"perspective-viewer-datagrid",
);
return datagrid.shadowRoot.innerHTML;
});
await compareContentsToSnapshot(contents, [
"jupyterlab-resize-group-by-traitlet.txt",
]);
});
});
@@ -0,0 +1,24 @@
{
"cells": [],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.7.9"
}
},
"nbformat": 4,
"nbformat_minor": 4
}
+202
View File
@@ -0,0 +1,202 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { test, expect } from "@playwright/test";
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import { dirname } from "path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const notebook_template = JSON.parse(
fs.readFileSync(__dirname + "/notebook_template.json", {
encoding: "utf-8",
}),
);
const DIST_ROOT = path.join(__dirname, "..", "..", "dist", "esm");
const TEST_CONFIG_ROOT = path.join(__dirname, "..", "config", "jupyter");
const remove_jupyter_artifacts = () => {
fs.rmSync(path.join(TEST_CONFIG_ROOT, "lab"), {
recursive: true,
force: true,
});
fs.rmSync(path.join(DIST_ROOT, ".ipynb_checkpoints"), {
recursive: true,
force: true,
});
};
/**
* Generate a new Jupyter notebook using the standard JSON template, and
* save it into dist/esm so that the tests can use the resulting notebook.
*
* @param {String} notebook_name
* @param {Array<String>} cells
*/
const generate_notebook = (notebook_name, cells) => {
const notebook_path = path.join(DIST_ROOT, notebook_name);
// deepcopy the notebook template so we are not modifying a shared object
const nb = JSON.parse(JSON.stringify(notebook_template));
// import perspective, set up test data etc.
nb["cells"] = [
{
cell_type: "code",
metadata: {},
execution_count: null,
outputs: [],
source: [
"import perspective\n",
"import perspective.widget\n",
"import pandas as pd\n",
"import numpy as np\n",
"arrow_data = None\n",
"with open('test.arrow', 'rb') as arrow: \n arrow_data = arrow.read()",
],
},
];
// Cells defined in the test as an array of arrays - each inner array
// is a new cell to be added to the notebook.
for (const cell of cells) {
nb["cells"].push({
cell_type: "code",
execution_count: null,
metadata: {},
outputs: [],
source: cell,
});
}
// Write the notebook to dist/esm, which acts as the working directory
// for the Jupyterlab test server.
fs.writeFileSync(notebook_path, JSON.stringify(nb));
};
// Add Jupyterlab-specific bindings to the global Jest objects
export function describe_jupyter(body, { name, root } = {}) {
// Remove the automatically generated workspaces directory, as it
// will try to redirect single-document URLs to the last URL opened.
test.beforeEach(remove_jupyter_artifacts);
test.afterAll(remove_jupyter_artifacts);
// URL is null because each test.capture_jupyterlab will have its own
// unique notebook generated.
return test.describe(`Blank Notebook`, body);
}
/**
* Execute body() on a Jupyter notebook without taking any screenshots.
*
* @param {*} name
* @param {*} cells
* @param {*} body
*/
export function test_jupyter(name, cells, body) {
const notebook_name = `${name.replace(/[ \.']/g, "_")}.ipynb`;
generate_notebook(notebook_name, cells);
const url = `doc/tree/${notebook_name}`;
test(name, async ({ page }) => {
await page.goto(
`http://127.0.0.1:${process.env.__JUPYTERLAB_PORT__}/${url}`,
{ waitUntil: "domcontentloaded" },
);
await body({ page });
});
}
export async function default_body(page) {
await execute_all_cells(page);
const viewer = await page.waitForSelector(
".jp-OutputArea-output perspective-viewer",
{ visible: true },
);
await viewer.evaluate(async (viewer) => await viewer.flush());
return viewer;
}
export async function execute_all_cells(page) {
await page.waitForFunction(async () => !!document.title);
await page.waitForSelector(".lm-Widget", { visible: true });
await page.waitForSelector(".jp-NotebookPanel-toolbar", {
visible: true,
});
// wait for a cell to be active
try {
await page.waitForSelector(
'.jp-Notebook-ExecutionIndicator:not([data-status="idle"])',
{ timeout: 1000 },
);
} catch (e) {}
// await new Promise((x) => setTimeout(x, 2000));
await page.waitForSelector(
'.jp-Notebook-ExecutionIndicator[data-status="idle"]',
);
// Use our custom keyboard shortcut to run all cells
await page.keyboard.press("R");
await page.keyboard.press("R");
await page.evaluate(() => (document.scrollTop = 0));
}
export async function add_and_execute_cell(page, cell_content) {
// wait for a code cell to be visible
await page.waitForSelector(".jp-CodeCell", {
visible: true,
});
// find and click the a cell in the notebook
await page.click(".jp-CodeCell");
await new Promise((x) => setTimeout(x, 100));
// find and click the "new cell" button
await page.click('jp-button[data-command="notebook:insert-cell-below"]');
await new Promise((x) => setTimeout(x, 100));
// after clicking new cell, the document will auto
// focus the new cell, so lets grab it
const el = await page.evaluateHandle(() => document.activeElement);
await el.type(cell_content);
await new Promise((x) => setTimeout(x, 100));
// now while the element is still focused, click the run cell button
await page.click(
'jp-button[data-command="notebook:run-cell-and-select-next"]',
);
await new Promise((x) => setTimeout(x, 100));
// wait for kernel to stop running
// await page.waitForSelector(
// "//div.jp-InputPrompt[contains(text(),'[*]:')]",
// {
// hidden: true,
// }
// );
}
export async function assert_no_error_in_cell(page, cell_content) {
// run the cell
await add_and_execute_cell(page, cell_content);
// wait for jupyter to render any frontend exceptions
return await Promise.race([
page
.waitForSelector(
'div[data-mime-type="application/vnd.jupyter.stderr"]',
)
.then(() => false),
page
.waitForSelector("//div//pre[contains(text(),\"'Passed'\")]")
.then(() => true),
]);
}
@@ -0,0 +1,598 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { API_VERSION, expect } from "@perspective-dev/test";
import path from "path";
import {
default_body,
add_and_execute_cell,
assert_no_error_in_cell,
execute_all_cells,
test_jupyter,
describe_jupyter,
} from "./utils.mjs";
import { fileURLToPath } from "url";
import { dirname } from "path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const getEditMode = async (viewer) => {
return await viewer.evaluate(async (viewer) => {
return (await viewer.save()).plugin_config.edit_mode;
});
};
// utils.with_jupyterlab(process.env.__JUPYTERLAB_PORT__, () => {
describe_jupyter(
() => {
test_jupyter(
"Open arrow and csv from file browser",
[],
async ({ page }) => {
await page.locator("#tab-key-1-7").click();
await page
.locator(`.jp-DirListing-item[data-file-type="arrow"]`)
.click({ button: "right" });
await page.hover(`.lm-Menu .lm-Menu-item[data-type="submenu"]`);
await page
.locator(`.lm-Menu-item[data-command="filebrowser:open"]`)
.click();
const num_columns = await page
.locator("regular-table thead tr")
.first()
.evaluate((tr) => tr.childElementCount);
expect(num_columns).toEqual(14);
await expect(
page.locator("regular-table tbody tr"),
).toHaveCount(5);
},
);
// Basics
test_jupyter(
"Loads data",
[
"w = perspective.widget.PerspectiveWidget(arrow_data, columns=['f64', 'str', 'datetime'])",
"w",
],
async ({ page }) => {
await default_body(page);
const num_columns = await page
.locator("regular-table thead tr")
.first()
.evaluate((tr) => tr.childElementCount);
expect(num_columns).toEqual(3);
await expect(
page.locator("regular-table tbody tr"),
).toHaveCount(5);
},
);
test_jupyter(
"Loads updates",
[
[
"server = perspective.Server()",
"client = server.new_local_client()",
"table = client.table(arrow_data)",
"w = perspective.widget.PerspectiveWidget(table, columns=['f64', 'str', 'datetime'])",
].join("\n"),
"w",
"table.update(arrow_data)",
],
async ({ page }) => {
await default_body(page);
const num_columns = await page
.locator("regular-table thead tr")
.first()
.evaluate((tr) => tr.childElementCount);
expect(num_columns).toEqual(3);
await expect(
page.locator("regular-table tbody tr"),
).toHaveCount(10);
},
);
test_jupyter(
"Loads a table",
[
[
"server = perspective.Server()",
"client = server.new_local_client()",
"table = client.table(arrow_data)",
"w = perspective.widget.PerspectiveWidget(table, columns=['f64', 'str', 'datetime'])",
].join("\n"),
"w",
],
async ({ page }) => {
await default_body(page);
const num_columns = await page
.locator("regular-table thead tr")
.first()
.evaluate((tr) => tr.childElementCount);
expect(num_columns).toEqual(3);
await expect(
page.locator("regular-table tbody tr"),
).toHaveCount(5);
},
);
test_jupyter(
"Loads AsyncTable",
[
`
import asyncio
server = perspective.Server()
sync_client = server.new_local_client()
sync_client.table({"Income": [5,4,3,2,1], "Expense": [4,3,2,1,1], "Profit": [1,1,1,1,0]}, name="Microstore")
proxy_sess = perspective.ProxySession(sync_client, lambda msg: asyncio.create_task(async_client.handle_response(msg)))
async_client = perspective.AsyncClient(proxy_sess.handle_request_async)
async_table = await async_client.open_table("Microstore")`,
"w = perspective.widget.PerspectiveWidget(async_table)",
"w",
],
async ({ page }) => {
await default_body(page);
const num_columns = await page
.locator("regular-table thead tr")
.first()
.evaluate((tr) => tr.childElementCount);
expect(num_columns).toEqual(3);
await expect(
page.locator("regular-table tbody tr"),
).toHaveCount(5);
},
);
test_jupyter(
"Loads updates to AsyncTable",
[
[
`
import asyncio
server = perspective.Server()
sync_client = server.new_local_client()
sync_table = sync_client.table(arrow_data)
proxy_sess = perspective.ProxySession(sync_client, lambda msg: asyncio.create_task(async_client.handle_response(msg)))
async_client = perspective.AsyncClient(proxy_sess.handle_request_async)
async_table = await async_client.open_table(sync_table.get_name())`,
"w = perspective.widget.PerspectiveWidget(async_table, columns=['f64', 'str', 'datetime'])",
].join("\n"),
"w",
"sync_table.update(arrow_data)",
],
async ({ page }) => {
await default_body(page);
const num_columns = await page
.locator("regular-table thead tr")
.first()
.evaluate((tr) => tr.childElementCount);
expect(num_columns).toEqual(3);
await expect(
page.locator("regular-table tbody tr"),
).toHaveCount(10);
},
);
// Restore
test_jupyter(
"Loads with settings=False",
[
[
"server = perspective.Server()",
"client = server.new_local_client()",
"table = client.table(arrow_data)",
"w = perspective.widget.PerspectiveWidget(table, columns=['f64', 'str', 'datetime'], settings=False)",
].join("\n"),
"w",
],
async ({ page }) => {
const viewer = await default_body(page);
const settings = await viewer.evaluate(async (viewer) => {
return (await viewer.save()).settings;
});
expect(settings).toEqual(false);
},
);
test_jupyter(
"Loads with edit_mode=EDIT",
[
[
"server = perspective.Server()",
"client = server.new_local_client()",
"table = client.table(arrow_data)",
"w = perspective.widget.PerspectiveWidget(table, plugin_config={'edit_mode': 'EDIT'})",
].join("\n"),
"w",
],
async ({ page }) => {
const viewer = await default_body(page);
const edit_mode = await getEditMode(viewer);
expect(edit_mode).toEqual("EDIT");
},
);
test_jupyter(
"Editable Toggle - from Python",
[
[
"server = perspective.Server()",
"client = server.new_local_client()",
"table = client.table(arrow_data)",
"w = perspective.widget.PerspectiveWidget(table)",
].join("\n"),
"w",
],
async ({ page }) => {
const viewer = await default_body(page);
let edit_mode = await getEditMode(viewer);
expect(edit_mode).toEqual("READ_ONLY");
await add_and_execute_cell(
page,
'w.plugin_config = {"edit_mode": "EDIT"}',
);
edit_mode = await getEditMode(viewer);
expect(edit_mode).toEqual("EDIT");
},
);
test_jupyter(
"Editable Toggle - from JS",
[
[
"server = perspective.Server()",
"client = server.new_local_client()",
"table = client.table(arrow_data)",
"w = perspective.widget.PerspectiveWidget(table)",
].join("\n"),
"w",
],
async ({ page }) => {
const viewer = await default_body(page);
let edit_mode = await getEditMode(viewer);
expect(edit_mode).toEqual("READ_ONLY");
await viewer.evaluate(async (viewer) => {
const edit =
viewer.children[1].shadowRoot.querySelector(
"span#edit_mode",
);
edit.click();
});
edit_mode = await getEditMode(viewer);
expect(edit_mode).toEqual("EDIT");
},
);
test_jupyter(
"Everything Else - Toggle from Python",
[
[
"server = perspective.Server()",
"client = server.new_local_client()",
"table = client.table(arrow_data)",
"w = perspective.widget.PerspectiveWidget(table)",
].join("\n"),
"w",
],
async ({ page }) => {
const viewer = await default_body(page);
let config = await viewer.evaluate(async (viewer) => {
return await viewer.save();
});
// Check default config
expect(config).toEqual({
version: API_VERSION,
columns_config: {},
aggregates: {},
columns: [
"ui8",
"i8",
"ui16",
"i16",
"ui32",
"i32",
"ui64",
"i64",
"f32",
"f64",
"bool",
"str",
"date",
"datetime",
],
expressions: {},
filter: [],
group_by: [],
plugin: "Datagrid",
plugin_config: {
columns: {},
edit_mode: "READ_ONLY",
scroll_lock: false,
},
settings: true,
sort: [],
split_by: [],
theme: "Pro Light",
title: null,
});
await add_and_execute_cell(
page,
`
w.plugin = "X Bar"
w.columns = ["ui8"]
w.filter = [["i8", "<", 50]]
w.group_by = ["date"]
w.split_by = ["bool"]
w.sort = [["date", "asc"]]
w.theme = "Pro Dark"`,
);
// grab the config again
config = await viewer.evaluate(async (viewer) => {
return await viewer.save();
});
// and check it
expect(config).toEqual({
version: API_VERSION,
columns_config: {},
aggregates: {},
columns: ["ui8"],
expressions: {},
filter: [["i8", "<", 50]],
group_by: ["date"],
plugin: "X Bar",
plugin_config: {},
settings: true,
sort: [["date", "asc"]],
split_by: ["bool"],
theme: "Pro Dark",
title: null,
});
},
);
test_jupyter(
"Everything Else - Toggle from JS",
[
[
"server = perspective.Server()",
"client = server.new_local_client()",
"table = client.table(arrow_data)",
"w = perspective.widget.PerspectiveWidget(table)",
].join("\n"),
"w",
],
async ({ page }) => {
const viewer = await default_body(page);
const config = await viewer.evaluate(async (viewer) => {
return await viewer.save();
});
// Check default config
expect(config).toEqual({
version: API_VERSION,
columns_config: {},
aggregates: {},
columns: [
"ui8",
"i8",
"ui16",
"i16",
"ui32",
"i32",
"ui64",
"i64",
"f32",
"f64",
"bool",
"str",
"date",
"datetime",
],
expressions: {},
filter: [],
group_by: [],
plugin: "Datagrid",
plugin_config: {
columns: {},
scroll_lock: false,
edit_mode: "READ_ONLY",
},
settings: true,
sort: [],
split_by: [],
theme: "Pro Light",
title: null,
});
await viewer.evaluate(async (viewer, version) => {
viewer.restore({
version,
columns: ["ui8"],
filter: [["i8", "<", "50"]],
group_by: ["date"],
plugin: "X Bar",
settings: false,
sort: [["date", "asc"]],
split_by: ["bool"],
theme: "Pro Dark",
title: null,
});
return "";
}, API_VERSION);
const error_cells_dont_exist = await assert_no_error_in_cell(
page,
`
assert w.plugin == "X Bar"
assert w.columns == ["ui8"]
assert w.filter == [["i8", "<", "50"]]
assert w.group_by == ["date"]
assert w.split_by == ["bool"]
assert w.plugin_config == {}
assert w.settings == False
assert w.sort == [["date", "asc"]]
assert w.theme == "Pro Dark"
"Passed"`,
);
expect(error_cells_dont_exist).toBe(true);
},
);
test_jupyter(
"Edit from frontend - end to end",
[
'w = perspective.widget.PerspectiveWidget({"a": [True, False, True], "b": ["abc", "def", "ghi"]}, index="b", plugin_config={"edit_mode": "EDIT"})',
"w",
],
async ({ page }) => {
const viewer = await default_body(page);
// assert in python or else
let error_cells_dont_exist = await assert_no_error_in_cell(
page,
[
`assert w.table.view().to_columns() == {'a': [True, False, True], 'b': ['abc', 'def', 'ghi']}`,
`"Passed"`,
].join("\n"),
);
expect(error_cells_dont_exist).toBe(true);
// Toggle some values in the frontend
const bools = await page.$$(".psp-bool-type");
// do synchronous
for (let bool of bools) {
await bool.click();
}
// now check again
error_cells_dont_exist = await assert_no_error_in_cell(
page,
[
`assert w.table.view().to_columns() == {'a': [False, True, False], 'b': ['abc', 'def', 'ghi']}`,
`"Passed"`,
].join("\n"),
);
expect(error_cells_dont_exist).toBe(true);
},
);
test_jupyter("Restores from saved config", [], async ({ page }) => {
await execute_all_cells(page);
let errored = await assert_no_error_in_cell(
page,
`
server = perspective.Server()
client = server.new_local_client()
table = client.table(arrow_data)
w = perspective.widget.PerspectiveWidget(table)
config = w.save()
perpsective.PerspectiveWidget(df, **config)
`,
);
expect(errored).toBe(false);
});
test_jupyter(
"Toggles to datagrid and back regression",
[
"w = perspective.widget.PerspectiveWidget(arrow_data, columns=['f64', 'str', 'datetime'])",
"w",
],
async ({ page }) => {
await default_body(page);
const num_columns = await page
.locator("regular-table thead tr")
.first()
.evaluate((tr) => tr.childElementCount);
async function toggle(plugin) {
await page.locator(".plugin-select-item").click();
await page
.locator("#plugin_selector_container.open")
.waitFor();
await page
.locator(`[data-plugin=${plugin}].plugin-select-item`)
.click();
await page
.locator("#plugin_selector_container:not(.open)")
.waitFor();
await page.evaluate(async () => {
await document
.querySelector("perspective-viewer")
.flush();
});
}
await toggle('"X/Y Line"');
await toggle("Datagrid");
await toggle('"X/Y Line"');
await toggle("Datagrid");
// expect(num_columns).toEqual(3);
await expect(
page.locator("regular-table tbody tr"),
).toHaveCount(5);
},
);
// *************************
// UTILS
// *************************
test_jupyter(
"Run in Cell - Assert in Cell working",
[],
async ({ page }) => {
await execute_all_cells(page);
// assert_no_error_in_cell runs add_and_execute_cell internally so only need to check one
const error_cells_dont_exist = await assert_no_error_in_cell(
page,
"raise Exception('anything')",
);
expect(error_cells_dont_exist).toBe(false);
},
);
},
{ name: "Simple", root: path.join(__dirname, "..", "..") },
);
// });
+24
View File
@@ -0,0 +1,24 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 CopyPlugin = require("copy-webpack-plugin");
module.exports = {
experiments: {
topLevelAwait: true,
},
plugins: [
new CopyPlugin({
patterns: [{ from: "./install.json", to: "../install.json" }],
}),
],
};
+44
View File
@@ -0,0 +1,44 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 "zx/globals";
const BUILD = [
{
define: {
global: "window",
},
entryPoints: ["src/index.tsx"],
format: "esm",
loader: {},
outfile: "dist/esm/index.js",
plugins: [NodeModulesExternal()],
},
];
async function build_all() {
await Promise.all(BUILD.map(build)).catch(() => process.exit(1));
try {
await $`tsc --project ./tsconfig.json`.stdio(
"inherit",
"inherit",
"inherit",
);
} catch (e) {
console.error(e);
process.exit(1);
}
}
build_all();
+15
View File
@@ -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 });
+15
View File
@@ -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 "zx/globals";
await $`typedoc --tsconfig tsconfig.json --out ../../docs/static/react`;
+51
View File
@@ -0,0 +1,51 @@
{
"name": "@perspective-dev/react",
"version": "4.5.2",
"description": "React component wrappers for `<perspective-viewer>` and `<perspective-workspace>`",
"keywords": [
"perspective",
"react",
"data",
"analytics",
"visualization"
],
"homepage": "https://perspective-dev.github.io",
"repository": {
"type": "git",
"url": "https://github.com/perspective-dev/perspective"
},
"license": "Apache-2.0",
"sideEffects": false,
"exports": {
".": {
"types": "./dist/esm/index.d.ts",
"import": "./dist/esm/index.js",
"default": "./dist/esm/index.js"
},
"./package.json": "./package.json"
},
"type": "module",
"scripts": {
"build": "node ./build.mjs",
"clean": "node ./clean.mjs",
"docs": "node ./docs.mjs"
},
"publishConfig": {
"access": "public"
},
"dependencies": {
"@perspective-dev/client": "workspace:",
"@perspective-dev/viewer": "workspace:",
"@perspective-dev/workspace": "workspace:",
"@types/react": "catalog:",
"react": "catalog:",
"react-dom": "catalog:"
},
"devDependencies": {
"@perspective-dev/esbuild-plugin": "workspace:",
"superstore-arrow": "catalog:",
"typescript": "catalog:",
"typedoc": "catalog:",
"zx": "catalog:"
}
}
+24
View File
@@ -0,0 +1,24 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
/**
*
* # See Also
*
* [`react-example`](https://github.com/perspective-dev/perspective/tree/master/examples/react-example)
* project from the Perspective GitHub repo.
*
* @module
*/
export * from "./viewer";
export * from "./workspace";
+27
View File
@@ -0,0 +1,27 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 React from "react";
export function usePspListener<A>(
el: HTMLElement | undefined | null,
event: string,
cb?: (x: A) => void,
) {
React.useEffect(() => {
if (!cb || !el) return;
const ctx = new AbortController();
const callback = (e: Event) => cb((e as CustomEvent).detail);
el?.addEventListener(event, callback, { signal: ctx.signal });
return () => ctx.abort();
}, [el, cb]);
}
+86
View File
@@ -0,0 +1,86 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 React from "react";
import type * as psp from "@perspective-dev/client";
import type * as pspViewer from "@perspective-dev/viewer";
import { usePspListener } from "./utils";
function PerspectiveViewerImpl(props: PerspectiveViewerProps) {
const [viewer, setViewer] =
React.useState<pspViewer.HTMLPerspectiveViewerElement | null>(null);
React.useEffect(() => {
return () => {
viewer?.delete();
};
}, [viewer]);
React.useEffect(() => {
if (props.client) {
viewer?.load(props.client);
} else {
viewer?.eject();
}
}, [viewer, props.client]);
React.useEffect(() => {
if (props.client && props.config) {
viewer?.restore(props.config);
}
}, [viewer, props.client, JSON.stringify(props.config)]);
usePspListener(viewer, "perspective-click", props.onClick);
usePspListener(viewer, "perspective-select", props.onSelect);
usePspListener(viewer, "perspective-config-update", props.onConfigUpdate);
return (
<perspective-viewer
ref={setViewer}
id={props.id}
className={props.className}
hidden={props.hidden}
slot={props.slot}
style={props.style}
tabIndex={props.tabIndex}
title={props.title}
/>
);
}
/**
* Props for the `<PerspectiveViewer>` component.
*/
export interface PerspectiveViewerProps {
client?: psp.Client | Promise<psp.Client> | psp.Table | Promise<psp.Table>;
config?: pspViewer.ViewerConfigUpdate;
onConfigUpdate?: (config: pspViewer.ViewerConfigUpdate) => void;
onClick?: (data: pspViewer.PerspectiveClickEventDetail) => void;
onSelect?: (data: pspViewer.PerspectiveSelectEventDetail) => void;
// Applicable props from `React.HTMLAttributes`, which we cannot extend
// directly because Perspective changes the signature of `onClick`.
className?: string | undefined;
hidden?: boolean | undefined;
id?: string | undefined;
slot?: string | undefined;
style?: React.CSSProperties | undefined;
tabIndex?: number | undefined;
title?: string | undefined;
}
/**
* A React wrapper component for `<perspective-viewer>` Custom Element.
*/
export const PerspectiveViewer: React.FC<PerspectiveViewerProps> = React.memo(
PerspectiveViewerImpl,
);
+95
View File
@@ -0,0 +1,95 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 * as psp from "@perspective-dev/client";
import type * as psp_viewer from "@perspective-dev/viewer";
import type * as pspWorkspace from "@perspective-dev/workspace";
import { PerspectiveWorkspaceConfig } from "@perspective-dev/workspace";
import * as utils from "./utils";
import * as React from "react";
export interface NewViewEventDetail {
config: psp_viewer.ViewerConfigUpdate;
widget: pspWorkspace.PerspectiveViewerWidget;
}
export interface ToggleGloalFilterEventDetail {
widget: pspWorkspace.PerspectiveViewerWidget;
isGlobalFilter: boolean;
}
export interface PerspectiveWorkspaceProps
extends React.HTMLAttributes<HTMLElement> {
client: psp.Client | Promise<psp.Client>;
layout: PerspectiveWorkspaceConfig;
onLayoutUpdate?: (layout: PerspectiveWorkspaceConfig) => void;
onNewView?: (detail: NewViewEventDetail) => void;
onToggleGlobalFilter?: (detail: ToggleGloalFilterEventDetail) => void;
}
const PerspectiveWorkspaceImpl = React.forwardRef<
pspWorkspace.HTMLPerspectiveWorkspaceElement | undefined,
PerspectiveWorkspaceProps
>(
(
{
client,
layout,
onLayoutUpdate,
onNewView,
onToggleGlobalFilter,
...htmlAttributes
},
ref,
) => {
const [workspace, setWorkspace] =
React.useState<pspWorkspace.HTMLPerspectiveWorkspaceElement>();
React.useImperativeHandle(ref, () => workspace, [workspace]);
React.useEffect(() => {
if (workspace && layout) {
workspace.restore(layout);
}
}, [workspace, layout]);
React.useEffect(() => {
if (workspace && client) {
workspace.load(client);
}
}, [workspace, client]);
utils.usePspListener(workspace, "workspace-new-view", onNewView);
utils.usePspListener(
workspace,
"workspace-layout-update",
onLayoutUpdate
? ({ layout }: { layout: PerspectiveWorkspaceConfig }) =>
workspace?.save().then((x) => onLayoutUpdate(x))
: undefined,
);
utils.usePspListener(
workspace,
"workspace-toggle-global-filter",
onToggleGlobalFilter,
);
return (
<perspective-workspace
ref={(r) => setWorkspace(r ?? undefined)}
{...htmlAttributes}
></perspective-workspace>
);
},
);
export const PerspectiveWorkspace = React.memo(PerspectiveWorkspaceImpl);
+106
View File
@@ -0,0 +1,106 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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-charts";
import "@perspective-dev/workspace";
// @ts-ignore
import SERVER_WASM from "@perspective-dev/server/dist/wasm/perspective-server.wasm?url";
// @ts-ignore
import CLIENT_WASM from "@perspective-dev/viewer/dist/wasm/perspective-viewer.wasm?url";
await Promise.all([
perspective.init_server(fetch(SERVER_WASM)),
perspective_viewer.init_client(fetch(CLIENT_WASM)),
]);
import * as psp from "@perspective-dev/client";
import type * as pspViewer from "@perspective-dev/viewer";
// @ts-ignore
import SUPERSTORE_ARROW from "superstore-arrow/superstore.lz4.arrow?url";
import * as React from "react";
import {
PerspectiveViewer,
PerspectiveWorkspace,
} from "@perspective-dev/react";
import "@perspective-dev/viewer/dist/css/themes.css";
import "./index.css";
const WORKER = await perspective.worker();
async function createNewSuperstoreTable(): Promise<psp.Table> {
const req = fetch(SUPERSTORE_ARROW);
const resp = await req;
const buffer = await resp.arrayBuffer();
return await WORKER.table(buffer);
}
const CONFIG: pspViewer.ViewerConfigUpdate = {
group_by: ["State"],
};
interface ToolbarState {
mounted: boolean;
table?: Promise<psp.Table>;
config: pspViewer.ViewerConfigUpdate;
}
export const App: React.FC = () => {
const [state, setState] = React.useState<ToolbarState>(() => ({
mounted: true,
table: createNewSuperstoreTable(),
config: { ...CONFIG },
}));
React.useEffect(() => {
return () => {
state.table?.then((table) => table?.delete({ lazy: true }));
};
}, []);
const onConfigUpdate = (config: pspViewer.ViewerConfigUpdate) => {
console.log("Config Update Event", config);
setState({ ...state, config });
};
const onClick = (detail: pspViewer.PerspectiveClickEventDetail) => {
console.log("Click Event,", detail);
};
const onSelect = (detail: pspViewer.PerspectiveSelectEventDetail) => {
console.log("Select Event", detail);
};
return (
<div className="container">
{state.mounted && (
<>
<PerspectiveViewer table={state.table} />
<PerspectiveViewer
table={state.table}
config={state.config}
onClick={onClick}
onSelect={onSelect}
onConfigUpdate={onConfigUpdate}
/>
</>
)}
</div>
);
};
+68
View File
@@ -0,0 +1,68 @@
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
* ┃ 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). ┃
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
*/
body {
margin: 0;
background-color: #f0f0f0;
font-family: "ui-monospace", "SFMono-Regular", "SF Mono", "Menlo",
"Consolas", "Liberation Mono", monospace;
}
.container {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: grid;
gap: 8px;
grid-template-columns: 1fr 1fr;
grid-template-rows: 45px 1fr;
}
perspective-viewer {
grid-row: 2;
}
.toolbar {
grid-row: 1;
grid-column-start: 1;
grid-column-end: 3;
display: flex;
flex-direction: row;
gap: 10px;
padding: 10px;
justify-content: stretch;
border-bottom: 1px solid #666;
}
button {
font-family: "ui-monospace", "SFMono-Regular", "SF Mono", "Menlo",
"Consolas", "Liberation Mono", monospace;
}
.workspace-container {
display: flex;
flex-direction: column;
.workspace-toolbar {
display: flex;
flex-direction: row;
}
perspective-workspace {
height: 100vh;
}
}
+93
View File
@@ -0,0 +1,93 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { test, expect } from "@playwright/experimental-ct-react";
import { App } from "./basic.story";
import { EmptyWorkspace, SingleView } from "./workspace.story";
test.describe("Perspective React", () => {
test("The viewer loads with data in it", async ({ page, mount }) => {
const comp = await mount(<App></App>);
const count = await page.evaluate(async () => {
await new Promise((x) => setTimeout(x, 1000));
return document.querySelectorAll("perspective-viewer").length;
});
expect(count).toBe(2);
});
test("React workspace functionality", async ({ page, mount }) => {
const comp = await mount(<EmptyWorkspace />);
const toggleMount = comp.locator("button.toggle-mount");
const addViewer = comp.locator("button.add-viewer");
const workspace = comp.locator("perspective-workspace");
const viewer = comp.locator("perspective-viewer");
await toggleMount.waitFor();
await addViewer.click();
await addViewer.click();
await addViewer.click();
await page.waitForFunction(
() =>
document.querySelector("perspective-workspace")!.children
.length === 3,
);
await expect(viewer).toHaveCount(3);
await toggleMount.click();
await workspace.waitFor({ state: "detached" });
// TODO: This test gets stuck in CI
await page.waitForTimeout(10);
await toggleMount.click();
await workspace.waitFor();
await page.waitForFunction(
() =>
document.querySelector("perspective-workspace")!.children
.length === 3,
);
await expect(viewer).toHaveCount(3);
});
test("Adding a viewer in single-document mode leaves SDM", async ({
page,
mount,
}) => {
const name = "abcdef";
const comp = await mount(<SingleView name={name} />);
const addViewer = comp.locator("button.add-viewer");
const viewer = comp.locator("perspective-viewer");
const settingsBtn = comp.locator(`perspective-viewer #settings_button`);
await settingsBtn.waitFor();
await addViewer.waitFor();
await addViewer.click();
await page.waitForFunction(
() =>
document.querySelector("perspective-workspace")!.children
.length === 2,
);
expect(await viewer.count()).toBe(2);
await settingsBtn.first().click();
const settingsPanel = viewer.locator("#settings_panel");
await settingsPanel.waitFor();
await addViewer.click();
await page.waitForFunction(
() =>
document.querySelector("perspective-workspace")!.children
.length === 3,
);
expect(await viewer.count()).toBe(3);
await settingsPanel.waitFor({ state: "detached" });
});
});
+154
View File
@@ -0,0 +1,154 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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-dev/workspace";
import {
HTMLPerspectiveWorkspaceElement,
PerspectiveWorkspaceConfig,
} from "@perspective-dev/workspace";
import * as React from "react";
import { PerspectiveWorkspace } from "@perspective-dev/react";
import perspective_viewer from "@perspective-dev/viewer";
import "@perspective-dev/viewer-datagrid";
import "@perspective-dev/viewer-charts";
import "@perspective-dev/workspace";
import * as Workspace from "@perspective-dev/workspace";
import * as perspective from "@perspective-dev/client";
import "@perspective-dev/viewer/dist/css/themes.css";
import "@perspective-dev/workspace/dist/css/pro.css";
import "./index.css";
// @ts-ignore
import SERVER_WASM from "@perspective-dev/server/dist/wasm/perspective-server.wasm?url";
// @ts-ignore
import CLIENT_WASM from "@perspective-dev/viewer/dist/wasm/perspective-viewer.wasm?url";
await Promise.all([
perspective.init_server(fetch(SERVER_WASM)),
perspective_viewer.init_client(fetch(CLIENT_WASM)),
]);
const CLIENT = await perspective.worker();
interface WorkspaceState {
layout: PerspectiveWorkspaceConfig;
mounted: boolean;
}
interface WorkspaceAppProps {
layout: PerspectiveWorkspaceConfig;
onSpecial?: () => void;
}
const WorkspaceApp: React.FC<WorkspaceAppProps> = (props) => {
const [state, setState] = React.useState<WorkspaceState>({
layout: props.layout,
mounted: true,
});
const onClickAddViewer = () => {
const name = window.crypto.randomUUID();
const data = `a,b,c\n${Math.random()},${Math.random()},${Math.random()}`;
CLIENT.table(data, { name });
const nextId = Workspace.genId(state.layout);
const layout = Workspace.addViewer(
state.layout,
{
table: name,
title: name,
},
nextId,
);
setState({
...state,
layout,
});
};
const onClickToggleMount = () =>
setState((old) => ({ ...old, mounted: !state.mounted }));
const onLayoutUpdate = (layout: PerspectiveWorkspaceConfig) => {
setState({ ...state, layout });
};
React.useEffect(() => {
setState((s) => ({
...s,
layout: props.layout,
}));
}, [props.layout]);
return (
<div className="workspace-container">
<div className="workspace-toolbar">
<button className="toggle-mount" onClick={onClickToggleMount}>
Toggle Mount
</button>
<button className="add-viewer" onClick={onClickAddViewer}>
Add Viewer
</button>
{props.onSpecial && (
<button className="special" onClick={props.onSpecial}>
Special Third Button
</button>
)}
</div>
{state.mounted && (
<PerspectiveWorkspace
client={CLIENT}
layout={state.layout}
onLayoutUpdate={onLayoutUpdate}
/>
)}
</div>
);
};
/// Renders the app with a default empty workspace
export const EmptyWorkspace: React.FC = () => {
return (
<WorkspaceApp
layout={{ sizes: [1], viewers: {}, detail: { main: null } }}
/>
);
};
export const SingleView: React.FC<{ name: string }> = ({ name }) => {
const _table = CLIENT.table("a,b,c\n1,2,3", { name });
const layout: PerspectiveWorkspaceConfig = {
sizes: [1],
detail: {
main: {
type: "tab-area",
currentIndex: 0,
widgets: [name],
},
},
viewers: {
[name]: {
table: name,
columns: ["a", "b", "c"],
title: name,
},
},
};
return <WorkspaceApp layout={layout} />;
};
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"strict": true,
"module": "ESNext",
"target": "ESNext",
"declaration": true,
"emitDeclarationOnly": true,
"outDir": "./dist/esm",
"rootDir": "./src",
"moduleResolution": "bundler",
"skipLibCheck": true,
"jsx": "preserve",
},
"files": [
"src/index.tsx"
]
}
+192
View File
@@ -0,0 +1,192 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { WorkerPlugin } from "@perspective-dev/esbuild-plugin/worker.js";
import { build } from "@perspective-dev/esbuild-plugin/build.js";
import { transform as transformCss } from "lightningcss";
import { execSync } from "node:child_process";
import * as fs from "node:fs/promises";
import { GlslMinify as AstGlslMinify } from "webpack-glsl-minify/build/minify.js";
/**
* Pull every identifier the JS code might resolve by string out of the
* unminified shader source so we can hand them to the AST minifier's
* `nomangle` list. `preserveUniforms: true` already covers `uniform`
* declarations, and the minifier auto-preserves `varying` / `in` /
* `out` names. The one category the minifier won't infer is the
* GLSL ES 1.00 `attribute` declaration in vertex shaders — those are
* the names `getAttribLocation` queries, so we surface them here.
*/
function extractPreservedNames(src) {
const names = new Set();
const attrRe =
/\battribute\s+(?:highp\s+|mediump\s+|lowp\s+)?\S+\s+([a-zA-Z_][\w]*)/g;
let m;
while ((m = attrRe.exec(src))) {
names.add(m[1]);
}
return [...names];
}
// AST-based GLSL minifier. Mangles function locals and non-`main`
// function names; preserves uniforms, attributes, varyings, and `gl_*`
// built-ins (the chart impls resolve those by string via
// `getUniformLocation` / `getAttribLocation`). Saves ~7% of the
// bundled shader payload over the prior regex pass, and parses
// `#`-directives natively so the previous newline-preservation hack
// is no longer needed.
const GlslMinify = () => ({
name: "glsl-minify",
setup(build) {
build.onLoad({ filter: /\.glsl$/ }, async (args) => {
const src = await fs.readFile(args.path, "utf8");
if (process.env.PSP_DEBUG) {
return { contents: src, loader: "text" };
}
const minifier = new AstGlslMinify(
{
preserveDefines: true,
preserveUniforms: true,
preserveVariables: false,
nomangle: extractPreservedNames(src),
output: "source",
esModule: false,
stripVersion: false,
},
undefined,
undefined,
);
const { sourceCode } = await minifier.execute(src);
return { contents: sourceCode, loader: "text" };
});
},
});
// CSS is imported via `import style from "...css"` + the `.css: text`
// loader, so the final bundle embeds the source verbatim as a JS
// string literal — esbuild's own minifier doesn't touch string
// contents. Route `.css` loads through lightningcss so the embedded
// CSS is minified (whitespace collapse, selector shortening, value
// normalisation).
//
// Skipped in `PSP_DEBUG` builds to keep source maps useful.
const LightningCssMinify = () => ({
name: "lightningcss-minify",
setup(build) {
build.onLoad({ filter: /\.css$/ }, async (args) => {
const src = await fs.readFile(args.path);
if (process.env.PSP_DEBUG) {
return { contents: src.toString("utf8"), loader: "text" };
}
const { code } = transformCss({
filename: args.path,
code: src,
minify: true,
});
return { contents: code.toString("utf8"), loader: "text" };
});
},
});
const BUILD = [
{
entryPoints: ["src/ts/index.ts"],
define: {
global: "window",
},
plugins: [
NodeModulesExternal(),
WorkerPlugin({
inline: !process.env.PSP_DEBUG,
plugins: [GlslMinify(), LightningCssMinify()],
loader: {
".css": "text",
".glsl": "text",
},
additionalOptions: {
minifyWhitespace: !process.env.PSP_DEBUG,
minifyIdentifiers: !process.env.PSP_DEBUG,
mangleProps: process.env.PSP_DEBUG
? undefined
: /^[_#]|^(plotRect|paddedX(?:Min|Max)|paddedY(?:Min|Max)|dataToPixel|tickColor|labelColor|axisLineColor|gridlineColor|legendText|legendBorder|tooltipBg|tooltipText|tooltipBorder|areaOpacity|heatmapGapPx|sunburstGapPx|gradientStops|seriesPalette|bufferPool)$/,
reserveProps: /(handle_response|__unsafe_open_view)/,
},
}),
GlslMinify(),
LightningCssMinify(),
],
// minifyWhitespace: !process.env.PSP_DEBUG,
// minifyIdentifiers: !process.env.PSP_DEBUG,
// mangleProps: process.env.PSP_DEBUG ? false : /^[_#]/,
format: "esm",
loader: {
".css": "text",
".glsl": "text",
},
outfile: "dist/esm/perspective-viewer-charts.js",
},
{
entryPoints: ["src/ts/index.ts"],
define: {
global: "window",
},
// minifyWhitespace: !process.env.PSP_DEBUG,
// minifyIdentifiers: !process.env.PSP_DEBUG,
// mangleProps: process.env.PSP_DEBUG ? false : /^[_#]/,
plugins: [
WorkerPlugin({
// Inline (Blob URL) for prod, file mode for debug —
// file mode preserves source maps + real paths in
// DevTools so worker breakpoints work.
inline: !process.env.PSP_DEBUG,
plugins: [GlslMinify(), LightningCssMinify()],
loader: {
".css": "text",
".glsl": "text",
},
additionalOptions: {
minifyWhitespace: !process.env.PSP_DEBUG,
minifyIdentifiers: !process.env.PSP_DEBUG,
mangleProps: process.env.PSP_DEBUG
? undefined
: /^[_#]|^(plotRect|paddedX(?:Min|Max)|paddedY(?:Min|Max)|dataToPixel|tickColor|labelColor|axisLineColor|gridlineColor|legendText|legendBorder|tooltipBg|tooltipText|tooltipBorder|areaOpacity|heatmapGapPx|sunburstGapPx|gradientStops|seriesPalette|bufferPool)$/,
reserveProps: /(handle_response|__unsafe_open_view)/,
},
}),
GlslMinify(),
LightningCssMinify(),
],
// minifyWhitespace: !process.env.PSP_DEBUG,
// minifyIdentifiers: !process.env.PSP_DEBUG,
// mangleProps: process.env.PSP_DEBUG ? false : /^[_#]/,
format: "esm",
loader: {
".css": "text",
".glsl": "text",
},
outfile: "dist/cdn/perspective-viewer-charts.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();
+15
View File
@@ -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 });
+47
View File
@@ -0,0 +1,47 @@
{
"name": "@perspective-dev/viewer-charts",
"version": "4.5.2",
"description": "Perspective.js WebGL Plugin",
"unpkg": "./dist/cdn/perspective-viewer-charts.js",
"jsdelivr": "./dist/cdn/perspective-viewer-charts.js",
"type": "module",
"exports": {
".": {
"types": "./dist/esm/index.d.ts",
"default": "./dist/esm/perspective-viewer-charts.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",
"devDependencies": {
"@perspective-dev/client": "workspace:",
"@perspective-dev/viewer": "workspace:",
"@perspective-dev/esbuild-plugin": "workspace:",
"@perspective-dev/test": "workspace:",
"@types/node": "catalog:",
"lightningcss": "catalog:",
"typescript": "catalog:",
"webpack-glsl-minify": "1.5.0"
}
}
@@ -0,0 +1,95 @@
:host {
position: relative;
display: block;
width: 100%;
height: 100%;
overflow: hidden;
font-family: inherit;
/* font-family: var(--psp-interface-monospace--font-family, inherit); */
/* --psp-charts--font-family: var(font-family, inherit); */
--psp-charts--tooltip--color: var(--psp--color);
--psp-charts--tooltip--background: var(--psp--background-color);
--psp-charts--tooltip--border-color: var(--psp-inactive--border-color);
}
.webgl-container {
position: absolute;
top: 6px;
left: 6px;
right: 6px;
bottom: 6px;
}
.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-charts--tooltip--background);
color: var(--psp-charts--tooltip--color);
border: 1px solid var(--psp-charts--tooltip--border-color);
border-radius: 4px;
padding: 4px 12px;
font: 11px var(--psp-charts--font-family);
cursor: pointer;
opacity: 0.7;
transition: opacity 0.15s;
}
.zoom-reset:hover {
opacity: 1;
}
.webgl-tooltip {
position: absolute;
pointer-events: auto;
font: 11px var(--psp-charts--font-family);
background: var(--psp-charts--tooltip--background);
color: var(--psp-charts--tooltip--color);
border: 1px solid var(--psp-charts--tooltip--border-color);
border-radius: 3px;
padding: 3px;
overflow-y: auto;
white-space: pre;
z-index: 10;
line-height: 16px;
}
@@ -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 type { Context2D } from "../charts/canvas-types";
import type { PlotRect } from "../layout/plot-layout";
export const TICK_SIZE = 5;
/**
* One horizontal row of numeric axis ticks + labels at CSS-pixel `axisY`.
* `side` selects tick direction (down into the bottom margin or up into
* the top margin) and the corresponding label baseline. Caller owns
* `strokeStyle`, `fillStyle`, `font`, and `lineWidth`.
*/
export function drawXTickRow(
ctx: Context2D,
plot: PlotRect,
ticks: number[],
axisY: number,
side: "top" | "bottom",
xToPixel: (v: number) => number,
format: (v: number) => string,
): void {
const dir = side === "bottom" ? 1 : -1;
ctx.textAlign = "center";
ctx.textBaseline = side === "bottom" ? "top" : "bottom";
const labelOffset = dir * (TICK_SIZE + 3);
for (const tick of ticks) {
const px = xToPixel(tick);
if (px < plot.x - 1 || px > plot.x + plot.width + 1) {
continue;
}
ctx.beginPath();
ctx.moveTo(px, axisY);
ctx.lineTo(px, axisY + dir * TICK_SIZE);
ctx.stroke();
ctx.fillText(format(tick), px, axisY + labelOffset);
}
}
/**
* One vertical column of numeric axis ticks + labels at CSS-pixel `axisX`.
* `side` selects tick direction (out toward the left or right margin) and
* the corresponding label alignment. Caller owns styling state.
*/
export function drawYTickColumn(
ctx: Context2D,
plot: PlotRect,
ticks: number[],
axisX: number,
side: "left" | "right",
yToPixel: (v: number) => number,
format: (v: number) => string,
): void {
const dir = side === "left" ? -1 : 1;
ctx.textAlign = side === "left" ? "right" : "left";
ctx.textBaseline = "middle";
const labelOffset = dir * (TICK_SIZE + 3);
for (const tick of ticks) {
const py = yToPixel(tick);
if (py < plot.y - 1 || py > plot.y + plot.height + 1) {
continue;
}
ctx.beginPath();
ctx.moveTo(axisX, py);
ctx.lineTo(axisX + dir * TICK_SIZE, py);
ctx.stroke();
ctx.fillText(format(tick), axisX + labelOffset, py);
}
}
/**
* Vertical gridlines at numeric X ticks, clipped to `plot`.
*/
export function drawGridlinesX(
ctx: Context2D,
plot: PlotRect,
ticks: number[],
xToPixel: (v: number) => number,
): void {
for (const tick of ticks) {
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();
}
}
/**
* Horizontal gridlines at numeric Y ticks, clipped to `plot`.
*/
export function drawGridlinesY(
ctx: Context2D,
plot: PlotRect,
ticks: number[],
yToPixel: (v: number) => number,
): void {
for (const tick of ticks) {
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();
}
}
@@ -0,0 +1,374 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { Canvas2D, Context2D } from "../charts/canvas-types";
import { PlotLayout } from "../layout/plot-layout";
import { formatTickValue, formatDateTickValue } from "../layout/ticks";
import { initCanvas } from "./canvas";
import {
renderCategoricalXTicks,
renderCategoricalYTicks,
type CategoricalDomain,
} from "./categorical-axis";
import {
drawGridlinesX,
drawGridlinesY,
drawXTickRow,
drawYTickColumn,
} from "./axis-primitives";
import type { AxisDomain } from "./numeric-axis";
import type { Theme } from "../theme/theme";
function tickFormatter(
domain: AxisDomain,
ticks: number[],
override?: (v: number) => string,
): (v: number) => string {
if (override) {
return override;
}
if (!domain.isDate) {
return formatTickValue;
}
const step = ticks.length > 1 ? ticks[1] - ticks[0] : 0;
return (v: number) => formatDateTickValue(v, step);
}
/**
* Render a numeric axis along the bottom or top of the plot area.
*/
function drawNumericXAxis(
ctx: Context2D,
layout: PlotLayout,
domain: AxisDomain,
ticks: number[],
side: "top" | "bottom",
theme: Theme,
formatter?: (v: number) => string,
): void {
const { labelColor, fontFamily } = theme;
const { plotRect: plot } = layout;
const axisY = side === "bottom" ? plot.y + plot.height : plot.y;
ctx.fillStyle = labelColor;
ctx.font = `11px ${fontFamily}`;
ctx.lineWidth = 1;
drawXTickRow(
ctx,
plot,
ticks,
axisY,
side,
(v) => layout.dataToPixel(v, 0).px,
tickFormatter(domain, ticks, formatter),
);
ctx.font = `13px ${fontFamily}`;
ctx.textAlign = "center";
ctx.textBaseline = "bottom";
ctx.fillText(
domain.label,
plot.x + plot.width / 2,
side === "bottom" ? layout.cssHeight - 2 : 10,
);
}
/**
* Render a numeric Y axis along either the left or right side of the plot
* area. The caller must have already `initCanvas`'d the target canvas.
* Used by bar charts with a categorical X and optional split Y axes.
*/
function drawYAxis(
ctx: Context2D,
layout: PlotLayout,
domain: AxisDomain,
ticks: number[],
side: "left" | "right",
theme: Theme,
formatter?: (v: number) => string,
): void {
const { labelColor, fontFamily } = theme;
const { plotRect: plot } = layout;
const axisX = side === "left" ? plot.x : plot.x + plot.width;
ctx.fillStyle = labelColor;
ctx.font = `11px ${fontFamily}`;
ctx.lineWidth = 1;
drawYTickColumn(
ctx,
plot,
ticks,
axisX,
side,
(v) => layout.dataToPixel(0, v).py,
tickFormatter(domain, ticks, formatter),
);
ctx.font = `13px ${fontFamily}`;
ctx.save();
if (side === "left") {
ctx.translate(14, plot.y + plot.height / 2);
ctx.rotate(-Math.PI / 2);
} else {
ctx.translate(layout.cssWidth - 10, plot.y + plot.height / 2);
ctx.rotate(Math.PI / 2);
}
ctx.textAlign = "center";
ctx.textBaseline = "bottom";
ctx.fillText(domain.label, 0, 0);
ctx.restore();
}
/**
* The category-axis side of a bar chart can render as either a
* stringified hierarchical category axis or a true numeric axis (when
* the single group_by level is date / datetime / integer / float).
* Both shapes flow through `renderBarAxesChrome`; the discriminator is
* the `mode` field.
*/
export type BarCategoryAxis =
| { mode: "category"; domain: CategoricalDomain }
| { mode: "numeric"; domain: AxisDomain; ticks: number[] };
export type BarValueAxis =
| { mode: "category"; domain: CategoricalDomain }
| { mode: "numeric"; domain: AxisDomain; ticks: number[] };
/**
* Render a numeric date-aware axis along the bottom of the plot. Aliases
* the bar-axis bottom variant so heatmap can share the implementation.
*/
export function drawNumericCategoryX(
ctx: Context2D,
layout: PlotLayout,
domain: AxisDomain,
ticks: number[],
theme: Theme,
formatter?: (v: number) => string,
): void {
drawNumericXAxis(ctx, layout, domain, ticks, "bottom", theme, formatter);
}
export function drawNumericCategoryY(
ctx: Context2D,
layout: PlotLayout,
domain: AxisDomain,
ticks: number[],
theme: Theme,
formatter?: (v: number) => string,
): void {
drawYAxis(ctx, layout, domain, ticks, "left", theme, formatter);
}
/**
* Render bar-chart chrome: L-shaped axis lines, a categorical axis
* (bottom for Y Bar, left for X Bar), and one or two numeric axes on
* the opposite sides.
*
* `isHorizontal=true` flips orientation for X Bar: categorical axis on
* the left, numeric axes on the bottom (and top for dual-axis). The
* `altDomain`/`altTicks` arguments always describe the *secondary*
* numeric axis regardless of orientation.
*/
export interface BarAxesFormatters {
/** Formatter for the value (Y in vertical, X in horizontal) axis. */
value?: (v: number) => string;
/** Formatter for the secondary alt value axis. */
alt?: (v: number) => string;
/** Formatter for the numeric category axis (when `catAxis.mode === "numeric"`). */
category?: (v: number) => string;
}
export function renderBarAxesChrome(
canvas: Canvas2D,
catAxis: BarCategoryAxis,
valueAxis: BarValueAxis,
layout: PlotLayout,
theme: Theme,
dpr: number,
altAxis: BarValueAxis | undefined,
isHorizontal = false,
formatters: BarAxesFormatters = {},
): void {
const ctx = initCanvas(canvas, layout, dpr);
if (!ctx) {
return;
}
const { plotRect: plot } = layout;
ctx.strokeStyle = theme.axisLineColor;
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);
if (altAxis) {
if (isHorizontal) {
ctx.moveTo(plot.x, plot.y);
ctx.lineTo(plot.x + plot.width, plot.y);
} else {
ctx.moveTo(plot.x + plot.width, plot.y);
ctx.lineTo(plot.x + plot.width, plot.y + plot.height);
}
}
ctx.stroke();
if (isHorizontal) {
if (catAxis.mode === "category") {
renderCategoricalYTicks(ctx, layout, catAxis.domain, theme);
} else {
drawNumericCategoryY(
ctx,
layout,
catAxis.domain,
catAxis.ticks,
theme,
formatters.category,
);
}
if (valueAxis.mode === "category") {
// Categorical value axis on the bottom: reuse the X
// categorical painter. Slot indices on the layout's X
// domain already place each category at its slot pixel.
renderCategoricalXTicks(ctx, layout, valueAxis.domain, theme);
} else {
drawNumericXAxis(
ctx,
layout,
valueAxis.domain,
valueAxis.ticks,
"bottom",
theme,
formatters.value,
);
}
if (altAxis) {
// Alt-axis painter expects the layout's X domain to match
// the alt domain — temporarily swap in `altDomain.min/max`
// for the duration of the call. Categorical alt has no
// top-side painter; render with the bottom-side painter
// (visual overlap with the primary side — documented
// limitation; user-pinned categorical alt is rare).
if (altAxis.mode === "category") {
renderCategoricalXTicks(ctx, layout, altAxis.domain, theme);
} else {
const origMin = layout.paddedXMin;
const origMax = layout.paddedXMax;
layout.paddedXMin = altAxis.domain.min;
layout.paddedXMax = altAxis.domain.max;
drawNumericXAxis(
ctx,
layout,
altAxis.domain,
altAxis.ticks,
"top",
theme,
formatters.alt,
);
layout.paddedXMin = origMin;
layout.paddedXMax = origMax;
}
}
} else {
if (catAxis.mode === "category") {
renderCategoricalXTicks(ctx, layout, catAxis.domain, theme);
} else {
drawNumericCategoryX(
ctx,
layout,
catAxis.domain,
catAxis.ticks,
theme,
formatters.category,
);
}
if (valueAxis.mode === "category") {
renderCategoricalYTicks(ctx, layout, valueAxis.domain, theme);
} else {
drawYAxis(
ctx,
layout,
valueAxis.domain,
valueAxis.ticks,
"left",
theme,
formatters.value,
);
}
if (altAxis) {
if (altAxis.mode === "category") {
renderCategoricalYTicks(ctx, layout, altAxis.domain, theme);
} else {
const origMin = layout.paddedYMin;
const origMax = layout.paddedYMax;
layout.paddedYMin = altAxis.domain.min;
layout.paddedYMax = altAxis.domain.max;
drawYAxis(
ctx,
layout,
altAxis.domain,
altAxis.ticks,
"right",
theme,
formatters.alt,
);
layout.paddedYMin = origMin;
layout.paddedYMax = origMax;
}
}
}
}
/**
* Render gridlines at the numeric axis ticks. In vertical bar charts
* the gridlines run horizontally at numeric Y ticks; in horizontal bar
* charts they run vertically at numeric X ticks.
*/
export function renderBarGridlines(
canvas: Canvas2D,
layout: PlotLayout,
valueTicks: number[],
theme: Theme,
dpr: number,
isHorizontal = false,
): void {
const ctx = initCanvas(canvas, layout, dpr);
if (!ctx) {
return;
}
ctx.strokeStyle = theme.gridlineColor;
ctx.lineWidth = 1;
if (isHorizontal) {
drawGridlinesX(
ctx,
layout.plotRect,
valueTicks,
(v) => layout.dataToPixel(v, 0).px,
);
} else {
drawGridlinesY(
ctx,
layout.plotRect,
valueTicks,
(v) => layout.dataToPixel(0, v).py,
);
}
}
@@ -0,0 +1,64 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { Canvas2D, Context2D } from "../charts/canvas-types";
import type { PlotLayout } from "../layout/plot-layout";
/**
* Destructive per-frame canvas setup: resize to CSS pixels × DPR,
* clear, and return a DPR-scaled 2D context. Call this exactly once
* per canvas per frame — setting `canvas.width` / `canvas.height`
* always wipes the bitmap and resets the transform, so calling it in
* a per-facet loop wipes every previously-drawn facet.
*
* Faceted renderers call this once per frame and then
* {@link getScaledContext} per facet to obtain the same context
* without re-wiping.
*/
export function initCanvas(
canvas: Canvas2D,
layout: PlotLayout,
dpr: number,
): Context2D | null {
canvas.width = Math.round(layout.cssWidth * dpr);
canvas.height = Math.round(layout.cssHeight * dpr);
const ctx = canvas.getContext("2d") as Context2D;
if (!ctx) {
return null;
}
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, layout.cssWidth, layout.cssHeight);
return ctx;
}
/**
* Non-destructive variant: returns the 2D context with its transform
* forced to `scale(dpr, dpr)` via `setTransform` (idempotent — no
* stacking). Assumes `initCanvas` was already called on this canvas
* this frame; does NOT resize or clear.
*
* Intended for per-facet render helpers that must not wipe the shared
* canvas bitmap mid-frame.
*/
export function getScaledContext(
canvas: Canvas2D,
dpr: number,
): Context2D | null {
const ctx = canvas.getContext("2d") as Context2D;
if (!ctx) {
return null;
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
return ctx;
}
@@ -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). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
/**
* Orientation-neutral helpers shared by the horizontal and vertical
* hierarchical categorical axes. Both axis painters specialize on top of
* this core; the only thing they disagree on is where bracket lines and
* leaf labels land on the plot chrome.
*/
/**
* One run of consecutive equal dictionary indices in a level's `indices`
* array, with the label pre-resolved. Used by the outer-level bracket
* renderer to coalesce a span of contiguous cells into a single labelled
* group.
*/
export interface GroupRun {
startIdx: number;
/**
* Inclusive.
*/
endIdx: number;
label: string;
}
/**
* Single-pass run-length encoding of `indices[startRow..endRow)` keyed
* by `dictionary`. Relies on perspective's guarantee that rows sharing
* an outer-level dictionary entry are emitted contiguously in traversal
* order — equal neighbours always belong to the same span. The emitted
* `label` is a direct reference into `dictionary` (no per-row copy).
*/
export function buildGroupRuns(
indices: Int32Array | ArrayLike<number>,
dictionary: string[],
startRow: number,
endRow: number,
): GroupRun[] {
const runs: GroupRun[] = [];
if (endRow <= startRow) {
return runs;
}
let runStart = startRow;
let runDict = indices[startRow];
for (let r = startRow + 1; r < endRow; r++) {
const d = indices[r];
if (d !== runDict) {
runs.push({
startIdx: runStart,
endIdx: r - 1,
label: dictionary[runDict] ?? "",
});
runStart = r;
runDict = d;
}
}
runs.push({
startIdx: runStart,
endIdx: endRow - 1,
label: dictionary[runDict] ?? "",
});
return runs;
}
/**
* Longest string length in a dictionary. O(dictSize), not O(numRows).
* Drives the rotation decision on the leaf level of the X axis and the
* column-width decision on the Y axis.
*/
export function maxDictLength(dictionary: string[]): number {
let m = 0;
for (const s of dictionary) {
if (s != null && s.length > m) {
m = s.length;
}
}
return m;
}
/**
* Filter a precomputed `runs` array to those whose index range
* intersects `[visMin, visMax]` (inclusive on both sides). Runs that
* straddle an endpoint are clipped so the caller sees `startIdx`/
* `endIdx` pinned to the visible slice — this matches the legacy
* `buildGroupRuns(indices, visMin, visMax + 1)` return shape.
*/
export function runsInRange(
runs: GroupRun[],
visMin: number,
visMax: number,
): GroupRun[] {
if (visMax < visMin) {
return [];
}
const out: GroupRun[] = [];
for (const run of runs) {
if (run.endIdx < visMin || run.startIdx > visMax) {
continue;
}
const startIdx = run.startIdx < visMin ? visMin : run.startIdx;
const endIdx = run.endIdx > visMax ? visMax : run.endIdx;
if (startIdx === run.startIdx && endIdx === run.endIdx) {
out.push(run);
} else {
out.push({ startIdx, endIdx, label: run.label });
}
}
return out;
}
@@ -0,0 +1,714 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { Context2D } from "../charts/canvas-types";
import { PlotLayout } from "../layout/plot-layout";
import {
labelRect,
rectContained,
rectsOverlap,
rotatedLabelsOverlap,
truncateLabel,
} from "./label-geometry";
import { type GroupRun, runsInRange } from "./categorical-axis-core";
import type { Theme } from "../theme/theme";
export interface CategoricalLevel {
labels: string[];
runs: GroupRun[];
maxLabelChars: number;
}
export interface CategoricalDomain {
levels: CategoricalLevel[];
numRows: number;
levelLabels: string[];
}
interface LevelTickLayout {
size: number;
rotation: 0 | 45 | 90;
}
const LEAF_LEVEL_HEIGHT = 25;
const OUTER_LEVEL_HEIGHT = 22;
const TICK_SIZE = 5;
const LABEL_FONT_PX = 11;
const LABEL_LINE_HEIGHT = 14;
const LEAF_LEVEL_WIDTH_MIN = 55;
const OUTER_LEVEL_WIDTH = 60;
const LEAF_LABEL_PADDING = 10;
function categoryIndexToPixelX(layout: PlotLayout, index: number): number {
return layout.dataToPixel(index, 0).px;
}
function categoryIndexToPixelY(layout: PlotLayout, index: number): number {
return layout.dataToPixel(0, index).py;
}
function leafLevelLayout(
numRows: number,
longestCharCount: number,
plotWidth: number,
): LevelTickLayout {
const budget = Math.max(0, plotWidth - 100);
if (numRows * 16 > budget) {
return { size: longestCharCount * 6.62 + 10, rotation: 90 };
}
if (numRows * (longestCharCount * 6 + 10) > budget) {
return { size: longestCharCount * 4 + 20, rotation: 45 };
}
return { size: LEAF_LEVEL_HEIGHT, rotation: 0 };
}
export function measureCategoricalLevels(
domain: CategoricalDomain,
plotWidth: number,
): LevelTickLayout[] {
const L = domain.levels.length;
const result: LevelTickLayout[] = [];
for (let l = 0; l < L; l++) {
const lev = domain.levels[l];
if (l === L - 1) {
result.push(
leafLevelLayout(domain.numRows, lev.maxLabelChars, plotWidth),
);
} else {
result.push({ size: OUTER_LEVEL_HEIGHT, rotation: 0 });
}
}
return result;
}
export function measureCategoricalLevelWidths(
domain: CategoricalDomain,
): number[] {
const L = domain.levels.length;
const widths: number[] = [];
const charPx = 6.2;
for (let l = 0; l < L; l++) {
if (l === L - 1) {
const longest = domain.levels[l].maxLabelChars;
widths.push(
Math.max(
LEAF_LEVEL_WIDTH_MIN,
longest * charPx + LEAF_LABEL_PADDING,
),
);
} else {
widths.push(OUTER_LEVEL_WIDTH);
}
}
return widths;
}
function sumNumeric(arr: number[]): number {
let t = 0;
for (const v of arr) {
t += v;
}
return t;
}
export function measureCategoricalAxisHeight(
domain: CategoricalDomain,
plotWidth: number,
): number {
if (domain.numRows === 0 || domain.levels.length === 0) {
return 24;
}
return sumNumeric(
measureCategoricalLevels(domain, plotWidth).map((l) => l.size),
);
}
export function measureCategoricalAxisWidth(domain: CategoricalDomain): number {
if (domain.numRows === 0 || domain.levels.length === 0) {
return 55;
}
return sumNumeric(measureCategoricalLevelWidths(domain));
}
function selectLeafTickIndices(
visMin: number,
visMax: number,
plotWidth: number,
avgLabelPx: number,
): number[] {
const count = visMax - visMin + 1;
if (count <= 0) {
return [];
}
const maxLabels = Math.max(1, Math.floor(plotWidth / avgLabelPx));
if (count <= maxLabels) {
const out: number[] = [];
for (let i = visMin; i <= visMax; i++) {
out.push(i);
}
return out;
}
const step = Math.ceil(count / maxLabels);
const out: number[] = [];
for (let i = visMin; i <= visMax; i += step) {
out.push(i);
}
return out;
}
function getLeafText(level: CategoricalLevel, row: number): string {
return level.labels[row] ?? "";
}
/**
* Visible row window from a (possibly zoomed) padded data range. `flip`
* accounts for the categorical Y-axis storing the domain inverted so
* that catIdx=0 renders at the top.
*/
function visibleRowWindow(
numRows: number,
a: number,
b: number,
flip: boolean,
): [number, number] | null {
const lo = flip ? Math.min(a, b) : a;
const hi = flip ? Math.max(a, b) : b;
const visMin = Math.max(0, Math.ceil(lo));
const visMax = Math.min(numRows - 1, Math.floor(hi));
return visMax < visMin ? null : [visMin, visMax];
}
/**
* Compute clipped main-axis spans for each visible run. Used by both
* outer-level renderers; the caller supplies the projection from
* row-index to the relevant pixel coordinate (X or Y) and the plot
* extent along the main axis.
*/
function clippedRuns(
runs: GroupRun[],
pixelOf: (idx: number) => number,
mainStart: number,
mainEnd: number,
): Array<{
run: GroupRun;
nearEdge: number;
farEdge: number;
nearClip: number;
farClip: number;
}> {
const out = [];
for (const run of runs) {
const nearEdge = pixelOf(run.startIdx - 0.5);
const farEdge = pixelOf(run.endIdx + 0.5);
const nearClip = Math.max(mainStart, Math.min(nearEdge, farEdge));
const farClip = Math.min(mainEnd, Math.max(nearEdge, farEdge));
if (farClip > nearClip) {
out.push({ run, nearEdge, farEdge, nearClip, farClip });
}
}
return out;
}
export function renderCategoricalXTicks(
ctx: Context2D,
layout: PlotLayout,
domain: CategoricalDomain,
theme: Theme,
): void {
if (domain.numRows === 0 || domain.levels.length === 0) {
return;
}
const { tickColor, labelColor, fontFamily } = theme;
const { plotRect: plot } = layout;
const baselineY = plot.y + plot.height;
ctx.strokeStyle = tickColor;
ctx.fillStyle = tickColor;
ctx.lineWidth = 1;
ctx.font = `${LABEL_FONT_PX}px ${fontFamily}`;
const levelLayouts = measureCategoricalLevels(domain, plot.width);
const win = visibleRowWindow(
domain.numRows,
layout.paddedXMin,
layout.paddedXMax,
false,
);
if (!win) {
return;
}
const [visMin, visMax] = win;
const L = domain.levels.length;
let yCursor = baselineY;
for (let l = L - 1; l >= 0; l--) {
const level = domain.levels[l];
const lay = levelLayouts[l];
const rowTop = yCursor;
yCursor += lay.size;
if (l === L - 1) {
renderLeafLevel(
ctx,
layout,
level,
visMin,
visMax,
rowTop,
lay,
fontFamily,
tickColor,
);
} else {
renderOuterLevel(
ctx,
layout,
level,
visMin,
visMax,
rowTop,
fontFamily,
tickColor,
);
}
}
const axisLabel = domain.levelLabels.filter((s) => !!s).join(" / ");
if (axisLabel) {
ctx.fillStyle = labelColor;
ctx.font = `13px ${fontFamily}`;
ctx.textAlign = "center";
ctx.textBaseline = "bottom";
ctx.fillText(axisLabel, plot.x + plot.width / 2, layout.cssHeight - 2);
}
}
function renderLeafLevel(
ctx: Context2D,
layout: PlotLayout,
level: CategoricalLevel,
visMin: number,
visMax: number,
rowTop: number,
lay: LevelTickLayout,
fontFamily: string,
tickColor: string,
): void {
const { plotRect: plot } = layout;
const avgCharWidth = 6.2;
const avgLabelPx = Math.max(
40,
Math.min(level.maxLabelChars * avgCharWidth + 8, plot.width / 2),
);
const tickRows =
lay.rotation === 0
? selectLeafTickIndices(visMin, visMax, plot.width, avgLabelPx)
: leafRowsForRotated(visMin, visMax);
ctx.strokeStyle = tickColor;
ctx.fillStyle = tickColor;
ctx.beginPath();
for (const r of tickRows) {
const px = categoryIndexToPixelX(layout, r);
if (px < plot.x - 1 || px > plot.x + plot.width + 1) {
continue;
}
ctx.moveTo(px, rowTop);
ctx.lineTo(px, rowTop + TICK_SIZE);
}
ctx.stroke();
ctx.font = `${LABEL_FONT_PX}px ${fontFamily}`;
const labelY = rowTop + TICK_SIZE + 3;
const boundsRect = {
x: plot.x,
width: plot.width,
y: rowTop,
height: 9999,
};
const kept: {
x: number;
y: number;
width: number;
height: number;
}[] = [];
for (const r of tickRows) {
const px = categoryIndexToPixelX(layout, r);
if (px < plot.x - 1 || px > plot.x + plot.width + 1) {
continue;
}
const text = getLeafText(level, r);
if (!text) {
continue;
}
const textWidth = ctx.measureText(text).width;
const rect = labelRect(
px,
labelY,
textWidth,
LABEL_LINE_HEIGHT,
lay.rotation,
);
if (!rectContained(rect, boundsRect)) {
continue;
}
if (lay.rotation === 0) {
if (kept.some((r) => rectsOverlap(r, rect))) {
continue;
}
} else {
if (kept.some((r) => rotatedLabelsOverlap(r, rect))) {
continue;
}
}
kept.push(rect);
drawLabel(ctx, text, px, labelY, lay.rotation, "center");
}
}
function renderOuterLevel(
ctx: Context2D,
layout: PlotLayout,
level: CategoricalLevel,
visMin: number,
visMax: number,
rowTop: number,
fontFamily: string,
tickColor: string,
): void {
const { plotRect: plot } = layout;
const runs = runsInRange(level.runs, visMin, visMax);
if (runs.length === 0) {
return;
}
const clipped = clippedRuns(
runs,
(idx) => categoryIndexToPixelX(layout, idx),
plot.x,
plot.x + plot.width,
);
if (clipped.length === 0) {
return;
}
ctx.strokeStyle = tickColor;
ctx.fillStyle = tickColor;
ctx.beginPath();
for (const c of clipped) {
ctx.moveTo(c.nearClip, rowTop + 3);
ctx.lineTo(c.farClip, rowTop + 3);
ctx.moveTo(c.nearEdge, rowTop);
ctx.lineTo(c.nearEdge, rowTop + 3);
ctx.moveTo(c.farEdge, rowTop);
ctx.lineTo(c.farEdge, rowTop + 3);
}
ctx.stroke();
ctx.font = `${LABEL_FONT_PX}px ${fontFamily}`;
const labelY = rowTop + 3 + 4;
const kept: {
x: number;
y: number;
width: number;
height: number;
}[] = [];
const boundsRect = {
x: plot.x,
width: plot.width,
y: rowTop,
height: 9999,
};
for (const c of clipped) {
const cx = (c.nearClip + c.farClip) / 2;
const text = c.run.label;
if (!text) {
continue;
}
const available = c.farClip - c.nearClip - 4;
const display = truncateLabel(ctx, text, available);
if (!display) {
continue;
}
const textWidth = ctx.measureText(display).width;
const rect = labelRect(cx, labelY, textWidth, LABEL_LINE_HEIGHT, 0);
if (!rectContained(rect, boundsRect)) {
continue;
}
if (kept.some((r) => rectsOverlap(r, rect))) {
continue;
}
kept.push(rect);
drawLabel(ctx, display, cx, labelY, 0, "center");
}
}
function leafRowsForRotated(visMin: number, visMax: number): number[] {
const out: number[] = [];
for (let i = visMin; i <= visMax; i++) {
out.push(i);
}
return out;
}
export function renderCategoricalYTicks(
ctx: Context2D,
layout: PlotLayout,
domain: CategoricalDomain,
theme: Theme,
): void {
if (domain.numRows === 0 || domain.levels.length === 0) {
return;
}
const { tickColor, labelColor, fontFamily } = theme;
const { plotRect: plot } = layout;
const axisX = plot.x;
ctx.strokeStyle = tickColor;
ctx.fillStyle = tickColor;
ctx.lineWidth = 1;
ctx.font = `${LABEL_FONT_PX}px ${fontFamily}`;
const widths = measureCategoricalLevelWidths(domain);
const win = visibleRowWindow(
domain.numRows,
layout.paddedYMin,
layout.paddedYMax,
true,
);
if (!win) {
return;
}
const [visMin, visMax] = win;
const L = domain.levels.length;
let xCursor = axisX;
for (let l = L - 1; l >= 0; l--) {
const level = domain.levels[l];
const w = widths[l];
const colRight = xCursor;
xCursor -= w;
if (l === L - 1) {
renderLeafLevelY(
ctx,
layout,
level,
visMin,
visMax,
colRight,
fontFamily,
tickColor,
);
} else {
renderOuterLevelY(
ctx,
layout,
level,
visMin,
visMax,
colRight,
w,
fontFamily,
tickColor,
);
}
}
const axisLabel = domain.levelLabels.filter((s) => !!s).join(" / ");
if (axisLabel) {
ctx.fillStyle = labelColor;
ctx.font = `13px ${fontFamily}`;
ctx.save();
ctx.translate(14, plot.y + plot.height / 2);
ctx.rotate(-Math.PI / 2);
ctx.textAlign = "center";
ctx.textBaseline = "bottom";
ctx.fillText(axisLabel, 0, 0);
ctx.restore();
}
}
function renderLeafLevelY(
ctx: Context2D,
layout: PlotLayout,
level: CategoricalLevel,
visMin: number,
visMax: number,
colRight: number,
fontFamily: string,
tickColor: string,
): void {
const { plotRect: plot } = layout;
const avgLabelHeight = LABEL_LINE_HEIGHT + 4;
const count = visMax - visMin + 1;
const maxLabels = Math.max(1, Math.floor(plot.height / avgLabelHeight));
const step = count <= maxLabels ? 1 : Math.ceil(count / maxLabels);
ctx.strokeStyle = tickColor;
ctx.fillStyle = tickColor;
ctx.beginPath();
for (let r = visMin; r <= visMax; r += step) {
const py = categoryIndexToPixelY(layout, r);
if (py < plot.y - 1 || py > plot.y + plot.height + 1) {
continue;
}
ctx.moveTo(colRight - TICK_SIZE, py);
ctx.lineTo(colRight, py);
}
ctx.stroke();
ctx.font = `${LABEL_FONT_PX}px ${fontFamily}`;
ctx.textAlign = "right";
ctx.textBaseline = "middle";
for (let r = visMin; r <= visMax; r += step) {
const py = categoryIndexToPixelY(layout, r);
if (py < plot.y - 1 || py > plot.y + plot.height + 1) {
continue;
}
const text = getLeafText(level, r);
if (!text) {
continue;
}
ctx.fillText(text, colRight - TICK_SIZE - 3, py);
}
}
function renderOuterLevelY(
ctx: Context2D,
layout: PlotLayout,
level: CategoricalLevel,
visMin: number,
visMax: number,
colRight: number,
colWidth: number,
fontFamily: string,
tickColor: string,
): void {
const { plotRect: plot } = layout;
const runs = runsInRange(level.runs, visMin, visMax);
if (runs.length === 0) {
return;
}
const clipped = clippedRuns(
runs,
(idx) => categoryIndexToPixelY(layout, idx),
plot.y,
plot.y + plot.height,
);
if (clipped.length === 0) {
return;
}
ctx.strokeStyle = tickColor;
ctx.fillStyle = tickColor;
const bracketX = colRight - 3;
ctx.beginPath();
for (const c of clipped) {
ctx.moveTo(bracketX, c.nearClip);
ctx.lineTo(bracketX, c.farClip);
ctx.moveTo(bracketX, c.nearEdge);
ctx.lineTo(colRight, c.nearEdge);
ctx.moveTo(bracketX, c.farEdge);
ctx.lineTo(colRight, c.farEdge);
}
ctx.stroke();
ctx.font = `${LABEL_FONT_PX}px ${fontFamily}`;
ctx.textAlign = "right";
ctx.textBaseline = "middle";
for (const c of clipped) {
const cy = (c.nearClip + c.farClip) / 2;
const text = c.run.label;
if (!text) {
continue;
}
const available = colWidth - 6;
const display = truncateLabel(ctx, text, available);
if (!display) {
continue;
}
ctx.fillText(display, bracketX - 3, cy);
}
}
function drawLabel(
ctx: Context2D,
text: string,
px: number,
py: number,
rotation: 0 | 45 | 90,
anchor: "center" | "end",
): void {
if (rotation === 0) {
ctx.textAlign = anchor === "end" ? "right" : "center";
ctx.textBaseline = "top";
ctx.fillText(text, px, py);
return;
}
ctx.save();
ctx.translate(px, py);
ctx.rotate((-rotation * Math.PI) / 180);
ctx.textAlign = "right";
ctx.textBaseline = "middle";
ctx.fillText(text, -2, 0);
ctx.restore();
}
@@ -0,0 +1,42 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { Canvas2D, Context2D } from "../charts/canvas-types";
import type { Theme } from "../theme/theme";
/**
* Paint a single facet's title strip — one line of centered text in the
* caller-supplied `rect`. Shared by every faceted chart family
* (cartesian, heatmap, …) so the title typography stays uniform.
*/
export function drawFacetTitle(
canvas: Canvas2D,
label: string,
rect: { x: number; y: number; width: number; height: number },
theme: Theme,
dpr: number,
): void {
const ctx = canvas.getContext("2d") as Context2D | null;
if (!ctx) {
return;
}
ctx.save();
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.scale(dpr, dpr);
ctx.font = `11px ${theme.fontFamily}`;
ctx.fillStyle = theme.labelColor;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(label, rect.x + rect.width / 2, rect.y + rect.height / 2);
ctx.restore();
}
@@ -0,0 +1,188 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
/**
* Generic pixel-space label geometry helpers shared by the axis,
* legend, and tooltip overlays.
*
* All rectangles are in CSS pixels, origin top-left, Y-axis pointing down.
*/
import type { Context2D } from "../charts/canvas-types";
export interface Rect {
x: number;
y: number;
width: number;
height: number;
}
export type Rotation = 0 | 45 | 90;
/**
* Bounding rectangle of a text label anchored at `(cx, cy)`, accounting for
* rotation. Matches d3fc's approximation of rotated text bounds.
*/
export function labelRect(
cx: number,
cy: number,
textWidth: number,
textHeight: number,
rotation: Rotation,
): Rect {
if (rotation === 0) {
return {
x: cx - textWidth / 2,
y: cy,
width: textWidth,
height: textHeight,
};
}
if (rotation === 90) {
return {
x: cx - textHeight / 2,
y: cy,
width: textHeight,
height: textWidth,
};
}
const w = (textWidth + textHeight) / Math.SQRT2;
return { x: cx - w, y: cy, width: w, height: w };
}
export function rectsOverlap(a: Rect, b: Rect): boolean {
return (
a.x <= b.x + b.width &&
b.x <= a.x + a.width &&
a.y <= b.y + b.height &&
b.y <= a.y + a.height
);
}
/**
* Rotated-label overlap heuristic from d3fc: for steeply-rotated labels
* the right edge of the previous box must precede the right edge of the
* next (plus a small gap).
*/
export function rotatedLabelsOverlap(a: Rect, b: Rect): boolean {
return a.x + a.width + 14 > b.x + b.width;
}
export function rectContained(inner: Rect, outer: Rect): boolean {
return (
inner.x >= outer.x &&
inner.x + inner.width <= outer.x + outer.width &&
inner.y >= outer.y &&
inner.y + inner.height <= outer.y + outer.height
);
}
/**
* Truncate `label` with a trailing ellipsis so the rendered width fits
* within `maxWidth`. Returns "" when even one character would overflow.
*/
export function truncateLabel(
ctx: Context2D,
label: string,
maxWidth: number,
): string {
if (maxWidth <= 0) {
return "";
}
if (ctx.measureText(label).width <= maxWidth) {
return label;
}
let lo = 0;
let hi = label.length;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
const candidate = label.slice(0, mid) + "…";
if (ctx.measureText(candidate).width <= maxWidth) {
lo = mid;
} else {
hi = mid - 1;
}
}
return lo === 0 ? "" : label.slice(0, lo) + "…";
}
/**
* Word-wrap `text` into at most `maxLines` lines, each fitting within
* `maxWidth`. Breaks at the last whitespace before the fit boundary
* when possible, falls back to a hard character break otherwise. The
* final line is ellipsis-truncated via {@link truncateLabel} if it
* still doesn't fit. Returns `[]` when nothing meaningful fits (only
* one line of ≤ 2 chars after wrapping).
*/
export function wrapLabel(
ctx: Context2D,
text: string,
maxWidth: number,
maxLines: number,
): string[] {
if (maxLines <= 0 || maxWidth <= 0) {
return [];
}
if (ctx.measureText(text).width <= maxWidth) {
return [text];
}
const lines: string[] = [];
let remaining = text;
while (remaining.length > 0 && lines.length < maxLines) {
const isLastLine = lines.length === maxLines - 1;
let fitLen = remaining.length;
while (
fitLen > 0 &&
ctx.measureText(remaining.slice(0, fitLen)).width > maxWidth
) {
fitLen--;
}
if (fitLen === 0) {
fitLen = 1;
}
if (fitLen === remaining.length) {
lines.push(remaining);
break;
}
let breakAt = fitLen;
const spaceIdx = remaining.lastIndexOf(" ", fitLen);
if (spaceIdx > 0) {
breakAt = spaceIdx;
}
if (isLastLine) {
lines.push(truncateLabel(ctx, remaining, maxWidth));
break;
}
lines.push(remaining.slice(0, breakAt));
remaining = remaining.slice(breakAt).trimStart();
}
if (lines.length === 1 && lines[0].length <= 2) {
return [];
}
return lines;
}
@@ -0,0 +1,218 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { Canvas2D, Context2D } from "../charts/canvas-types";
import type { PlotLayout, PlotRect } from "../layout/plot-layout";
import { formatTickValue } from "../layout/ticks";
import {
colorValueToT,
sampleGradient,
type GradientStop,
} from "../theme/gradient";
import type { Theme } from "../theme/theme";
function rgbCss(c: [number, number, number, number]): string {
return `rgb(${Math.round(c[0] * 255)},${Math.round(c[1] * 255)},${Math.round(c[2] * 255)})`;
}
/**
* Render a vertical color gradient legend on the Canvas2D overlay.
* Only call when a color column is active. When `colorDomain` crosses
* zero the 50% stop (sign pivot) is annotated with a tick + `0` label.
*
* Per-facet wrapper; computes the anchor from `layout` and delegates
* to {@link renderLegendAt}. Facet grids render one shared gradient
* legend and pass an explicit rect to `renderLegendAt` directly.
*/
export function renderLegend(
canvas: Canvas2D,
layout: PlotLayout,
colorDomain: { min: number; max: number; label: string },
stops: GradientStop[],
theme: Theme,
formatter?: (v: number) => string,
): void {
const rect: PlotRect = {
x: layout.plotRect.x + layout.plotRect.width + 12,
y: layout.margins.top + 20,
width: Math.max(
1,
layout.cssWidth - layout.plotRect.x - layout.plotRect.width - 12,
),
height: Math.max(1, layout.plotRect.height),
};
renderLegendAt(canvas, rect, colorDomain, stops, theme, formatter);
}
/**
* Render a gradient legend at an explicit canvas-absolute rect.
* Used by facet grids that paint one legend for the whole grid and
* by single-plot charts through {@link renderLegend}.
*/
export function renderLegendAt(
canvas: Canvas2D,
rect: PlotRect,
colorDomain: { min: number; max: number; label: string },
stops: GradientStop[],
theme: Theme,
formatter: (v: number) => string = formatTickValue,
): void {
const ctx = canvas.getContext("2d") as Context2D | null;
if (!ctx) {
return;
}
const textColor = theme.legendText;
const borderColor = theme.legendBorder;
const fontFamily = theme.fontFamily;
const barWidth = 16;
const barHeight = Math.min(120, rect.height * 0.4);
const x = rect.x;
const y = rect.y;
ctx.fillStyle = textColor;
ctx.font = `9px ${fontFamily}`;
ctx.textAlign = "left";
ctx.textBaseline = "bottom";
ctx.fillText(colorDomain.label, x, y - 4);
// Paint the gradient by walking `colorDomain.min..max` top→bottom and
// feeding each value through `colorValueToT` so the legend matches the
// sign-aware mapping used by the GPU / treemap paths.
const topVal = colorDomain.max;
const bottomVal = colorDomain.min;
const gradient = ctx.createLinearGradient(0, y, 0, y + barHeight);
const SAMPLES = 16;
for (let i = 0; i <= SAMPLES; i++) {
const offset = i / SAMPLES;
const v = topVal + offset * (bottomVal - topVal);
const t = colorValueToT(v, colorDomain.min, colorDomain.max);
const rgba = sampleGradient(stops, t);
gradient.addColorStop(offset, rgbCss(rgba));
}
ctx.fillStyle = gradient;
ctx.fillRect(x, y, barWidth, barHeight);
ctx.strokeStyle = borderColor;
ctx.lineWidth = 1;
ctx.strokeRect(x, y, barWidth, barHeight);
ctx.fillStyle = textColor;
ctx.font = `10px ${fontFamily}`;
ctx.textAlign = "left";
ctx.textBaseline = "middle";
const labelX = x + barWidth + 5;
ctx.fillText(formatter(colorDomain.max), labelX, y + 2);
ctx.fillText(
formatter((colorDomain.min + colorDomain.max) / 2),
labelX,
y + barHeight / 2,
);
ctx.fillText(formatter(colorDomain.min), labelX, y + barHeight - 2);
// Sign-pivot marker when the data crosses zero: a small tick on the
// right edge of the bar + a "0" label.
if (colorDomain.min < 0 && colorDomain.max > 0) {
const zeroOffset =
(colorDomain.max - 0) / (colorDomain.max - colorDomain.min);
const zeroY = y + zeroOffset * barHeight;
ctx.strokeStyle = textColor;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(x + barWidth, zeroY);
ctx.lineTo(x + barWidth + 4, zeroY);
ctx.stroke();
ctx.fillStyle = textColor;
ctx.fillText("0", labelX, zeroY);
}
}
/**
* Render a categorical legend with discrete colored swatches.
* Used when split_by or string color columns produce distinct categories.
*
* The per-facet wrapper; computes the anchor from `layout` and delegates
* to {@link renderCategoricalLegendAt}. Facet grids that render one
* shared legend pass an explicit rect to `renderCategoricalLegendAt`
* directly.
*/
export function renderCategoricalLegend(
canvas: Canvas2D,
layout: PlotLayout,
labels: Map<string, number>,
palette: [number, number, number][],
theme: Theme,
): void {
const rect: PlotRect = {
x: layout.plotRect.x + layout.plotRect.width + 12,
y: layout.margins.top + 10,
width: Math.max(
1,
layout.cssWidth - layout.plotRect.x - layout.plotRect.width - 12,
),
height: Math.max(1, layout.plotRect.height),
};
renderCategoricalLegendAt(canvas, rect, labels, palette, theme);
}
/**
* Render a categorical legend at an explicit canvas-absolute rect.
* Used by facet grids that paint one legend for the whole grid and by
* single-plot charts through {@link renderCategoricalLegend}.
*/
export function renderCategoricalLegendAt(
canvas: Canvas2D,
rect: PlotRect,
labels: Map<string, number>,
palette: [number, number, number][],
theme: Theme,
): void {
const ctx = canvas.getContext("2d") as Context2D | null;
if (!ctx) {
return;
}
if (labels.size === 0) {
return;
}
const textColor = theme.legendText;
const fontFamily = theme.fontFamily;
const swatchSize = 10;
const lineHeight = 18;
const x = rect.x;
let y = rect.y + lineHeight / 2;
ctx.font = `11px ${fontFamily}`;
ctx.textAlign = "left";
ctx.textBaseline = "middle";
for (const [label, idx] of labels) {
if (y + swatchSize / 2 > rect.y + rect.height) {
break;
}
const color = palette[idx] ??
palette[idx % palette.length] ?? [0, 0, 0];
ctx.fillStyle = `rgb(${Math.round(color[0] * 255)},${Math.round(color[1] * 255)},${Math.round(color[2] * 255)})`;
ctx.fillRect(x, y - swatchSize / 2, swatchSize, swatchSize);
ctx.fillStyle = textColor;
ctx.fillText(label, x + swatchSize + 6, y);
y += lineHeight;
}
}
@@ -0,0 +1,353 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { Canvas2D, Context2D } from "../charts/canvas-types";
import { PlotLayout, type PlotRect } from "../layout/plot-layout";
import {
computeNiceTicks,
formatTickValue,
formatDateTickValue,
} from "../layout/ticks";
import { getScaledContext } from "./canvas";
import {
drawGridlinesX,
drawGridlinesY,
drawXTickRow,
drawYTickColumn,
} from "./axis-primitives";
import type { Theme } from "../theme/theme";
export interface AxisDomain {
min: number;
max: number;
label: string;
isDate?: boolean;
}
export interface TickResult {
xTicks: number[];
yTicks: number[];
}
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),
};
}
export function renderGridlines(
canvas: Canvas2D,
layout: PlotLayout,
xTicks: number[],
yTicks: number[],
theme: Theme,
dpr: number,
): void {
const ctx = getScaledContext(canvas, dpr);
if (!ctx) {
return;
}
const { plotRect: plot } = layout;
ctx.strokeStyle = theme.gridlineColor;
ctx.lineWidth = 1;
drawGridlinesX(ctx, plot, xTicks, (v) => layout.dataToPixel(v, 0).px);
drawGridlinesY(ctx, plot, yTicks, (v) => layout.dataToPixel(0, v).py);
}
function tickFmt(
domain: AxisDomain,
ticks: number[],
override?: (v: number) => string,
): (v: number) => string {
if (override) {
return override;
}
const step = ticks.length > 1 ? ticks[1] - ticks[0] : 0;
return domain.isDate
? (v: number) => formatDateTickValue(v, step)
: formatTickValue;
}
/**
* Shared core for X-axis rendering used by both per-cell and outer-band
* variants. `axisY` is the pixel Y of the axis line; `band` defines the
* span of that line. `labelBand` (when label-rendering is requested)
* gives the box used to position/center the axis label below it.
*/
function renderXAxisCore(
ctx: Context2D,
xDomain: AxisDomain,
xTicks: number[],
layouts: PlotLayout[],
axisY: number,
band: { x: number; width: number },
theme: Theme,
label: { cx: number; baselineY: number } | null,
formatter?: (v: number) => string,
): void {
const { tickColor, labelColor, axisLineColor, fontFamily } = theme;
const fmt = tickFmt(xDomain, xTicks, formatter);
ctx.strokeStyle = axisLineColor;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(band.x, axisY);
ctx.lineTo(band.x + band.width, axisY);
ctx.stroke();
ctx.fillStyle = tickColor;
ctx.strokeStyle = tickColor;
ctx.font = `11px ${fontFamily}`;
for (const layout of layouts) {
drawXTickRow(
ctx,
layout.plotRect,
xTicks,
axisY,
"bottom",
(v) => layout.dataToPixel(v, 0).px,
fmt,
);
}
if (label && xDomain.label) {
ctx.fillStyle = labelColor;
ctx.font = `13px ${fontFamily}`;
ctx.textAlign = "center";
ctx.textBaseline = "bottom";
ctx.fillText(xDomain.label, label.cx, label.baselineY);
}
}
/**
* Shared core for Y-axis rendering. `axisX` is the pixel X of the axis
* line; `band` defines its vertical span. `label` (when set) gives the
* pivot point for the rotated axis label.
*/
function renderYAxisCore(
ctx: Context2D,
yDomain: AxisDomain,
yTicks: number[],
layouts: PlotLayout[],
axisX: number,
band: { y: number; height: number },
theme: Theme,
label: { pivotY: number } | null,
formatter?: (v: number) => string,
): void {
const { tickColor, labelColor, axisLineColor, fontFamily } = theme;
const fmt = tickFmt(yDomain, yTicks, formatter);
ctx.strokeStyle = axisLineColor;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(axisX, band.y);
ctx.lineTo(axisX, band.y + band.height);
ctx.stroke();
ctx.fillStyle = tickColor;
ctx.strokeStyle = tickColor;
ctx.font = `11px ${fontFamily}`;
for (const layout of layouts) {
drawYTickColumn(
ctx,
layout.plotRect,
yTicks,
axisX,
"left",
(v) => layout.dataToPixel(0, v).py,
fmt,
);
}
if (label && yDomain.label) {
ctx.fillStyle = labelColor;
ctx.font = `13px ${fontFamily}`;
ctx.save();
ctx.translate(14, label.pivotY);
ctx.rotate(-Math.PI / 2);
ctx.textAlign = "center";
ctx.textBaseline = "bottom";
ctx.fillText(yDomain.label, 0, 0);
ctx.restore();
}
}
export function renderCellXAxis(
canvas: Canvas2D,
xDomain: AxisDomain,
layout: PlotLayout,
xTicks: number[],
theme: Theme,
hasLabel: boolean,
dpr: number,
formatter?: (v: number) => string,
): void {
const ctx = getScaledContext(canvas, dpr);
if (!ctx) {
return;
}
const { plotRect: plot } = layout;
renderXAxisCore(
ctx,
xDomain,
xTicks,
[layout],
plot.y + plot.height,
plot,
theme,
hasLabel
? {
cx: plot.x + plot.width / 2,
baselineY: layout.cssHeight - 2,
}
: null,
formatter,
);
}
export function renderCellYAxis(
canvas: Canvas2D,
yDomain: AxisDomain,
layout: PlotLayout,
yTicks: number[],
theme: Theme,
hasLabel: boolean,
dpr: number,
formatter?: (v: number) => string,
): void {
const ctx = getScaledContext(canvas, dpr);
if (!ctx) {
return;
}
const { plotRect: plot } = layout;
renderYAxisCore(
ctx,
yDomain,
yTicks,
[layout],
plot.x,
plot,
theme,
hasLabel ? { pivotY: plot.y + plot.height / 2 } : null,
formatter,
);
}
export function renderAxesChrome(
canvas: Canvas2D,
xDomain: AxisDomain,
yDomain: AxisDomain,
layout: PlotLayout,
xTicks: number[],
yTicks: number[],
theme: Theme,
dpr: number,
xFormatter?: (v: number) => string,
yFormatter?: (v: number) => string,
): void {
renderCellYAxis(
canvas,
yDomain,
layout,
yTicks,
theme,
true,
dpr,
yFormatter,
);
renderCellXAxis(
canvas,
xDomain,
layout,
xTicks,
theme,
true,
dpr,
xFormatter,
);
}
export function renderOuterXAxis(
canvas: Canvas2D,
rect: PlotRect,
xDomain: AxisDomain,
xTicks: number[],
colLayouts: PlotLayout[],
theme: Theme,
hasLabel: boolean,
dpr: number,
formatter?: (v: number) => string,
): void {
const ctx = getScaledContext(canvas, dpr);
if (!ctx) {
return;
}
renderXAxisCore(
ctx,
xDomain,
xTicks,
colLayouts,
rect.y,
rect,
theme,
hasLabel
? {
cx: rect.x + rect.width / 2,
baselineY: rect.y + rect.height - 2,
}
: null,
formatter,
);
}
export function renderOuterYAxis(
canvas: Canvas2D,
rect: PlotRect,
yDomain: AxisDomain,
yTicks: number[],
rowLayouts: PlotLayout[],
theme: Theme,
hasLabel: boolean,
dpr: number,
formatter?: (v: number) => string,
): void {
const ctx = getScaledContext(canvas, dpr);
if (!ctx) {
return;
}
renderYAxisCore(
ctx,
yDomain,
yTicks,
rowLayouts,
rect.x + rect.width,
rect,
theme,
hasLabel ? { pivotY: rect.y + rect.height / 2 } : null,
formatter,
);
}
@@ -0,0 +1,516 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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/view-reader";
import { buildSplitGroups } from "../../data/split-groups";
import type { CategoricalLevel } from "../../axis/categorical-axis";
import {
resolveAxisMode,
resolveCategoryAxis,
resolveNumericCategoryDomain,
type AxisMode,
type NumericCategoryDomain,
} from "../common/category-axis-resolver";
import { computeSlotGeometry } from "../common/band-layout";
export interface CandleSeriesInfo {
seriesId: number;
splitIdx: number;
splitKey: string;
label: string;
}
/**
* Logical candle record. Synthesized on demand from {@link CandleColumns}
* via {@link readCandleRecord} for tooltip / hover paths. The pipeline
* never materializes these — see `CandleColumns` for the columnar
* storage that replaces the legacy `CandleRecord[]`.
*/
export interface CandleRecord {
catIdx: number;
splitIdx: number;
seriesId: number;
xCenter: number;
halfWidth: number;
open: number;
close: number;
high: number;
low: number;
isUp: boolean;
}
/**
* Columnar storage for the candle record set. Replaces the legacy
* `CandleRecord[]` to avoid per-record POJO allocation at scale.
*
* Records are appended in `(splitIdx, catIdx)` order as the pipeline
* loop is structured (outer split, inner category) — both `xCenter` and
* `catIdx` are monotonically non-decreasing within a split, which the
* hit-test uses for binary-search narrowing.
*
* `count` is the active record count; the underlying typed arrays may
* be over-allocated for capacity reuse across builds.
*/
export interface CandleColumns {
count: number;
catIdx: Int32Array;
splitIdx: Int32Array;
seriesId: Int32Array;
xCenter: Float64Array;
halfWidth: Float64Array;
open: Float64Array;
close: Float64Array;
high: Float64Array;
low: Float64Array;
/**
* 1 = up (close ≥ open), 0 = down.
*/
isUp: Uint8Array;
}
export function emptyCandleColumns(): CandleColumns {
return {
count: 0,
catIdx: new Int32Array(0),
splitIdx: new Int32Array(0),
seriesId: new Int32Array(0),
xCenter: new Float64Array(0),
halfWidth: new Float64Array(0),
open: new Float64Array(0),
close: new Float64Array(0),
high: new Float64Array(0),
low: new Float64Array(0),
isUp: new Uint8Array(0),
};
}
/**
* Reuse `prev`'s typed arrays when capacity is sufficient, else allocate
* fresh. Resets `count` to 0; pipeline writes from index 0.
*/
export function ensureCandleColumnsCapacity(
prev: CandleColumns | null,
capacity: number,
): CandleColumns {
if (prev && prev.catIdx.length >= capacity) {
prev.count = 0;
return prev;
}
return {
count: 0,
catIdx: new Int32Array(capacity),
splitIdx: new Int32Array(capacity),
seriesId: new Int32Array(capacity),
xCenter: new Float64Array(capacity),
halfWidth: new Float64Array(capacity),
open: new Float64Array(capacity),
close: new Float64Array(capacity),
high: new Float64Array(capacity),
low: new Float64Array(capacity),
isUp: new Uint8Array(capacity),
};
}
/**
* Synthesize a {@link CandleRecord} POJO for record `i`. Used by
* tooltip / pinned tooltip / hover return paths; not called in any
* frame-rate hot loop.
*/
export function readCandleRecord(cols: CandleColumns, i: number): CandleRecord {
return {
catIdx: cols.catIdx[i],
splitIdx: cols.splitIdx[i],
seriesId: cols.seriesId[i],
xCenter: cols.xCenter[i],
halfWidth: cols.halfWidth[i],
open: cols.open[i],
close: cols.close[i],
high: cols.high[i],
low: cols.low[i],
isUp: cols.isUp[i] !== 0,
};
}
export interface CandlestickPipelineInput {
columns: ColumnDataMap;
numRows: number;
columnSlots: (string | null)[];
groupBy: string[];
splitBy: string[];
/**
* Source-column types for `group_by` columns. Same shape as the bar
* pipeline — used to stringify non-string row-paths and to enable
* numeric-axis mode for a single non-string non-boolean group_by.
*/
groupByTypes: Record<string, string>;
/**
* Band-slot geometry knobs sourced from
* {@link PluginConfig.band_inner_frac} / `bar_inner_pad`. Forwarded
* to `computeSlotGeometry`. Replace the `BAND_INNER_FRAC` /
* `BAR_INNER_PAD` constants.
*/
bandInnerFrac: number;
barInnerPad: number;
/**
* Reusable scratch — pipeline writes records into the typed arrays
* in place. Pass the previous build's columns to amortize
* allocation across data reloads.
*/
scratchCandles?: CandleColumns | null;
}
export type { NumericCategoryDomain };
export interface CandlestickPipelineResult {
splitPrefixes: string[];
rowPaths: CategoricalLevel[];
numCategories: number;
rowOffset: number;
/**
* Axis mode discriminator (see bar-build for semantics).
*/
axisMode: AxisMode;
numericCategoryDomain: NumericCategoryDomain | null;
/**
* Per-category X position (real data units) in numeric mode.
*/
categoryPositions: Float64Array | null;
series: CandleSeriesInfo[];
candles: CandleColumns;
yDomain: { min: number; max: number };
}
const EMPTY_RESULT: Omit<CandlestickPipelineResult, "candles"> = {
splitPrefixes: [],
rowPaths: [],
numCategories: 0,
rowOffset: 0,
axisMode: { mode: "category" },
numericCategoryDomain: null,
categoryPositions: null,
series: [],
yDomain: { min: 0, max: 1 },
};
/**
* Pure pipeline: turn a raw `ColumnDataMap` into a columnar
* {@link CandleColumns}. Column slots (Open / Close / High / Low) mirror
* d3fc's convention:
* - `Open` is required.
* - `Close` falls back to the next row's Open (last row: own Open).
* - `High` falls back to `max(open, close)`.
* - `Low` falls back to `min(open, close)`.
*
* The fallbacks apply per series when `split_by` is active, so each
* split reads the "next row" from its own series.
*/
export function buildCandlestickPipeline(
input: CandlestickPipelineInput,
): CandlestickPipelineResult {
const {
columns,
numRows,
columnSlots,
groupBy,
splitBy,
groupByTypes,
bandInnerFrac,
barInnerPad,
scratchCandles,
} = input;
const axisMode = resolveAxisMode(groupBy, groupByTypes);
const openBase = columnSlots[0] || "";
if (!openBase) {
return { ...EMPTY_RESULT, candles: emptyCandleColumns() };
}
const closeBase = columnSlots[1] || "";
const highBase = columnSlots[2] || "";
const lowBase = columnSlots[3] || "";
// Split-prefix resolution. Each split provides its own Open column
// (required) and may provide any subset of Close / High / Low.
const splitPrefixes: string[] = [];
if (splitBy.length > 0) {
const aggregates = [openBase];
if (closeBase) {
aggregates.push(closeBase);
}
if (highBase) {
aggregates.push(highBase);
}
if (lowBase) {
aggregates.push(lowBase);
}
for (const g of buildSplitGroups(columns, [openBase], aggregates)) {
if (g.colNames.has(openBase)) {
splitPrefixes.push(g.prefix);
}
}
if (splitPrefixes.length === 0) {
splitPrefixes.push("");
}
} else {
splitPrefixes.push("");
}
const levelTypes = groupBy.map((name) => groupByTypes[name] ?? "string");
const { rowPaths, numCategories, rowOffset } = resolveCategoryAxis(
columns,
numRows,
groupBy.length,
levelTypes,
);
if (numCategories === 0) {
return {
...EMPTY_RESULT,
axisMode,
splitPrefixes,
rowPaths,
rowOffset,
candles: emptyCandleColumns(),
};
}
const P = splitPrefixes.length;
const series: CandleSeriesInfo[] = [];
for (let p = 0; p < P; p++) {
const splitKey = splitPrefixes[p];
series.push({
seriesId: p,
splitIdx: p,
splitKey,
label: splitKey === "" ? openBase : splitKey,
});
}
// Numeric-mode category positions — read from `__ROW_PATH_0__` so
// candles anchor at real data values (e.g. ms-since-epoch) instead
// of logical category indices.
let categoryPositions: Float64Array | null = null;
let numericCategoryDomain: NumericCategoryDomain | null = null;
let numericBandWidth = 1;
if (axisMode.mode === "numeric" && numCategories > 0) {
const rp = columns.get("__ROW_PATH_0__");
const resolved = resolveNumericCategoryDomain(
rp?.values,
numCategories,
rowOffset,
groupBy[0] ?? "",
axisMode.numericType === "date" ||
axisMode.numericType === "datetime",
);
if (resolved) {
categoryPositions = resolved.categoryPositions;
numericCategoryDomain = resolved.numericCategoryDomain;
numericBandWidth = resolved.numericCategoryDomain.bandWidth;
}
}
const baseSlot = computeSlotGeometry(P, bandInnerFrac, barInnerPad);
const slotWidth = baseSlot.slotWidth * numericBandWidth;
const halfWidth = baseSlot.halfWidth * numericBandWidth;
const halfP = (P - 1) / 2;
// Per-series column references (resolved once, not per row).
const seriesCols: {
openCol: ArrayLike<unknown> | null;
closeCol: ArrayLike<unknown> | null;
highCol: ArrayLike<unknown> | null;
lowCol: ArrayLike<unknown> | null;
openValid: Uint8Array | null;
closeValid: Uint8Array | null;
highValid: Uint8Array | null;
lowValid: Uint8Array | null;
}[] = [];
for (let p = 0; p < P; p++) {
const prefix = splitPrefixes[p];
const nm = (base: string) =>
prefix === "" ? base : `${prefix}|${base}`;
const openCol = columns.get(nm(openBase));
if (!openCol?.values) {
seriesCols.push({
openCol: null,
closeCol: null,
highCol: null,
lowCol: null,
openValid: null,
closeValid: null,
highValid: null,
lowValid: null,
});
continue;
}
const closeCol = closeBase ? columns.get(nm(closeBase)) : null;
const highCol = highBase ? columns.get(nm(highBase)) : null;
const lowCol = lowBase ? columns.get(nm(lowBase)) : null;
seriesCols.push({
openCol: openCol.values,
closeCol: closeCol?.values ?? null,
highCol: highCol?.values ?? null,
lowCol: lowCol?.values ?? null,
openValid: openCol.valid ?? null,
closeValid: closeCol?.valid ?? null,
highValid: highCol?.valid ?? null,
lowValid: lowCol?.valid ?? null,
});
}
// Pre-allocate columnar candle storage at N*P upper bound. The
// pipeline emits at most one record per (split, cat) cell.
const cap = numCategories * P;
const candles = ensureCandleColumnsCapacity(scratchCandles ?? null, cap);
let write = 0;
let yMin = Infinity;
let yMax = -Infinity;
const N = numCategories;
for (let p = 0; p < P; p++) {
const sc = seriesCols[p];
const openCol = sc.openCol;
if (!openCol) {
continue;
}
const closeCol = sc.closeCol;
const highCol = sc.highCol;
const lowCol = sc.lowCol;
const openValid = sc.openValid;
const closeValid = sc.closeValid;
const highValid = sc.highValid;
const lowValid = sc.lowValid;
const slotOffset = (p - halfP) * slotWidth;
for (let catI = 0; catI < N; catI++) {
const row = catI + rowOffset;
// Inlined valid-bit test (was a closure in the legacy build).
if (openValid && !((openValid[row >> 3] >> (row & 7)) & 1)) {
continue;
}
const open = openCol[row] as number;
if (!isFinite(open)) {
continue;
}
// Close fallback: explicit close column → next row's open →
// own open (degenerate). Each branch inlines the validity
// check to avoid a per-row closure.
let close: number;
if (
closeCol &&
(!closeValid || ((closeValid[row >> 3] >> (row & 7)) & 1) !== 0)
) {
const v = closeCol[row] as number;
close = isFinite(v) ? v : open;
} else {
const nextRow = catI < N - 1 ? catI + 1 + rowOffset : row;
if (
!openValid ||
((openValid[nextRow >> 3] >> (nextRow & 7)) & 1) !== 0
) {
const v = openCol[nextRow] as number;
close = isFinite(v) ? v : open;
} else {
close = open;
}
}
let high: number;
if (
highCol &&
(!highValid || ((highValid[row >> 3] >> (row & 7)) & 1) !== 0)
) {
const v = highCol[row] as number;
high = isFinite(v) ? v : open > close ? open : close;
} else {
high = open > close ? open : close;
}
let low: number;
if (
lowCol &&
(!lowValid || ((lowValid[row >> 3] >> (row & 7)) & 1) !== 0)
) {
const v = lowCol[row] as number;
low = isFinite(v) ? v : open < close ? open : close;
} else {
low = open < close ? open : close;
}
const center = categoryPositions ? categoryPositions[catI] : catI;
const xCenter = center + slotOffset;
const isUp = close >= open ? 1 : 0;
candles.catIdx[write] = catI;
candles.splitIdx[write] = p;
candles.seriesId[write] = p;
candles.xCenter[write] = xCenter;
candles.halfWidth[write] = halfWidth;
candles.open[write] = open;
candles.close[write] = close;
candles.high[write] = high;
candles.low[write] = low;
candles.isUp[write] = isUp;
write++;
if (low < yMin) {
yMin = low;
}
if (high > yMax) {
yMax = high;
}
}
}
candles.count = write;
if (!isFinite(yMin) || !isFinite(yMax)) {
yMin = 0;
yMax = 1;
} else if (yMin === yMax) {
// Zero-height domain: pad symmetrically so the axis renders.
const pad = Math.max(Math.abs(yMin), 1) * 0.05;
yMin -= pad;
yMax += pad;
}
return {
splitPrefixes,
rowPaths,
numCategories,
rowOffset,
axisMode,
numericCategoryDomain,
categoryPositions,
series,
candles,
yDomain: { min: yMin, max: yMax },
};
}
@@ -0,0 +1,256 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { CandlestickChart } from "./candlestick";
import { renderCandlestickChromeOverlay } from "./candlestick-render";
/**
* Pixels of horizontal slack around the wick centerline so narrow
* bodies with tall wicks stay clickable.
*/
const WICK_TOLERANCE_PX = 3;
/**
* Find the leftmost candle index whose `xCenter` is `>= target`. Bars
* are appended in (split, cat) order; within a split `xCenter` is
* monotonically increasing, but across splits it interleaves at the
* same catIdx. The hit-test still only needs the first candidate at or
* after `target` — subsequent split records share the same catIdx and
* are visited until xCenter exceeds `target + halfWidth`, so a plain
* binary search on `xCenter` ordered as written suffices when
* splits=1; for multi-split we fall back to a linear scan from the
* lower bound found.
*/
function lowerBoundXCenter(
xC: Float64Array,
count: number,
target: number,
): number {
let lo = 0;
let hi = count;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
if (xC[mid] < target) {
lo = mid + 1;
} else {
hi = mid;
}
}
return lo;
}
export function handleCandlestickHover(
chart: CandlestickChart,
mx: number,
my: number,
): void {
if (chart._pinnedIdx !== -1) {
return;
}
const layout = chart._lastLayout;
if (!layout) {
return;
}
const candles = chart._candles;
if (candles.count === 0) {
if (chart._hoveredIdx !== -1) {
chart._hoveredIdx = -1;
renderCandlestickChromeOverlay(chart);
}
return;
}
// Convert mouse → data once; from then on hit-tests are in data
// space, eliminating ~5 `dataToPixel` calls per candidate that the
// legacy implementation performed.
const plot = layout.plotRect;
const padXMin = layout.paddedXMin;
const padXMax = layout.paddedXMax;
const padYMin = layout.paddedYMin;
const padYMax = layout.paddedYMax;
if (
mx < plot.x ||
mx > plot.x + plot.width ||
my < plot.y ||
my > plot.y + plot.height
) {
if (chart._hoveredIdx !== -1) {
chart._hoveredIdx = -1;
renderCandlestickChromeOverlay(chart);
}
return;
}
const dataX = padXMin + ((mx - plot.x) / plot.width) * (padXMax - padXMin);
const dataY = padYMax - ((my - plot.y) / plot.height) * (padYMax - padYMin);
const pxPerDataX = plot.width / (padXMax - padXMin);
const wickToleranceData = WICK_TOLERANCE_PX / pxPerDataX;
const xC = candles.xCenter;
const hw = candles.halfWidth;
const open = candles.open;
const close = candles.close;
const high = candles.high;
const low = candles.low;
// Estimate a generous halfWidth bound so the binary-search visible
// slice covers any candle whose body could overlap `dataX`. The
// halfWidth is uniform per build; conservatively read from the
// first record (or fall back to a small constant).
const maxHalfWidth = candles.count > 0 ? hw[0] : 0;
const tol = Math.max(maxHalfWidth, wickToleranceData);
// Binary-search to a small slice [lo, hi) covering candidates whose
// xCenter falls within ±tol of dataX. Candles outside this window
// can't possibly be hit; the linear scan that follows is bounded by
// (split count × overlap), not the full candle count.
const lo = lowerBoundXCenter(xC, candles.count, dataX - tol);
const hi = lowerBoundXCenter(xC, candles.count, dataX + tol + 1e-12);
// Walk the slice in reverse so the most-recently-added (frontmost)
// candle wins ties — matches legacy behavior.
let hit = -1;
for (let i = hi - 1; i >= lo; i--) {
const xc = xC[i];
const halfW = hw[i];
const xWithinBody = dataX >= xc - halfW && dataX <= xc + halfW;
const xWithinWick = Math.abs(dataX - xc) <= wickToleranceData;
if (!xWithinBody && !xWithinWick) {
continue;
}
const o = open[i];
const c = close[i];
const bodyLow = o < c ? o : c;
const bodyHigh = o < c ? c : o;
const insideBody = xWithinBody && dataY >= bodyLow && dataY <= bodyHigh;
const insideWick = dataY >= low[i] && dataY <= high[i];
if (insideBody || insideWick) {
hit = i;
break;
}
}
if (hit !== chart._hoveredIdx) {
chart._hoveredIdx = hit;
renderCandlestickChromeOverlay(chart);
}
}
export function showCandlestickPinnedTooltip(
chart: CandlestickChart,
idx: number,
): void {
chart._tooltip.dismiss();
chart._pinnedIdx = idx;
const candles = chart._candles;
if (idx < 0 || idx >= candles.count || !chart._lastLayout) {
return;
}
const lines = buildCandlestickTooltipLines(chart, idx);
if (lines.length === 0) {
return;
}
const xCenter = candles.xCenter[idx];
const yMid = (candles.high[idx] + candles.low[idx]) / 2;
const pos = chart._lastLayout.dataToPixel(xCenter, yMid);
// CSS bounds come from the chart's own layout, which is populated
// by the render path regardless of where the chart runs.
const cssWidth = chart._lastLayout.cssWidth;
const cssHeight = chart._lastLayout.cssHeight;
chart._tooltip.pin(lines, pos, { cssWidth, cssHeight });
// Pinning hides the inline hover tooltip but does not change the
// WebGL pass — only the chrome overlay needs to redraw.
chart._hoveredIdx = -1;
renderCandlestickChromeOverlay(chart);
}
export function dismissCandlestickPinnedTooltip(chart: CandlestickChart): void {
chart._tooltip.dismiss();
chart._pinnedIdx = -1;
}
/**
* Build tooltip lines for candle at index `idx` in the columnar
* storage. Indexed access avoids materializing a `CandleRecord` POJO
* on the hot tooltip path.
*/
export function buildCandlestickTooltipLines(
chart: CandlestickChart,
idx: number,
): string[] {
const lines: string[] = [];
const candles = chart._candles;
if (idx < 0 || idx >= candles.count) {
return lines;
}
const catIdx = candles.catIdx[idx];
const splitIdx = candles.splitIdx[idx];
const open = candles.open[idx];
const close = candles.close[idx];
const high = candles.high[idx];
const low = candles.low[idx];
if (
chart._categoryAxisMode === "numeric" &&
chart._numericCategoryDomain &&
chart._categoryPositions
) {
const v = chart._categoryPositions[catIdx];
const xColumn = chart._groupBy[0];
lines.push(chart.getColumnFormatter(xColumn, "value")(v));
} else if (chart._rowPaths.length > 0) {
const parts: string[] = [];
for (const rp of chart._rowPaths) {
const s = rp.labels[catIdx] ?? "";
if (s) {
parts.push(s);
}
}
if (parts.length > 0) {
lines.push(parts.join(" "));
}
} else {
lines.push(`Row ${catIdx + chart._rowOffset}`);
}
if (splitIdx >= 0 && chart._splitPrefixes.length > 1) {
const prefix = chart._splitPrefixes[splitIdx];
if (prefix) {
lines.push(prefix);
}
}
const openFmt = chart.getColumnFormatter(chart._columnSlots[0], "value");
const closeFmt = chart.getColumnFormatter(chart._columnSlots[1], "value");
const highFmt = chart.getColumnFormatter(chart._columnSlots[2], "value");
const lowFmt = chart.getColumnFormatter(chart._columnSlots[3], "value");
lines.push(`Open: ${openFmt(open)}`);
lines.push(`Close: ${closeFmt(close)}`);
lines.push(`High: ${highFmt(high)}`);
lines.push(`Low: ${lowFmt(low)}`);
return lines;
}
@@ -0,0 +1,390 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { WebGLContextManager } from "../../webgl/context-manager";
import type { CandlestickChart, CandlestickAutoFitCache } from "./candlestick";
import { PlotLayout } from "../../layout/plot-layout";
import { sampleGradient } from "../../theme/gradient";
import { renderInPlotFrame } from "../../webgl/plot-frame";
import { renderCanvasTooltip } from "../../interaction/tooltip-controller";
import { computeNiceTicks } from "../../layout/ticks";
import { type AxisDomain } from "../../axis/numeric-axis";
import {
renderBarAxesChrome,
renderBarGridlines,
type BarCategoryAxis,
} from "../../axis/bar-axis";
import {
measureCategoricalAxisHeight,
type CategoricalDomain,
} from "../../axis/categorical-axis";
import { buildCandlestickTooltipLines } from "./candlestick-interact";
import {
computeVisibleExtent,
type VisibleExtent,
} from "../common/visible-extent";
/**
* Resolve up/down body colors from `theme.gradientStops`. Cached on the
* chart via `_upDownColorKey` (reference identity of the stops array)
* — only `restyle()` (which clears the theme cache via
* `invalidateTheme`) or a data load with a fresh theme triggers
* resampling. Legacy code re-sampled every frame.
*/
export function ensureUpDownColors(chart: CandlestickChart): void {
const theme = chart._resolveTheme();
const stops = theme.gradientStops;
if (chart._upDownColorKey === stops) {
return;
}
const upSample = sampleGradient(stops, 1.0);
const downSample = sampleGradient(stops, 0.0);
chart._upColor = [upSample[0], upSample[1], upSample[2]];
chart._downColor = [downSample[0], downSample[1], downSample[2]];
chart._upDownColorKey = stops;
}
/**
* Drop persistent body / wick / OHLC vertex buffers. Subsequent draws
* no-op until the next {@link rebuildGlyphBuffers} call.
*/
export function invalidateGlyphBuffers(chart: CandlestickChart): void {
chart._glyphs.bodyWick.invalidateBuffers(chart);
chart._glyphs.ohlc.invalidateBuffers(chart);
}
/**
* Rebuild the persistent body / wick / OHLC vertex buffers. Reads
* `_candles` (columnar) plus the cached `_upColor` / `_downColor` to
* populate the GPU buffers exactly once per data load. Subsequent pan/
* zoom redraws bind + dispatch with no uploads.
*/
export function rebuildGlyphBuffers(
chart: CandlestickChart,
glManager: WebGLContextManager,
): void {
chart._glyphs.bodyWick.rebuildBuffers(chart, glManager);
chart._glyphs.ohlc.rebuildBuffers(chart, glManager);
}
export function renderCandlestickFrame(
chart: CandlestickChart,
glManager: WebGLContextManager,
): void {
const gl = glManager.gl;
const dpr = glManager.dpr;
const cssWidth = gl.canvas.width / dpr;
const cssHeight = gl.canvas.height / dpr;
if (cssWidth <= 0 || cssHeight <= 0) {
return;
}
if (chart._numCategories === 0) {
return;
}
const theme = chart._resolveTheme();
// Up/down colors sampled at the extremes of the theme gradient.
// Cached on the chart — `ensureUpDownColors` is a no-op when the
// gradient-stops reference matches the previous call. `restyle()`
// clears the cache via `invalidateTheme`, and the data-load path
// refreshes it before rebuilding glyph buffers.
ensureUpDownColors(chart);
const numericCat = chart._categoryAxisMode === "numeric";
const xDomainMin = numericCat ? chart._numericCategoryDomain!.min : -0.5;
const xDomainMax = numericCat
? chart._numericCategoryDomain!.max
: chart._numCategories - 0.5;
if (chart._zoomController) {
chart._zoomController.setBaseDomain(
xDomainMin,
xDomainMax,
chart._yDomain.min,
chart._yDomain.max,
);
}
const vis = chart._zoomController
? chart._zoomController.getVisibleDomain()
: {
xMin: xDomainMin,
xMax: xDomainMax,
yMin: chart._yDomain.min,
yMax: chart._yDomain.max,
};
// Auto-fit the price axis to the visible X window. Skipped when X
// is at default zoom (the refit equals `_yDomain` there and would
// only churn baselines) — Y-axis pan/zoom alone shouldn't trigger
// an X-window refit.
if (
chart._autoFitValue &&
chart._zoomController &&
!chart._zoomController.isXDefault()
) {
const fit = computeVisibleCandleExtent(chart, vis.xMin, vis.xMax);
if (fit.hasFit) {
vis.yMin = fit.min;
vis.yMax = fit.max;
}
}
const hasXLabel = chart._groupBy.length > 0;
const provisionalDomain: CategoricalDomain = {
levels: chart._rowPaths,
numRows: chart._numCategories,
levelLabels: chart._groupBy.slice(),
};
let layout: PlotLayout;
if (numericCat) {
layout = new PlotLayout(cssWidth, cssHeight, {
hasXLabel,
hasYLabel: true,
hasLegend: false,
bottomExtra: 24,
});
} else {
const estLeft = 55 + 16;
const estRight = 16;
const estPlotWidth = Math.max(1, cssWidth - estLeft - estRight);
const bottomExtra = measureCategoricalAxisHeight(
provisionalDomain,
estPlotWidth,
);
layout = new PlotLayout(cssWidth, cssHeight, {
hasXLabel,
hasYLabel: true,
hasLegend: false,
bottomExtra,
});
}
chart._lastLayout = layout;
if (chart._zoomController) {
chart._zoomController.updateLayout(layout);
}
const projection = layout.buildProjectionMatrix(
vis.xMin,
vis.xMax,
vis.yMin,
vis.yMax,
"y",
undefined,
undefined,
chart._categoryOrigin,
0,
);
const yTicks = computeNiceTicks(vis.yMin, vis.yMax, 6);
const yLabel = chart._columnSlots[0] || "";
const xDomain: CategoricalDomain = provisionalDomain;
const yDomain: AxisDomain = {
min: vis.yMin,
max: vis.yMax,
label: yLabel,
};
if (chart._gridlineCanvas) {
renderBarGridlines(
chart._gridlineCanvas,
layout,
yTicks,
theme,
glManager.dpr,
);
}
renderInPlotFrame(gl, layout, glManager.dpr, () => {
if (chart._defaultChartType === "ohlc") {
chart._glyphs.ohlc.draw(chart, gl, glManager, projection);
} else {
chart._glyphs.bodyWick.draw(chart, gl, glManager, projection);
}
});
chart._lastXDomain = xDomain;
chart._lastYDomain = yDomain;
chart._lastYTicks = yTicks;
chart._lastCatTicks = numericCat
? computeNiceTicks(vis.xMin, vis.xMax, 6)
: null;
renderCandlestickChromeOverlay(chart);
}
export function renderCandlestickChromeOverlay(chart: CandlestickChart): void {
if (
!chart._chromeCanvas ||
!chart._lastLayout ||
!chart._lastYDomain ||
!chart._lastYTicks
) {
return;
}
const theme = chart._resolveTheme();
let catAxis: BarCategoryAxis;
if (
chart._categoryAxisMode === "numeric" &&
chart._numericCategoryDomain &&
chart._lastCatTicks
) {
catAxis = {
mode: "numeric",
domain: {
min: chart._numericCategoryDomain.min,
max: chart._numericCategoryDomain.max,
isDate: chart._numericCategoryDomain.isDate,
label: chart._numericCategoryDomain.label,
},
ticks: chart._lastCatTicks,
};
} else if (chart._lastXDomain) {
catAxis = { mode: "category", domain: chart._lastXDomain };
} else {
return;
}
// OHLC value axis: all four price columns share the value axis;
// pick the first available (Open is always present, the rest can
// be null per `candlestick-build.ts`).
const valueColumn =
chart._columnSlots[0] ??
chart._columnSlots[1] ??
chart._columnSlots[2] ??
chart._columnSlots[3];
const xColumn = chart._groupBy[0];
renderBarAxesChrome(
chart._chromeCanvas,
catAxis,
{
mode: "numeric",
domain: chart._lastYDomain,
ticks: chart._lastYTicks,
},
chart._lastLayout,
theme,
chart._glManager?.dpr ?? 1,
undefined,
false,
{
value: chart.getColumnFormatter(valueColumn, "tick"),
category: chart.getColumnFormatter(xColumn, "tick"),
},
);
if (chart._hoveredIdx >= 0 && chart._hoveredIdx < chart._candles.count) {
renderCandlestickTooltip(chart);
}
}
function renderCandlestickTooltip(chart: CandlestickChart): void {
if (!chart._chromeCanvas || !chart._lastLayout) {
return;
}
const i = chart._hoveredIdx;
const candles = chart._candles;
if (i < 0 || i >= candles.count) {
return;
}
const layout = chart._lastLayout;
const xCenter = candles.xCenter[i];
const yMid = (candles.high[i] + candles.low[i]) / 2;
const pos = layout.dataToPixel(xCenter, yMid);
const lines = buildCandlestickTooltipLines(chart, i);
const theme = chart._resolveTheme();
renderCanvasTooltip(
chart._chromeCanvas,
pos,
lines,
layout,
theme,
chart._glManager?.dpr ?? 1,
{
crosshair: false,
highlightRadius: 0,
},
);
}
/**
* Price extent over candles whose `xCenter` falls inside
* `[visXMin, visXMax]`. Uses `low`/`high` (not `open`/`close`) so the
* wick stays inside the plot at any zoom. Cached on
* `chart._autoFitCache`; hover-only redraws hit the cache.
*
* Cache lifetime: reset on data upload ([candlestick.ts]
* `uploadAndRender`).
*/
function computeVisibleCandleExtent(
chart: CandlestickChart,
visXMin: number,
visXMax: number,
): VisibleExtent {
const cache = chart._autoFitCache;
if (cache && cache.xMin === visXMin && cache.xMax === visXMax) {
return cache;
}
const next = cache ?? newCandlestickAutoFitCache();
next.xMin = visXMin;
next.xMax = visXMax;
// Walk the columnar storage directly; the legacy form built a
// closure adapter per call, defeating monomorphism in
// `computeVisibleExtent`.
const candles = chart._candles;
let lo = Infinity;
let hi = -Infinity;
let hasFit = false;
const xC = candles.xCenter;
const lows = candles.low;
const highs = candles.high;
for (let j = 0; j < candles.count; j++) {
const cx = xC[j];
if (cx < visXMin || cx > visXMax) {
continue;
}
if (lows[j] < lo) {
lo = lows[j];
}
if (highs[j] > hi) {
hi = highs[j];
}
hasFit = true;
}
next.min = hasFit ? lo : 0;
next.max = hasFit ? hi : 1;
next.hasFit = hasFit;
chart._autoFitCache = next;
// Reference suppression — `computeVisibleExtent` retained for the
// shared common helper but no longer used in this fast path.
void computeVisibleExtent;
return next;
}
function newCandlestickAutoFitCache(): CandlestickAutoFitCache {
return { xMin: 0, xMax: 0, min: 0, max: 1, hasFit: false };
}
@@ -0,0 +1,348 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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/view-reader";
import type { WebGLContextManager } from "../../webgl/context-manager";
import { CategoricalYChart } from "../common/categorical-y-chart";
import {
buildCandlestickPipeline,
emptyCandleColumns,
type CandleColumns,
type CandleSeriesInfo,
type NumericCategoryDomain,
} from "./candlestick-build";
import {
renderCandlestickFrame,
renderCandlestickChromeOverlay,
invalidateGlyphBuffers,
rebuildGlyphBuffers,
ensureUpDownColors,
} from "./candlestick-render";
import {
handleCandlestickHover,
showCandlestickPinnedTooltip,
} from "./candlestick-interact";
import { BodyWickGlyph } from "./glyphs/draw-candlesticks";
import { OHLCGlyph } from "./glyphs/draw-ohlc";
import { expandDomainInPlace } from "../common/expand-domain";
/**
* Per-frame memo of the auto-fit Y extent for a {@link CandlestickChart},
* keyed on the visible X window. Hover-only redraws hit the cache.
*/
export interface CandlestickAutoFitCache {
// Cache key — the categorical (X) window.
xMin: number;
xMax: number;
// VisibleExtent payload — value axis min/max + hasFit flag.
min: number;
max: number;
hasFit: boolean;
}
export interface CandlestickLocations {
u_proj_left: WebGLUniformLocation | null;
u_proj_right: WebGLUniformLocation | null;
u_hover_series: WebGLUniformLocation | null;
a_corner: number;
a_x_center: number;
a_half_width: number;
a_y0: number;
a_y1: number;
a_color: number;
a_series_id: number;
a_axis: number;
}
/**
* Candlestick / OHLC chart. Both plugins (`y-candlestick`, `y-ohlc`)
* share this class — the only per-plugin difference is
* `_defaultChartType` (`"candlestick"` vs `"ohlc"`), which
* {@link renderCandlestickFrame} uses to pick the glyph draw function.
*
* Fields are package-internal (no `private`) so helper modules in this
* folder can read/write them.
*/
export class CandlestickChart extends CategoricalYChart {
_locations: CandlestickLocations | null = null;
// `_rowPaths`, `_numCategories`, `_rowOffset`, `_program`,
// `_cornerBuffer`, `_lastLayout`, `_lastXDomain`, `_lastYDomain`,
// and `_lastYTicks` all live on `CategoricalYChart`.
_splitPrefixes: string[] = [];
_series: CandleSeriesInfo[] = [];
/**
* Columnar candle records. Indexed in `[0, _candles.count)`.
* Replaces the legacy `CandleRecord[]` to avoid per-record POJO
* allocation on data load.
*/
_candles: CandleColumns = emptyCandleColumns();
_yDomain: { min: number; max: number } = { min: 0, max: 1 };
/**
* `domain_mode: "expand"` accumulators. Hold the running union of
* the value-axis (and, in numeric-category mode, category-axis)
* extent across data loads. Cleared in `resetExpandedDomain` —
* wired from the worker's `resetAllZooms` and from view-config
* mutations on `AbstractChart`. `null` whenever the option is
* `"fit"` or the accumulator has just been cleared.
*/
_expandedYDomain: { min: number; max: number } | null = null;
_expandedCategoryDomain: { min: number; max: number } | null = null;
/**
* Numeric category-axis state (single non-string group_by).
*/
_categoryAxisMode: "category" | "numeric" = "category";
_numericCategoryDomain: NumericCategoryDomain | null = null;
_categoryPositions: Float64Array | null = null;
_lastCatTicks: number[] | null = null;
/**
* Origin used to rebase candle xCenters before f32 narrowing — see {@link SeriesChart._categoryOrigin}.
*/
_categoryOrigin = 0;
/**
* Gradient-sampled colors for the up (close ≥ open) / down sides.
* Cached via `_upDownColorKey` — only `restyle()` (which clears the
* theme cache) or a data load forces re-sampling.
*/
_upColor: [number, number, number] = [0, 0.8, 0.4];
_downColor: [number, number, number] = [0.8, 0.2, 0.2];
/**
* Identity of the gradient-stops reference last used to sample the
* up/down colors. When this matches the current `theme.gradientStops`
* reference, `ensureUpDownColors` short-circuits.
*/
_upDownColorKey: unknown = null;
_hoveredIdx = -1;
_pinnedIdx = -1;
/**
* Typed glyph composition. Each glyph owns its own program cache
* and persistent vertex buffers privately; the chart routes
* draw/rebuild/invalidate via `_glyphs`. `_defaultChartType`
* (`"candlestick"` vs `"ohlc"`) selects which glyph the frame
* builder dispatches to.
*/
readonly _glyphs = {
bodyWick: new BodyWickGlyph(),
ohlc: new OHLCGlyph(),
} as const;
/**
* Auto-fit the price (Y) axis to the `low`/`high` extent of
* candles whose `xCenter` falls inside the visible X window. Pairs
* with the locked Y axis: the lock means user input can't zoom Y
* directly, auto-fit is what actually moves it as the user scrolls
* through time. Default: on (financial-chart convention).
*/
override _autoFitValue = true;
/**
* Per-frame memo of the auto-fit Y extent keyed on the visible X
* window. Hover-only redraws (X window unchanged) hit the cache.
* Reset to null on data upload.
*/
_autoFitCache: CandlestickAutoFitCache | null = null;
protected override tooltipCallbacks() {
return {
onHover: (mx: number, my: number) =>
handleCandlestickHover(this, mx, my),
onLeave: () => {
if (this._hoveredIdx !== -1) {
this._hoveredIdx = -1;
// Hover state only affects the chrome overlay
// (tooltip box) — the WebGL pass is unchanged. Skip
// the full repaint (which would rebuild glyph
// buffers and shake out one more frame of latency).
renderCandlestickChromeOverlay(this);
}
},
onPin: (mx: number, my: number) => {
// Refresh the hit-test at the click coords so the pin
// path doesn't depend on the RAF-throttled hover state
// — see comment in `series.ts` `onPin`.
handleCandlestickHover(this, mx, my);
if (this._hoveredIdx >= 0) {
const idx = this._hoveredIdx;
showCandlestickPinnedTooltip(this, idx);
void this._emitCandleClickSelect(idx);
}
},
onUnpin: () => {
this.emitUnselect();
},
};
}
/**
* Resolve a clicked candle into a `PerspectiveClickDetail` and
* emit both `perspective-click` and
* `perspective-global-filter selected:true`.
*
* One candle per (catIdx, splitIdx). Like the series pipeline,
* `catIdx + _rowOffset` is the source-view row; the column name is
* the Close column (the canonical "y" target for OHLC). Group-by
* values come from `_rowPaths`; split-by values come from
* `_splitPrefixes[splitIdx]` split on `|`.
*/
private async _emitCandleClickSelect(idx: number): Promise<void> {
if (idx < 0 || idx >= this._candles.count) {
return;
}
const catIdx = this._candles.catIdx[idx];
const splitIdx = this._candles.splitIdx[idx];
const groupByValues: (string | null)[] = this._rowPaths.map(
(level) => level.labels[catIdx] ?? null,
);
const splitKey = this._splitPrefixes[splitIdx] ?? "";
const splitByValues =
this._splitBy.length > 0 && splitKey !== ""
? splitKey.split("|")
: [];
// OHLC plugins put Close in slot 1 (FIN_NAMES = ["Open",
// "Close", "High", "Low", "Tooltip"]). Fall back to the first
// non-null slot if Close isn't configured.
const columnName =
this._columnSlots[1] ||
this._columnSlots.find((s): s is string => !!s) ||
"";
await this.emitClickAndSelect({
rowIdx: catIdx + this._rowOffset,
columnName,
groupByValues,
splitByValues,
});
}
override invalidateTheme(): void {
super.invalidateTheme();
// Up/down colors are sampled from `theme.gradientStops`. Drop the
// identity key so the next render re-samples after a `restyle()`.
this._upDownColorKey = null;
}
async uploadAndRender(
glManager: WebGLContextManager,
columns: ColumnDataMap,
startRow: number,
endRow: number,
): Promise<void> {
this._glManager = glManager;
if (startRow !== 0) {
return;
}
const result = buildCandlestickPipeline({
columns,
numRows: endRow,
columnSlots: this._columnSlots,
groupBy: this._groupBy,
splitBy: this._splitBy,
groupByTypes: this._groupByTypes,
bandInnerFrac: this._pluginConfig.band_inner_frac,
barInnerPad: this._pluginConfig.bar_inner_pad,
scratchCandles: this._candles,
});
// `domain_mode: "expand"` post-build union — mirrors the series
// pipeline. `expandDomainInPlace` mutates `result.*` so the
// assignments below pick up the grown extent automatically.
if (this._pluginConfig.domain_mode === "expand") {
this._expandedYDomain = expandDomainInPlace(
this._expandedYDomain,
result.yDomain,
);
if (result.numericCategoryDomain) {
this._expandedCategoryDomain = expandDomainInPlace(
this._expandedCategoryDomain,
result.numericCategoryDomain,
);
}
} else {
this._expandedYDomain = null;
this._expandedCategoryDomain = null;
}
this._rowPaths = result.rowPaths;
this._numCategories = result.numCategories;
this._rowOffset = result.rowOffset;
this._splitPrefixes = result.splitPrefixes;
this._series = result.series;
this._candles = result.candles;
this._yDomain = result.yDomain;
this._categoryAxisMode = result.axisMode.mode;
this._numericCategoryDomain = result.numericCategoryDomain;
this._categoryPositions = result.categoryPositions;
this._categoryOrigin = result.numericCategoryDomain?.min ?? 0;
// New candles invalidate any auto-fit extent memo. Color key
// stays — gradient stops are theme-bound, not data-bound — but
// the persistent vertex buffers must be rebuilt to reflect the
// new candle set.
this._autoFitCache = null;
// Resolve up/down colors (cheap on cache hit) before rebuilding
// glyph buffers so the persistent body buffer captures the
// correct per-candle RGB. Then rebuild buffers.
ensureUpDownColors(this);
invalidateGlyphBuffers(this);
rebuildGlyphBuffers(this, glManager);
await this.requestRender(glManager);
}
_fullRender(glManager: WebGLContextManager): void {
this._glManager = glManager;
renderCandlestickFrame(this, glManager);
}
override resetExpandedDomain(): void {
this._expandedYDomain = null;
this._expandedCategoryDomain = null;
}
protected destroyInternal(): void {
if (this._glManager) {
const gl = this._glManager.gl;
if (this._cornerBuffer) {
gl.deleteBuffer(this._cornerBuffer);
}
// Each glyph owns its program-local GPU resources +
// persistent vertex buffers; `destroy` frees both.
this._glyphs.bodyWick.destroy(this);
this._glyphs.ohlc.destroy(this);
}
this._program = null;
this._locations = null;
this._cornerBuffer = null;
this._candles = emptyCandleColumns();
this._series = [];
this._rowPaths = [];
this._numCategories = 0;
this._upDownColorKey = null;
}
}
@@ -0,0 +1,466 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { WebGLContextManager } from "../../../webgl/context-manager";
import type { CandlestickChart } from "../candlestick";
import {
createLineCornerBuffer,
createQuadCornerBuffer,
getInstancing,
} from "../../../webgl/instanced-attrs";
import { compileProgram } from "../../../webgl/program-cache";
import bodyVert from "../../../shaders/candlestick-body.vert.glsl";
import bodyFrag from "../../../shaders/candlestick-body.frag.glsl";
import lineVert from "../../../shaders/line-uniform.vert.glsl";
import lineFrag from "../../../shaders/line-uniform.frag.glsl";
type GL = WebGL2RenderingContext | WebGLRenderingContext;
interface BodyCache {
program: WebGLProgram;
quadBuffer: WebGLBuffer;
instanceBuffer: WebGLBuffer;
u_projection: WebGLUniformLocation | null;
a_corner: number;
a_x_center: number;
a_half_width: number;
a_y0: number;
a_y1: number;
a_color: number;
}
interface WickCache {
program: WebGLProgram;
cornerBuffer: WebGLBuffer;
segmentBuffer: WebGLBuffer;
u_projection: WebGLUniformLocation | null;
u_color: WebGLUniformLocation | null;
u_resolution: WebGLUniformLocation | null;
u_line_width: WebGLUniformLocation | null;
/**
* `line-uniform` was extended to support the Y-Line `interpolate`
* feature with a per-segment alpha multiplier driven by
* `a_real_start * a_real_end` and `u_interp_alpha`. Wicks are
* always "real" data — every segment renders fully — so we hold
* these locations to neutralize them at draw time (uniform = 1.0,
* constant attribute values = 1.0). Without that, an unset uniform
* defaults to 0 and the fragment alpha collapses to 0, rendering
* the wicks invisible.
*/
u_interp_alpha: WebGLUniformLocation | null;
a_corner: number;
a_start: number;
a_end: number;
a_real_start: number;
a_real_end: number;
}
interface ProgramCache {
body: BodyCache;
wick: WickCache;
}
/**
* Persistent body + wick vertex buffer state. Built once per data load
* by `rebuildBuffers`; pan/zoom redraws bind + dispatch with no uploads.
*/
interface BodyWickBuffers {
bodyCount: number;
upWickCount: number;
downWickCount: number;
/**
* Persistent GPU buffer for up wicks. Layout: [x,low, x,high, ...].
*/
upWickBuffer: WebGLBuffer;
downWickBuffer: WebGLBuffer;
}
/**
* Candlestick body + wick glyph. Owns the body and wick programs +
* their corner/segment/instance buffers, and the persistent up/down
* wick vertex buffers built per data load.
*/
export class BodyWickGlyph {
private _program: ProgramCache | null = null;
private _buffers: BodyWickBuffers | null = null;
/**
* Lazily compile the body and wick programs and create their static
* GPU buffers (corner / quad). Cached for the lifetime of the chart.
*/
private ensureProgram(glManager: WebGLContextManager): ProgramCache {
if (this._program) {
return this._program;
}
const gl = glManager.gl;
const quadBuffer = createQuadCornerBuffer(gl);
const bodyPartial = compileProgram<
Omit<BodyCache, "quadBuffer" | "instanceBuffer">
>(
glManager,
"candlestick-body",
bodyVert,
bodyFrag,
["u_projection"],
[
"a_corner",
"a_x_center",
"a_half_width",
"a_y0",
"a_y1",
"a_color",
],
);
const body: BodyCache = {
...bodyPartial,
quadBuffer,
instanceBuffer: gl.createBuffer()!,
};
const cornerBuffer = createLineCornerBuffer(gl);
const wickPartial = compileProgram<
Omit<WickCache, "cornerBuffer" | "segmentBuffer">
>(
glManager,
"line-uniform",
lineVert,
lineFrag,
[
"u_projection",
"u_color",
"u_resolution",
"u_line_width",
"u_interp_alpha",
],
["a_corner", "a_start", "a_end", "a_real_start", "a_real_end"],
);
const wick: WickCache = {
...wickPartial,
cornerBuffer,
segmentBuffer: gl.createBuffer()!,
};
this._program = { body, wick };
return this._program;
}
/**
* Drop persistent body + wick vertex buffers. Called from data-load
* (before `rebuildBuffers`) and from chart-destroy paths.
*/
invalidateBuffers(chart: CandlestickChart): void {
const buf = this._buffers;
if (!buf || !chart._glManager) {
this._buffers = null;
return;
}
const gl = chart._glManager.gl;
gl.deleteBuffer(buf.upWickBuffer);
gl.deleteBuffer(buf.downWickBuffer);
this._buffers = null;
}
/**
* Pre-build the per-instance body buffer (interleaved
* [xCenter, halfWidth, y0, y1, r, g, b]) and the up/down wick
* line-segment buffers. Single GPU upload per buffer per data load.
*/
rebuildBuffers(
chart: CandlestickChart,
glManager: WebGLContextManager,
): void {
const candles = chart._candles;
const cache = this.ensureProgram(glManager);
const gl = glManager.gl;
const xOrigin = chart._categoryOrigin;
if (candles.count === 0) {
const upBuf = gl.createBuffer()!;
const downBuf = gl.createBuffer()!;
this._buffers = {
bodyCount: 0,
upWickCount: 0,
downWickCount: 0,
upWickBuffer: upBuf,
downWickBuffer: downBuf,
};
return;
}
// Body buffer: 7 floats per candle (interleaved).
const data = new Float32Array(candles.count * 7);
let upCount = 0;
let downCount = 0;
const xC = candles.xCenter;
const hw = candles.halfWidth;
const open = candles.open;
const close = candles.close;
const isUp = candles.isUp;
const upColor = chart._upColor;
const downColor = chart._downColor;
for (let i = 0; i < candles.count; i++) {
const o = open[i];
const c = close[i];
const bodyLow = o < c ? o : c;
const bodyHigh = o < c ? c : o;
const up = isUp[i] !== 0;
const col = up ? upColor : downColor;
const off = i * 7;
data[off + 0] = xC[i] - xOrigin;
data[off + 1] = hw[i] * 0.7;
data[off + 2] = bodyLow;
data[off + 3] = bodyHigh;
data[off + 4] = col[0];
data[off + 5] = col[1];
data[off + 6] = col[2];
if (up) {
upCount++;
} else {
downCount++;
}
}
gl.bindBuffer(gl.ARRAY_BUFFER, cache.body.instanceBuffer);
gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW);
// Wick buffers: per-color, packed [x,low, x,high] segments.
const upWick = new Float32Array(upCount * 4);
const downWick = new Float32Array(downCount * 4);
let upW = 0;
let downW = 0;
const lows = candles.low;
const highs = candles.high;
for (let i = 0; i < candles.count; i++) {
const x = xC[i] - xOrigin;
const lo = lows[i];
const hi = highs[i];
if (isUp[i] !== 0) {
upWick[upW + 0] = x;
upWick[upW + 1] = lo;
upWick[upW + 2] = x;
upWick[upW + 3] = hi;
upW += 4;
} else {
downWick[downW + 0] = x;
downWick[downW + 1] = lo;
downWick[downW + 2] = x;
downWick[downW + 3] = hi;
downW += 4;
}
}
// Reuse existing wick GL buffers when available; otherwise allocate.
const prev = this._buffers;
const upBuf = prev?.upWickBuffer ?? gl.createBuffer()!;
const downBuf = prev?.downWickBuffer ?? gl.createBuffer()!;
gl.bindBuffer(gl.ARRAY_BUFFER, upBuf);
gl.bufferData(gl.ARRAY_BUFFER, upWick, gl.STATIC_DRAW);
gl.bindBuffer(gl.ARRAY_BUFFER, downBuf);
gl.bufferData(gl.ARRAY_BUFFER, downWick, gl.STATIC_DRAW);
this._buffers = {
bodyCount: candles.count,
upWickCount: upCount,
downWickCount: downCount,
upWickBuffer: upBuf,
downWickBuffer: downBuf,
};
}
draw(
chart: CandlestickChart,
gl: GL,
glManager: WebGLContextManager,
projection: Float32Array,
): void {
const buf = this._buffers;
if (!buf || buf.bodyCount === 0) {
return;
}
const cache = this.ensureProgram(glManager);
drawBodies(gl, glManager, cache.body, buf.bodyCount, projection);
drawWicks(chart, gl, glManager, cache.wick, buf, projection);
}
/**
* Free program-local GPU buffers + persistent vertex buffers. The
* shader programs themselves are owned by `WebGLContextManager.shaders`
* and not freed here.
*/
destroy(chart: CandlestickChart): void {
const gl = chart._glManager?.gl;
if (gl) {
this.invalidateBuffers(chart);
const cache = this._program;
if (cache) {
gl.deleteBuffer(cache.body.quadBuffer);
gl.deleteBuffer(cache.body.instanceBuffer);
gl.deleteBuffer(cache.wick.cornerBuffer);
gl.deleteBuffer(cache.wick.segmentBuffer);
}
}
this._program = null;
this._buffers = null;
}
}
/**
* Bind the persistent body buffer and issue one instanced draw.
*/
function drawBodies(
gl: GL,
glManager: WebGLContextManager,
cache: BodyCache,
instanceCount: number,
projection: Float32Array,
): void {
if (instanceCount === 0) {
return;
}
const stride = 7 * Float32Array.BYTES_PER_ELEMENT;
gl.useProgram(cache.program);
gl.uniformMatrix4fv(cache.u_projection, false, projection);
const instancing = getInstancing(glManager);
const { setDivisor } = instancing;
// Per-vertex corner.
gl.bindBuffer(gl.ARRAY_BUFFER, cache.quadBuffer);
gl.enableVertexAttribArray(cache.a_corner);
gl.vertexAttribPointer(cache.a_corner, 2, gl.FLOAT, false, 0, 0);
setDivisor(cache.a_corner, 0);
// Per-instance attributes from the persistent interleaved buffer.
gl.bindBuffer(gl.ARRAY_BUFFER, cache.instanceBuffer);
const f = Float32Array.BYTES_PER_ELEMENT;
const bind = (loc: number, size: number, offset: number) => {
gl.enableVertexAttribArray(loc);
gl.vertexAttribPointer(loc, size, gl.FLOAT, false, stride, offset);
setDivisor(loc, 1);
};
bind(cache.a_x_center, 1, 0);
bind(cache.a_half_width, 1, 1 * f);
bind(cache.a_y0, 1, 2 * f);
bind(cache.a_y1, 1, 3 * f);
bind(cache.a_color, 3, 4 * f);
instancing.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, instanceCount);
setDivisor(cache.a_x_center, 0);
setDivisor(cache.a_half_width, 0);
setDivisor(cache.a_y0, 0);
setDivisor(cache.a_y1, 0);
setDivisor(cache.a_color, 0);
}
/**
* Bind the persistent up/down wick buffers and dispatch one
* instanced draw per color group. No per-frame allocations.
*/
function drawWicks(
chart: CandlestickChart,
gl: GL,
glManager: WebGLContextManager,
cache: WickCache,
buf: BodyWickBuffers,
projection: Float32Array,
): void {
if (buf.upWickCount === 0 && buf.downWickCount === 0) {
return;
}
const dpr = glManager.dpr;
gl.useProgram(cache.program);
gl.uniformMatrix4fv(cache.u_projection, false, projection);
gl.uniform2f(cache.u_resolution, gl.canvas.width, gl.canvas.height);
gl.uniform1f(cache.u_line_width, chart._pluginConfig.wick_width_px * dpr);
// `line-uniform` was extended for the Y-Line interpolate feature
// with a per-segment alpha multiplier; neutralize it here.
// Constant attribute values (used when the array is disabled) and
// uniform are stable for every draw, so set once after
// `useProgram`. Disabling the arrays first guards against a prior
// Y-Line draw that left them enabled at the same attribute index
// (locations are shared because both programs link from the same
// source).
gl.disableVertexAttribArray(cache.a_real_start);
gl.disableVertexAttribArray(cache.a_real_end);
gl.vertexAttrib1f(cache.a_real_start, 1.0);
gl.vertexAttrib1f(cache.a_real_end, 1.0);
gl.uniform1f(cache.u_interp_alpha, 1.0);
const instancing = getInstancing(glManager);
const { setDivisor } = instancing;
gl.bindBuffer(gl.ARRAY_BUFFER, cache.cornerBuffer);
gl.enableVertexAttribArray(cache.a_corner);
gl.vertexAttribPointer(cache.a_corner, 1, gl.FLOAT, false, 0, 0);
setDivisor(cache.a_corner, 0);
drawWickGroup(
gl,
instancing,
cache,
buf.upWickBuffer,
buf.upWickCount,
chart._upColor,
);
drawWickGroup(
gl,
instancing,
cache,
buf.downWickBuffer,
buf.downWickCount,
chart._downColor,
);
setDivisor(cache.a_start, 0);
setDivisor(cache.a_end, 0);
}
function drawWickGroup(
gl: GL,
instancing: ReturnType<typeof getInstancing>,
cache: WickCache,
segmentBuffer: WebGLBuffer,
count: number,
color: [number, number, number],
): void {
if (count === 0) {
return;
}
const stride = 2 * Float32Array.BYTES_PER_ELEMENT;
const { setDivisor } = instancing;
gl.uniform4f(cache.u_color, color[0], color[1], color[2], 1.0);
gl.bindBuffer(gl.ARRAY_BUFFER, segmentBuffer);
gl.enableVertexAttribArray(cache.a_start);
gl.vertexAttribPointer(cache.a_start, 2, gl.FLOAT, false, 2 * stride, 0);
setDivisor(cache.a_start, 1);
gl.enableVertexAttribArray(cache.a_end);
gl.vertexAttribPointer(cache.a_end, 2, gl.FLOAT, false, 2 * stride, stride);
setDivisor(cache.a_end, 1);
instancing.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, count);
}
@@ -0,0 +1,351 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { WebGLContextManager } from "../../../webgl/context-manager";
import type { CandlestickChart } from "../candlestick";
import {
createLineCornerBuffer,
getInstancing,
} from "../../../webgl/instanced-attrs";
import { compileProgram } from "../../../webgl/program-cache";
import lineVert from "../../../shaders/line-uniform.vert.glsl";
import lineFrag from "../../../shaders/line-uniform.frag.glsl";
type GL = WebGL2RenderingContext | WebGLRenderingContext;
interface OHLCCache {
program: WebGLProgram;
cornerBuffer: WebGLBuffer;
segmentBuffer: WebGLBuffer;
u_projection: WebGLUniformLocation | null;
u_color: WebGLUniformLocation | null;
u_resolution: WebGLUniformLocation | null;
u_line_width: WebGLUniformLocation | null;
/**
* `line-uniform` was extended to support the Y-Line `interpolate`
* feature with a per-segment alpha multiplier driven by
* `a_real_start * a_real_end` and `u_interp_alpha`. The OHLC glyph
* doesn't need that — every segment is "real" — so we hold these
* locations to neutralize them at draw time (uniform = 1.0,
* constant attribute values = 1.0). Without that, an unset uniform
* defaults to 0 and the fragment alpha collapses to 0, rendering
* the entire OHLC glyph invisible.
*/
u_interp_alpha: WebGLUniformLocation | null;
a_corner: number;
a_start: number;
a_end: number;
a_real_start: number;
a_real_end: number;
}
/**
* Persistent OHLC vertex buffer state — one buffer per color group
* (up / down), each holding 3 line segments per candle (HL vertical,
* open tick, close tick). Built once per data load; pan/zoom redraws
* rebind + dispatch with no uploads.
*/
interface OHLCBuffers {
upBuffer: WebGLBuffer;
downBuffer: WebGLBuffer;
/**
* Number of line-segment instances in the up buffer (= 3 × up candle count).
*/
upInstanceCount: number;
downInstanceCount: number;
}
/**
* OHLC bar glyph. Owns the OHLC program + per-color persistent segment
* buffers built per data load. Co-tenanted with `BodyWickGlyph` on
* `CandlestickChart`; only one of the two is active per frame depending
* on `_defaultChartType`.
*/
export class OHLCGlyph {
private _program: OHLCCache | null = null;
private _buffers: OHLCBuffers | null = null;
private ensureProgram(glManager: WebGLContextManager): OHLCCache {
if (this._program) {
return this._program;
}
const gl = glManager.gl;
const cornerBuffer = createLineCornerBuffer(gl);
const partial = compileProgram<
Omit<OHLCCache, "cornerBuffer" | "segmentBuffer">
>(
glManager,
"line-uniform",
lineVert,
lineFrag,
[
"u_projection",
"u_color",
"u_resolution",
"u_line_width",
"u_interp_alpha",
],
["a_corner", "a_start", "a_end", "a_real_start", "a_real_end"],
);
this._program = {
...partial,
cornerBuffer,
segmentBuffer: gl.createBuffer()!,
};
return this._program;
}
/**
* Drop persistent OHLC vertex buffers. Called from data-load (before
* `rebuildBuffers`) and from chart-destroy paths.
*/
invalidateBuffers(chart: CandlestickChart): void {
const buf = this._buffers;
if (!buf || !chart._glManager) {
this._buffers = null;
return;
}
const gl = chart._glManager.gl;
gl.deleteBuffer(buf.upBuffer);
gl.deleteBuffer(buf.downBuffer);
this._buffers = null;
}
/**
* Pre-build the per-group OHLC instance buffers. Each candle emits 3
* line segments (HL, open tick, close tick); layout per instance is
* `[start.x, start.y, end.x, end.y]`. Single GPU upload per group per
* data load.
*/
rebuildBuffers(
chart: CandlestickChart,
glManager: WebGLContextManager,
): void {
// Only rebuild when this chart actually paints OHLC. Cheap enough
// to always rebuild but skipping avoids two empty GPU buffers on
// candlestick instances.
if (chart._defaultChartType !== "ohlc") {
return;
}
const candles = chart._candles;
const gl = glManager.gl;
this.ensureProgram(glManager);
const xOrigin = chart._categoryOrigin;
const xC = candles.xCenter;
const hw = candles.halfWidth;
const open = candles.open;
const close = candles.close;
const high = candles.high;
const low = candles.low;
const isUp = candles.isUp;
let upCount = 0;
let downCount = 0;
for (let i = 0; i < candles.count; i++) {
if (isUp[i] !== 0) {
upCount++;
} else {
downCount++;
}
}
const upData = new Float32Array(upCount * 3 * 4);
const downData = new Float32Array(downCount * 3 * 4);
let upW = 0;
let downW = 0;
for (let i = 0; i < candles.count; i++) {
const xc = xC[i] - xOrigin;
const o = open[i];
const c = close[i];
const lo = low[i];
const hi = high[i];
const halfW = hw[i];
const target = isUp[i] !== 0 ? upData : downData;
let w = isUp[i] !== 0 ? upW : downW;
// HL vertical line.
target[w++] = xc;
target[w++] = lo;
target[w++] = xc;
target[w++] = hi;
// Open tick: left-facing horizontal stub at y=open.
target[w++] = xc - halfW;
target[w++] = o;
target[w++] = xc;
target[w++] = o;
// Close tick: right-facing horizontal stub at y=close.
target[w++] = xc;
target[w++] = c;
target[w++] = xc + halfW;
target[w++] = c;
if (isUp[i] !== 0) {
upW = w;
} else {
downW = w;
}
}
const prev = this._buffers;
const upBuf = prev?.upBuffer ?? gl.createBuffer()!;
const downBuf = prev?.downBuffer ?? gl.createBuffer()!;
gl.bindBuffer(gl.ARRAY_BUFFER, upBuf);
gl.bufferData(gl.ARRAY_BUFFER, upData, gl.STATIC_DRAW);
gl.bindBuffer(gl.ARRAY_BUFFER, downBuf);
gl.bufferData(gl.ARRAY_BUFFER, downData, gl.STATIC_DRAW);
this._buffers = {
upBuffer: upBuf,
downBuffer: downBuf,
upInstanceCount: upCount * 3,
downInstanceCount: downCount * 3,
};
}
/**
* Bind the persistent up/down OHLC buffers and dispatch one instanced
* draw per color group.
*/
draw(
chart: CandlestickChart,
gl: GL,
glManager: WebGLContextManager,
projection: Float32Array,
): void {
const buf = this._buffers;
if (
!buf ||
(buf.upInstanceCount === 0 && buf.downInstanceCount === 0)
) {
return;
}
const cache = this.ensureProgram(glManager);
const dpr = glManager.dpr;
gl.useProgram(cache.program);
gl.uniformMatrix4fv(cache.u_projection, false, projection);
gl.uniform2f(cache.u_resolution, gl.canvas.width, gl.canvas.height);
gl.uniform1f(
cache.u_line_width,
chart._pluginConfig.ohlc_line_width_px * dpr,
);
// `line-uniform` was extended for the Y-Line interpolate
// feature with a per-segment alpha multiplier; neutralize it
// here. Constant attribute values (used when the array is
// disabled) and uniform are stable for every draw, so set
// once after `useProgram`. Disabling the arrays first guards
// against a prior Y-Line draw that left them enabled at the
// same attribute index (locations are shared because both
// programs link from the same source).
gl.disableVertexAttribArray(cache.a_real_start);
gl.disableVertexAttribArray(cache.a_real_end);
gl.vertexAttrib1f(cache.a_real_start, 1.0);
gl.vertexAttrib1f(cache.a_real_end, 1.0);
gl.uniform1f(cache.u_interp_alpha, 1.0);
const instancing = getInstancing(glManager);
const { setDivisor } = instancing;
gl.bindBuffer(gl.ARRAY_BUFFER, cache.cornerBuffer);
gl.enableVertexAttribArray(cache.a_corner);
gl.vertexAttribPointer(cache.a_corner, 1, gl.FLOAT, false, 0, 0);
setDivisor(cache.a_corner, 0);
drawGroup(
gl,
instancing,
cache,
buf.upBuffer,
buf.upInstanceCount,
chart._upColor,
);
drawGroup(
gl,
instancing,
cache,
buf.downBuffer,
buf.downInstanceCount,
chart._downColor,
);
setDivisor(cache.a_start, 0);
setDivisor(cache.a_end, 0);
}
destroy(chart: CandlestickChart): void {
const gl = chart._glManager?.gl;
if (gl) {
this.invalidateBuffers(chart);
const cache = this._program;
if (cache) {
gl.deleteBuffer(cache.cornerBuffer);
gl.deleteBuffer(cache.segmentBuffer);
}
}
this._program = null;
this._buffers = null;
}
}
function drawGroup(
gl: GL,
instancing: ReturnType<typeof getInstancing>,
cache: OHLCCache,
buffer: WebGLBuffer,
instanceCount: number,
color: [number, number, number],
): void {
if (instanceCount === 0) {
return;
}
const instanceStride = 4 * Float32Array.BYTES_PER_ELEMENT;
const pointSize = 2 * Float32Array.BYTES_PER_ELEMENT;
const { setDivisor } = instancing;
gl.uniform4f(cache.u_color, color[0], color[1], color[2], 1.0);
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.enableVertexAttribArray(cache.a_start);
gl.vertexAttribPointer(
cache.a_start,
2,
gl.FLOAT,
false,
instanceStride,
0,
);
setDivisor(cache.a_start, 1);
gl.enableVertexAttribArray(cache.a_end);
gl.vertexAttribPointer(
cache.a_end,
2,
gl.FLOAT,
false,
instanceStride,
pointSize,
);
setDivisor(cache.a_end, 1);
instancing.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, instanceCount);
}
@@ -0,0 +1,30 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
/**
* Either flavor of 2D canvas the chart code may paint to. The chart's
* gridline and chrome canvases are `OffscreenCanvas` once the Host
* transfers rendering control to the Renderer (which it does in
* `LocalTransport` to validate that the chart code is DOM-free for
* worker mode). The GL canvas is also `HTMLCanvasElement | OffscreenCanvas`
* via WebGL's `gl.canvas`.
*/
export type Canvas2D = HTMLCanvasElement | OffscreenCanvas;
/**
* 2D rendering context for either canvas flavor. The `OffscreenCanvas`
* variant is missing only `getContextAttributes` and `drawFocusIfNeeded` —
* neither used by the chart render paths.
*/
export type Context2D =
| CanvasRenderingContext2D
| OffscreenCanvasRenderingContext2D;
@@ -0,0 +1,750 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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/view-reader";
import { buildSplitGroups } from "../../data/split-groups";
import type { WebGLContextManager } from "../../webgl/context-manager";
import type { CartesianChart, SplitGroup } from "./cartesian";
import { LabelInterner } from "./label-interner";
/**
* Resolve a row's string value into a slot index in `dictionary`,
* inserting on first encounter. Invalid / missing values land in a
* lazily-added `"(null)"` slot — no reserved slot 0 when the data has
* no nulls. Shared by the X and Y categorical paths in
* `processCartesianChunk`.
*/
function lookupCategorySlot(
col: ColumnData | null,
rowIdx: number,
dictionary: string[],
seen: Map<string, number>,
): number {
let label: string;
if (!col) {
label = "(null)";
} else {
const valid = col.valid;
const isValid = valid
? !!((valid[rowIdx >> 3] >> (rowIdx & 7)) & 1)
: true;
if (!isValid) {
label = "(null)";
} else if (col.indices && col.dictionary) {
label = col.dictionary[col.indices[rowIdx]] ?? "(null)";
} else if (col.values) {
const v = col.values[rowIdx];
label = v == null ? "(null)" : String(v);
} else {
label = "(null)";
}
}
let slot = seen.get(label);
if (slot === undefined) {
slot = dictionary.length;
dictionary.push(label);
seen.set(label, slot);
}
return slot;
}
/**
* Resolve per-split-prefix column-name tuples. `colorBase`/`sizeBase`
* are optional (empty string when the corresponding slot is unset).
*/
function buildCartesianSplitGroups(
columns: ColumnDataMap,
xBase: string,
yBase: string,
colorBase: string,
sizeBase: string,
labelBase: string,
): SplitGroup[] {
const required = xBase ? [xBase, yBase] : [yBase];
const optional: string[] = [];
if (colorBase) {
optional.push(colorBase);
}
if (sizeBase) {
optional.push(sizeBase);
}
if (labelBase) {
optional.push(labelBase);
}
return buildSplitGroups(columns, required, optional).map((g) => ({
prefix: g.prefix,
xColName: xBase ? g.colNames.get(xBase)! : "",
yColName: g.colNames.get(yBase)!,
colorColName: colorBase ? `${g.prefix}|${colorBase}` : "",
sizeColName: sizeBase ? `${g.prefix}|${sizeBase}` : "",
labelColName: labelBase ? `${g.prefix}|${labelBase}` : "",
}));
}
/**
* First-chunk init: compile the glyph program, reset data extents,
* resolve column roles and split groups, pre-allocate CPU + GPU buffers.
*/
export function initCartesianPipeline(
chart: CartesianChart,
glManager: WebGLContextManager,
columns: ColumnDataMap,
endRow: number,
): void {
chart.glyph.ensureProgram(chart, glManager);
const prevColorName = chart._colorName;
const prevColorIsString = chart._colorIsString;
// `domain_mode: "expand"` seeds the per-build extents from the
// running accumulator instead of `±Infinity`, so the per-row scan
// below naturally unions new data into the previously rendered
// domain / range / color / size scales. `"fit"` clears the
// accumulator alongside the live extents so toggling back to
// expand later starts from a fresh baseline.
const expand = chart._pluginConfig.domain_mode === "expand";
if (expand) {
chart._xMin = chart._expandedXMin;
chart._xMax = chart._expandedXMax;
chart._yMin = chart._expandedYMin;
chart._yMax = chart._expandedYMax;
chart._colorMin = chart._expandedColorMin;
chart._colorMax = chart._expandedColorMax;
chart._sizeMin = chart._expandedSizeMin;
chart._sizeMax = chart._expandedSizeMax;
} else {
chart._xMin = Infinity;
chart._xMax = -Infinity;
chart._yMin = Infinity;
chart._yMax = -Infinity;
chart._colorMin = Infinity;
chart._colorMax = -Infinity;
chart._sizeMin = Infinity;
chart._sizeMax = -Infinity;
chart._expandedXMin = Infinity;
chart._expandedXMax = -Infinity;
chart._expandedYMin = Infinity;
chart._expandedYMax = -Infinity;
chart._expandedColorMin = Infinity;
chart._expandedColorMax = -Infinity;
chart._expandedSizeMin = Infinity;
chart._expandedSizeMax = -Infinity;
}
chart._xOrigin = NaN;
chart._yOrigin = NaN;
chart._dataCount = 0;
chart._hitTest.clear();
chart._maxSeriesUploaded = 0;
const slots = chart._columnSlots;
// Line uses `[yBase]` with row-index X; scatter and X/Y Line use
// `[xBase, yBase, colorBase, sizeBase]`. A single positional layout
// handles both: treat an empty slot[0] as "X = row index".
const xBase = slots[0] || "";
const yBase = slots[1] || "";
const colorBase = slots[2] || "";
const sizeBase = slots[3] || "";
const labelBase = slots[4] || "";
chart._xLabel = xBase;
chart._yLabel = yBase;
chart._xIsRowIndex = !xBase;
// Post-aggregation `string` columns on X / Y switch the axis to
// categorical: per-row slot indices are written into `_xData` /
// `_yData` instead of raw values, and the chrome overlay paints a
// categorical axis. Reset the per-frame dictionary state here at
// chunk 0 so slot 0 is always the first non-null label encountered
// in arrival order (matching the perspective view's sort).
chart._xIsString = !!xBase && chart._columnTypes[xBase] === "string";
chart._yIsString = !!yBase && chart._columnTypes[yBase] === "string";
chart._xCategoryDictionary = [];
chart._yCategoryDictionary = [];
chart._xCategorySeen = new Map();
chart._yCategorySeen = new Map();
chart._xCategoryDomain = null;
chart._yCategoryDomain = null;
// Categorical axes use 0-based slot indices, so the rebase origin
// is fixed at 0. Skipping the NaN-init guard below prevents the
// first-seen-slot from being adopted as the origin (which would
// shift every other slot's pixel position).
if (chart._xIsString) {
chart._xOrigin = 0;
chart._xMin = 0;
chart._xMax = 0;
chart._expandedXMin = Infinity;
chart._expandedXMax = -Infinity;
}
if (chart._yIsString) {
chart._yOrigin = 0;
chart._yMin = 0;
chart._yMax = 0;
chart._expandedYMin = Infinity;
chart._expandedYMax = -Infinity;
}
// Capture the per-series row budget BEFORE any split expansion. When
// split_by is active we grow `totalCapacity` to fit `numSplits`
// parallel slot ranges; reading `totalCapacity` again after that
// would hand every series the whole expanded buffer and cause
// series 1..N writes to overshoot the GPU buffer.
const rowsPerSeries = glManager.bufferPool.totalCapacity || endRow;
if (chart._splitBy.length > 0) {
chart._splitGroups = buildCartesianSplitGroups(
columns,
xBase,
yBase,
colorBase,
sizeBase,
labelBase,
);
if (chart._splitGroups.length === 0) {
chart._seriesCapacity = 0;
chart._seriesUploadedCounts = [];
return;
}
// Split mode: per-point columns live under `${prefix}|${base}`.
// The `_*Name` fields hold the base names so downstream code
// (render labels, tooltip lookup) can present them as one
// logical column. The per-facet resolution happens inside
// `processCartesianChunk` via `_splitGroups[i].*ColName`.
chart._xName = chart._splitGroups[0].xColName;
chart._yName = chart._splitGroups[0].yColName;
chart._colorName = colorBase;
chart._sizeName = sizeBase;
chart._labelName = labelBase;
// Infer dtype from any split's color column — all splits
// share the same underlying column type.
chart._colorIsString = false;
if (colorBase) {
const firstColorCol = columns.get(
chart._splitGroups[0].colorColName,
);
chart._colorIsString = firstColorCol?.type === "string";
}
glManager.ensureBufferCapacity(
rowsPerSeries * chart._splitGroups.length,
);
} else {
chart._splitGroups = [];
chart._xName = xBase;
chart._yName = yBase;
chart._colorName = colorBase;
chart._sizeName = sizeBase;
chart._labelName = labelBase;
chart._colorIsString = false;
if (chart._colorName) {
const colorCol = columns.get(chart._colorName);
chart._colorIsString = colorCol?.type === "string";
}
}
// Color label identity persists across `update()` calls so a given
// string keeps the same palette index for as long as the color column
// stays the same — perspective's dictionary encoding does not promise
// a stable index order between batches, so re-seeding from scratch
// would shuffle every label's color on each update. Reset only when
// the user changes the column or its dtype (string ↔ numeric); a
// numeric color column doesn't use this map and clearing keeps it
// small.
if (
chart._colorName !== prevColorName ||
chart._colorIsString !== prevColorIsString
) {
chart._uniqueColorLabels = new Map();
}
const numSeries = Math.max(1, chart._splitGroups.length);
chart._seriesCapacity = rowsPerSeries;
chart._seriesUploadedCounts = new Array(numSeries).fill(0);
const cpuCap = numSeries * rowsPerSeries;
chart._xData = new Float32Array(cpuCap);
chart._yData = new Float32Array(cpuCap);
chart._colorData = new Float32Array(cpuCap);
chart._rowIndexData = new Int32Array(cpuCap);
chart._labels = labelBase ? new LabelInterner(cpuCap) : null;
}
/**
* Process one data chunk: extract positions + optional color/size per
* point, extend extents, write into per-series slots, capture tooltip
* data, and let the glyph upload its own GPU attribute buffers.
*/
export function processCartesianChunk(
chart: CartesianChart,
glManager: WebGLContextManager,
columns: ColumnDataMap,
startRow: number,
chunkLength: number,
endRow: number,
): void {
if (!chart._yName) {
return;
}
const sourceLength = chunkLength;
if (sourceLength === 0) {
return;
}
if (chart._seriesCapacity === 0) {
return;
}
const hasSplits = chart._splitGroups.length > 0;
// Per-series data source. `colorCol` is the facet's color column
// reference — in split mode each series has its own
// `${prefix}|${colorBase}`, in non-split mode the single series
// carries the user's selected color column. The color-resolution
// logic in the inner loop reads uniformly from `ser.colorCol`
// across both modes.
//
// `xColData` / `yColData` carry the full `ColumnData` so the
// categorical path can read `indices` + `dictionary` for slot
// lookup; `xCol` / `yCol` keep the numeric fast path zero-cost
// (and stay `null` on string columns where `values` is unset).
type SeriesSrc = {
xCol: Float32Array | Float64Array | Int32Array | null;
yCol: Float32Array | Float64Array | Int32Array | null;
xColData: ColumnData | null;
yColData: ColumnData | null;
xValid: Uint8Array | undefined;
yValid: Uint8Array | undefined;
colorCol: ColumnData | null;
sizeCol: (Float32Array | Float64Array | Int32Array) | null;
labelCol: ColumnData | null;
};
const series: SeriesSrc[] = [];
if (hasSplits) {
for (const sg of chart._splitGroups) {
const xc = sg.xColName ? columns.get(sg.xColName) : null;
const yc = columns.get(sg.yColName);
if (!yc) {
continue;
}
if (!chart._yIsString && !yc.values) {
continue;
}
const sc = sg.sizeColName ? columns.get(sg.sizeColName) : null;
const cc = sg.colorColName
? (columns.get(sg.colorColName) ?? null)
: null;
const lc = sg.labelColName
? (columns.get(sg.labelColName) ?? null)
: null;
series.push({
xCol: xc?.values ?? null,
yCol: yc.values ?? null,
xColData: xc ?? null,
yColData: yc,
xValid: xc?.valid,
yValid: yc.valid,
colorCol: cc,
sizeCol: sc?.values ?? null,
labelCol: lc,
});
}
} else {
const xc = chart._xName ? columns.get(chart._xName) : null;
const yc = chart._yName ? columns.get(chart._yName) : null;
if (!yc) {
return;
}
if (!chart._yIsString && !yc.values) {
return;
}
const cc = chart._colorName
? (columns.get(chart._colorName) ?? null)
: null;
const lc = chart._labelName
? (columns.get(chart._labelName) ?? null)
: null;
series.push({
xCol: xc?.values ?? null,
yCol: yc.values ?? null,
xColData: xc ?? null,
yColData: yc,
xValid: xc?.valid,
yValid: yc?.valid,
colorCol: cc,
sizeCol: null,
labelCol: lc,
});
}
if (series.length === 0) {
return;
}
if (chart._stagingChunkSize < sourceLength) {
chart._stagingPositions = new Float32Array(sourceLength * 2);
chart._stagingColors = new Float32Array(sourceLength);
chart._stagingSizes = new Float32Array(sourceLength);
chart._stagingChunkSize = sourceLength;
}
const positions = chart._stagingPositions!;
const colorValues = chart._stagingColors!;
const sizeValues = chart._stagingSizes!;
// Non-split size column: resolve once; inner loop reads values[i].
const nonSplitSizeValues =
!hasSplits && chart._sizeName
? (columns.get(chart._sizeName)?.values ?? null)
: null;
// Seed `_uniqueColorLabels` from the color column's dictionary in
// index order. For a stable single dictionary this makes
// `palette[_uniqueColorLabels.get(label)] === palette[dictIdx %
// N]`. For splits (distinct dictionaries per facet) values that
// appear in multiple splits are inserted once — later splits
// extend the map without disturbing earlier indices, so the
// same string has the same color in every facet.
//
// Also pin `_colorMin` / `_colorMax` to the full palette-index
// domain. If the row loop only encountered a subset of indices
// we'd otherwise set a narrower range and the shader's
// `(v - min) / (max - min)` mapping would land on the wrong
// palette stop.
if (chart._colorIsString && chart._colorName) {
for (const ser of series) {
const dict = ser.colorCol?.dictionary;
if (!dict) {
continue;
}
for (let i = 0; i < dict.length; i++) {
const s = dict[i];
if (!chart._uniqueColorLabels.has(s)) {
chart._uniqueColorLabels.set(
s,
chart._uniqueColorLabels.size,
);
}
}
}
if (chart._uniqueColorLabels.size > 0) {
chart._colorMin = 0;
chart._colorMax = chart._uniqueColorLabels.size - 1;
}
}
// Faceted-no-Color: pin the color range to the facet-index domain
// so the vertex shader's linear `(v - cmin) / (cmax - cmin)`
// mapping lands per-point at LUT stop `s / (N-1)`. Without this
// pin, `_colorMin/_colorMax` would stay at the +Inf/-Inf sentinel
// and every facet's points would sample the LUT center.
if (!chart._colorName && chart._splitGroups.length > 1) {
chart._colorMin = 0;
chart._colorMax = chart._splitGroups.length - 1;
}
for (let s = 0; s < series.length; s++) {
const ser = series[s];
const prevCount = chart._seriesUploadedCounts[s] ?? 0;
const slotBase = s * chart._seriesCapacity;
const maxWrite = chart._seriesCapacity - prevCount;
if (maxWrite <= 0) {
continue;
}
const colorValid = ser.colorCol?.valid;
let writeIdx = 0;
for (let j = 0; j < sourceLength && writeIdx < maxWrite; j++) {
const i = j;
// Numeric axes filter out null/invalid rows entirely;
// categorical axes route them into a `"(null)"` slot
// instead, so the validity / NaN guards only apply on
// the numeric branch.
if (
!chart._yIsString &&
ser.yValid &&
!((ser.yValid[i >> 3] >> (i & 7)) & 1)
) {
continue;
}
if (
!chart._xIsString &&
ser.xCol &&
ser.xValid &&
!((ser.xValid[i >> 3] >> (i & 7)) & 1)
) {
continue;
}
const colorIsNull =
colorValid !== undefined &&
!((colorValid[i >> 3] >> (i & 7)) & 1);
let rawY: number;
if (chart._yIsString) {
rawY = lookupCategorySlot(
ser.yColData,
i,
chart._yCategoryDictionary,
chart._yCategorySeen,
);
} else if (ser.yCol) {
rawY = ser.yCol[i] as number;
if (isNaN(rawY)) {
continue;
}
} else {
continue;
}
let rawX: number;
if (chart._xIsString) {
rawX = lookupCategorySlot(
ser.xColData,
i,
chart._xCategoryDictionary,
chart._xCategorySeen,
);
} else if (ser.xCol) {
rawX = ser.xCol[i] as number;
if (isNaN(rawX)) {
continue;
}
} else {
rawX = startRow + i;
}
// Project raw (x, y) → data-space (x, y). Default is
// identity for cartesian charts; map subclasses override
// to apply Mercator. Second NaN guard catches projection
// failures (e.g. Mercator's ±85° lat clamp).
const [x, y] = chart.projectPoint(rawX, rawY);
if (isNaN(x) || isNaN(y)) {
continue;
}
if (x < chart._xMin) {
chart._xMin = x;
}
if (x > chart._xMax) {
chart._xMax = x;
}
if (y < chart._yMin) {
chart._yMin = y;
}
if (y > chart._yMax) {
chart._yMax = y;
}
// Capture rebase origins from the first valid sample. The
// origin is f64 in JS state but applied before every f32
// store below — `_xData`, `_yData`, and the GPU `positions`
// staging buffer all hold rebased values, so the projection
// matrix's `tx`/`ty` terms (built from rebased extents in
// cartesian-render) stay near zero and the shader's
// `sx*x + tx` cancellation is precision-safe.
if (isNaN(chart._xOrigin)) {
chart._xOrigin = x;
}
if (isNaN(chart._yOrigin)) {
chart._yOrigin = y;
}
const xr = x - chart._xOrigin;
const yr = y - chart._yOrigin;
const flatIdx = slotBase + prevCount + writeIdx;
chart._xData![flatIdx] = xr;
chart._yData![flatIdx] = yr;
// Remember the source arrow row this slot came from so
// lazy tooltip fetches can resolve columns on demand. In
// split mode each series duplicates the same arrow row
// into its own slot, so `startRow + i` is the right view
// row regardless of `s`.
chart._rowIndexData![flatIdx] = startRow + i;
positions[writeIdx * 2] = xr;
positions[writeIdx * 2 + 1] = yr;
// Color: unified resolution for split + non-split.
// Read from this series' own color column (facet-specific
// in split mode, the chart-wide column otherwise). Scales
// (`_colorMin/_colorMax` and `_uniqueColorLabels`) are
// shared across every series so identical values render
// as identical colors in every facet.
const cc = ser.colorCol;
if (colorIsNull) {
colorValues[writeIdx] = 0.5;
chart._colorData![flatIdx] = 0.5;
} else if (cc && !chart._colorIsString && cc.values) {
const v = cc.values[i] as number;
colorValues[writeIdx] = v;
chart._colorData![flatIdx] = v;
if (v < chart._colorMin) {
chart._colorMin = v;
}
if (v > chart._colorMax) {
chart._colorMax = v;
}
} else if (
cc &&
chart._colorIsString &&
cc.indices &&
cc.dictionary
) {
const label = cc.dictionary[cc.indices[i]];
// Dict-seeding above ensures this label is already
// in `_uniqueColorLabels`; defensive insert for any
// value that appears in data but not the dictionary
// (shouldn't happen for Arrow dict columns).
if (!chart._uniqueColorLabels.has(label)) {
chart._uniqueColorLabels.set(
label,
chart._uniqueColorLabels.size,
);
chart._colorMax = chart._uniqueColorLabels.size - 1;
}
const idx = chart._uniqueColorLabels.get(label)!;
colorValues[writeIdx] = idx;
chart._colorData![flatIdx] = idx;
// Skip min/max updates — they were pinned to the full
// palette-index domain during seeding.
} else {
colorValues[writeIdx] = s;
chart._colorData![flatIdx] = s;
}
// Label: resolve the slot's string via the column's arrow
// dictionary; `LabelInterner.set` deduplicates across
// facets so identical strings share an entry. Non-string
// or unencoded label columns are silently skipped — the
// slot stays at its `-1` initialization.
if (chart._labels && ser.labelCol) {
const lc = ser.labelCol;
const labelValid = lc.valid;
const labelIsNull =
labelValid !== undefined &&
!((labelValid[i >> 3] >> (i & 7)) & 1);
if (!labelIsNull && lc.indices && lc.dictionary) {
chart._labels.set(flatIdx, lc.dictionary[lc.indices[i]]);
}
}
// Size: per-split size column, or global sizeName.
if (ser.sizeCol) {
const v = ser.sizeCol[i] as number;
sizeValues[writeIdx] = v;
if (v < chart._sizeMin) {
chart._sizeMin = v;
}
if (v > chart._sizeMax) {
chart._sizeMax = v;
}
} else if (nonSplitSizeValues) {
const v = nonSplitSizeValues[i] as number;
sizeValues[writeIdx] = v;
if (v < chart._sizeMin) {
chart._sizeMin = v;
}
if (v > chart._sizeMax) {
chart._sizeMax = v;
}
} else {
sizeValues[writeIdx] = 0;
}
writeIdx++;
}
if (writeIdx === 0) {
continue;
}
// Upload the shared position buffer for this series's new slice.
const positionByteOffset =
(slotBase + prevCount) * 2 * Float32Array.BYTES_PER_ELEMENT;
glManager.bufferPool.upload(
"a_position",
positions.subarray(0, writeIdx * 2),
positionByteOffset,
2,
);
// Upload the raw color and size buffers (consumed by glyphs).
const scalarByteOffset =
(slotBase + prevCount) * Float32Array.BYTES_PER_ELEMENT;
glManager.bufferPool.upload(
"a_color_value",
colorValues.subarray(0, writeIdx),
scalarByteOffset,
);
glManager.bufferPool.upload(
"a_size_value",
sizeValues.subarray(0, writeIdx),
scalarByteOffset,
);
chart._seriesUploadedCounts[s] = prevCount + writeIdx;
if (chart._seriesUploadedCounts[s] > chart._maxSeriesUploaded) {
chart._maxSeriesUploaded = chart._seriesUploadedCounts[s];
}
}
// Total dataCount = sum of all series' uploaded counts.
let total = 0;
for (const c of chart._seriesUploadedCounts) {
total += c;
}
chart._dataCount = total;
glManager.uploadedCount = total;
chart._hitTest.markDirty();
if (isFinite(chart._xMin)) {
chart.setZoomBaseDomain(
chart._xMin,
chart._xMax,
chart._yMin,
chart._yMax,
);
}
}
@@ -0,0 +1,355 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { CartesianChart } from "./cartesian";
import { renderCartesianChromeOverlay } from "./cartesian-render";
const TOOLTIP_RADIUS_PX = 24;
/**
* Lazily rebuild the spatial hit-test index from the current CPU-side
* point buffers. Walks every series slot so ranges with gaps (unused
* tails) are skipped naturally.
*/
function ensureCartesianSpatialGrid(chart: CartesianChart): void {
if (!chart._hitTest.isDirty || !chart._xData || !chart._yData) {
return;
}
const xData = chart._xData;
const yData = chart._yData;
const numSeries = Math.max(1, chart._splitGroups.length);
const cap = chart._seriesCapacity;
// `_xData`/`_yData` hold rebased values (`absolute - origin`) so the
// f32 GPU pipeline keeps sub-millisecond precision for datetime
// axes; the spatial-grid bounds and queries below must live in the
// same rebased space.
const xOrigin = isNaN(chart._xOrigin) ? 0 : chart._xOrigin;
const yOrigin = isNaN(chart._yOrigin) ? 0 : chart._yOrigin;
chart._hitTest.rebuild(
{
xMin: chart._xMin - xOrigin,
xMax: chart._xMax - xOrigin,
yMin: chart._yMin - yOrigin,
yMax: chart._yMax - yOrigin,
},
chart._dataCount,
(insert) => {
for (let s = 0; s < numSeries; s++) {
const count = chart._seriesUploadedCounts[s] ?? 0;
const base = s * cap;
for (let j = 0; j < count; j++) {
insert(base + j, xData[base + j], yData[base + j]);
}
}
},
);
}
/**
* Update {@link CartesianChart._hoveredIndex} for the given mouse
* position. Triggers a chrome re-render if the hovered index changes.
*
* In faceted mode, the hit test first resolves which facet the mouse is
* over, then restricts the search to that facet's series slice. This
* makes hover local to a facet; coordinated ghost indicators in other
* facets are painted by the chrome overlay.
*/
export function handleCartesianHover(
chart: CartesianChart,
mx: number,
my: number,
): void {
if (!chart._xData || !chart._yData) {
return;
}
// Resolve the facet (and its layout) under the cursor. Non-facet
// charts have `_facetGrid = null` and fall back to the cached
// `_lastLayout`; the hover then scans every series.
const { layout, facetIdx } = resolveHoverTarget(chart, mx, my);
if (!layout) {
clearHover(chart);
return;
}
const plot = layout.plotRect;
if (
mx < plot.x ||
mx > plot.x + plot.width ||
my < plot.y ||
my > plot.y + plot.height
) {
clearHover(chart);
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);
// `_xData`/`_yData` are rebased (see `processCartesianChunk`), so
// pass rebased mouse coords into the hit-test routines below; range
// ratios (`pxPerData*`) are translation-invariant and stay as-is.
const xOrigin = isNaN(chart._xOrigin) ? 0 : chart._xOrigin;
const yOrigin = isNaN(chart._yOrigin) ? 0 : chart._yOrigin;
const dataXRebased = dataX - xOrigin;
const dataYRebased = dataY - yOrigin;
const bestIdx =
facetIdx < 0
? hoverAllSeries(
chart,
dataXRebased,
dataYRebased,
pxPerDataX,
pxPerDataY,
)
: hoverOneSeries(
chart,
facetIdx,
dataXRebased,
dataYRebased,
pxPerDataX,
pxPerDataY,
);
if (bestIdx !== chart._hoveredIndex || facetIdx !== chart._hoveredFacet) {
chart._hoveredIndex = bestIdx;
chart._hoveredFacet = facetIdx;
if (bestIdx >= 0) {
// Fire the lazy tooltip build; the controller drops stale
// resolves so rapid mouse motion can't paint out-of-date
// text. Crosshair / highlight ring are painted immediately
// from geometry so the hover feels instant; the tooltip
// box fills in once the row arrives (no "loading…" flicker).
const serial = chart._lazyTooltip.beginHover(bestIdx);
chart.glyph.buildTooltipLines(chart, bestIdx).then((lines) => {
if (chart._lazyTooltip.commitHover(serial, lines)) {
renderCartesianChromeOverlay(chart);
}
});
} else {
chart._lazyTooltip.clearHover();
}
renderCartesianChromeOverlay(chart);
}
}
function clearHover(chart: CartesianChart): void {
if (chart._hoveredIndex !== -1 || chart._hoveredFacet !== -1) {
chart._hoveredIndex = -1;
chart._hoveredFacet = -1;
renderCartesianChromeOverlay(chart);
}
}
/**
* Return `(layout, facetIdx)` for the sub-plot under `(mx, my)`.
* `facetIdx` is `-1` in single-plot mode; the caller then scans every
* series (legacy behaviour). In faceted mode, `-1` also signals "mouse
* is in the grid frame but not inside any plot rect" — the caller
* clears hover in that case.
*/
function resolveHoverTarget(
chart: CartesianChart,
mx: number,
my: number,
): {
layout: import("../../layout/plot-layout").PlotLayout | null;
facetIdx: number;
} {
if (chart._facetGrid) {
const cells = chart._facetGrid.cells;
for (let i = 0; i < cells.length; i++) {
const plot = cells[i].layout.plotRect;
if (
mx >= plot.x &&
mx <= plot.x + plot.width &&
my >= plot.y &&
my <= plot.y + plot.height
) {
return { layout: cells[i].layout, facetIdx: i };
}
}
return { layout: null, facetIdx: -1 };
}
return { layout: chart._lastLayout, facetIdx: -1 };
}
function hoverAllSeries(
chart: CartesianChart,
dataX: number,
dataY: number,
pxPerDataX: number,
pxPerDataY: number,
): number {
ensureCartesianSpatialGrid(chart);
let bestIdx = chart._hitTest.query(
dataX,
dataY,
TOOLTIP_RADIUS_PX,
pxPerDataX,
pxPerDataY,
chart._xData,
chart._yData,
);
if (bestIdx >= 0) {
return bestIdx;
}
// Brute-force fallback over every valid slot.
let bestDistSq = TOOLTIP_RADIUS_PX * TOOLTIP_RADIUS_PX;
const numSeries = Math.max(1, chart._splitGroups.length);
const cap = chart._seriesCapacity;
const xData = chart._xData!;
const yData = chart._yData!;
for (let s = 0; s < numSeries; s++) {
const count = chart._seriesUploadedCounts[s] ?? 0;
const base = s * cap;
for (let j = 0; j < count; j++) {
const idx = base + j;
const dx = (xData[idx] - dataX) * pxPerDataX;
const dy = (yData[idx] - dataY) * pxPerDataY;
const distSq = dx * dx + dy * dy;
if (distSq < bestDistSq) {
bestDistSq = distSq;
bestIdx = idx;
}
}
}
return bestIdx;
}
/**
* Hit-test a single series' slot range. Faceted mode scopes hover to
* the series that owns the facet under the cursor; the spatial grid
* spans all series so we do a brute-force scan over just that series'
* slice — cheap even for dense datasets because only `count[s]` slots
* are read.
*/
function hoverOneSeries(
chart: CartesianChart,
seriesIdx: number,
dataX: number,
dataY: number,
pxPerDataX: number,
pxPerDataY: number,
): number {
const count = chart._seriesUploadedCounts[seriesIdx] ?? 0;
if (count === 0) {
return -1;
}
const cap = chart._seriesCapacity;
const base = seriesIdx * cap;
const xData = chart._xData!;
const yData = chart._yData!;
let bestDistSq = TOOLTIP_RADIUS_PX * TOOLTIP_RADIUS_PX;
let bestIdx = -1;
for (let j = 0; j < count; j++) {
const idx = base + j;
const dx = (xData[idx] - dataX) * pxPerDataX;
const dy = (yData[idx] - dataY) * pxPerDataY;
const distSq = dx * dx + dy * dy;
if (distSq < bestDistSq) {
bestDistSq = distSq;
bestIdx = idx;
}
}
return bestIdx;
}
/**
* Show a sticky (pinned) tooltip at the given point, anchored to the
* GL canvas's parent via the tooltip controller.
*
* In faceted mode, resolves the source facet from `pointIdx` and uses
* that cell's layout so the tooltip anchors to the correct sub-plot.
*/
export function showCartesianPinnedTooltip(
chart: CartesianChart,
pointIdx: number,
): void {
chart._tooltip.dismiss();
chart._pinnedIndex = pointIdx;
if (pointIdx < 0 || !chart._xData || !chart._yData) {
return;
}
const layout = layoutForIndex(chart, pointIdx);
if (!layout) {
return;
}
const xOrigin = isNaN(chart._xOrigin) ? 0 : chart._xOrigin;
const yOrigin = isNaN(chart._yOrigin) ? 0 : chart._yOrigin;
const pos = layout.dataToPixel(
chart._xData[pointIdx] + xOrigin,
chart._yData[pointIdx] + yOrigin,
);
const serial = chart._lazyTooltip.beginPin();
chart.glyph.buildTooltipLines(chart, pointIdx).then((lines) => {
// Abandon the pin if the user moved on (another pin/dismiss
// between click and resolve) or the underlying view changed.
if (!chart._lazyTooltip.isPinFresh(serial)) {
return;
}
if (chart._pinnedIndex !== pointIdx) {
return;
}
if (lines.length === 0) {
return;
}
chart._tooltip.pin(lines, pos, layout);
});
chart._hoveredIndex = -1;
chart._hoveredFacet = -1;
renderCartesianChromeOverlay(chart);
}
function layoutForIndex(
chart: CartesianChart,
pointIdx: number,
): import("../../layout/plot-layout").PlotLayout | null {
if (chart._facetGrid && chart._seriesCapacity > 0) {
const s = Math.floor(pointIdx / chart._seriesCapacity);
const cell = chart._facetGrid.cells[s];
if (cell) {
return cell.layout;
}
}
return chart._lastLayout;
}
export function dismissCartesianPinnedTooltip(chart: CartesianChart): void {
chart._tooltip.dismiss();
chart._pinnedIndex = -1;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,508 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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/view-reader";
import type { WebGLContextManager } from "../../webgl/context-manager";
import { AbstractChart } from "../chart-base";
import { SpatialHitTester } from "../../interaction/hit-test";
import { PlotLayout } from "../../layout/plot-layout";
import { type AxisDomain } from "../../axis/numeric-axis";
import type { CategoricalDomain } from "../../axis/categorical-axis";
import type { GradientTextureCache } from "../../webgl/gradient-texture";
import type { Glyph } from "./glyph";
import {
initCartesianPipeline,
processCartesianChunk,
} from "./cartesian-build";
import {
renderCartesianFrame,
renderCartesianChromeOverlay,
} from "./cartesian-render";
import {
handleCartesianHover,
showCartesianPinnedTooltip,
dismissCartesianPinnedTooltip,
} from "./cartesian-interact";
import type { LabelInterner } from "./label-interner";
import { LazyTooltip } from "../../interaction/lazy-tooltip";
export interface SplitGroup {
prefix: string;
xColName: string;
yColName: string;
colorColName: string;
sizeColName: string;
labelColName: string;
}
/**
* Unified continuous (numeric X/Y) chart. Glyphs plug in to render
* points, lines, or (future) areas over the shared data pipeline:
* streaming chunk upload, per-series slotted buffer layout, pan/zoom,
* spatial hit testing, chrome overlay, tooltip controller.
*
* Fields are package-internal (no `private`) so the split helper
* modules and glyphs can read/write them.
*/
export class CartesianChart extends AbstractChart {
readonly glyph: Glyph;
constructor(glyph: Glyph) {
super();
this.glyph = glyph;
}
/**
* Rendering pipeline selector. `"cartesian"` is the default —
* draws axes, gridlines, and ticks via the chrome canvas.
* `"map"` (set by `MapChart` subclasses) suppresses cartesian
* chrome and inserts a raster tile layer underneath the glyph
* draw in `_fullRender`, so the same glyphs (point / line /
* density) render on top of a basemap.
*
* Read in `cartesian-render.ts` at three branch points; the
* `"cartesian"` path is byte-for-byte unchanged by the addition
* of this enum.
*/
_renderMode: "cartesian" | "map" = "cartesian";
/**
* Per-point data-space projection hook. Default is identity; map
* subclasses override to map (lon, lat) → Mercator meters. Called
* from `processCartesianChunk` immediately after the NaN guard,
* before extent accumulation and the `_xData` / `_yData` slot
* writes — so every downstream consumer (axis domain, projection
* matrix, spatial hit-test, glyph buffers) sees projected space
* uniformly. Returning `[NaN, NaN]` from a subclass discards the
* row (e.g. Mercator's ±85° latitude clamp).
*/
projectPoint(x: number, y: number): [number, number] {
return [x, y];
}
/**
* Paint a per-frame background inside the plot-frame scissor,
* before the glyph draw. Map subclasses override to render the
* raster tile basemap; the default no-op leaves cartesian charts
* byte-for-byte unchanged.
*
* Called once per facet in faceted mode (each call's `projection`
* and `domain` are that cell's), wrapped in the cell's scissor —
* just like `glyph.drawSeries`.
*
* `xOrigin` / `yOrigin` are the rebase origins the projection
* matrix bakes in (see `buildProjectionMatrix`). Glyphs ship
* pre-rebased positions, so the background pass must subtract
* them from absolute-domain coords (e.g. tile Mercator extents)
* before uploading vertex positions; otherwise the matrix
* over-corrects and the background lands off-screen by
* `sx * xOrigin` clip units.
*/
renderBackground(
_glManager: import("../../webgl/context-manager").WebGLContextManager,
_layout: import("../../layout/plot-layout").PlotLayout,
_projection: Float32Array,
_domain: { xMin: number; xMax: number; yMin: number; yMax: number },
_xOrigin: number,
_yOrigin: number,
): void {
// no-op for cartesian charts
}
/**
* Paint chrome (attribution, scale bar) for map mode on top of the
* chrome canvas, in place of the cartesian axes/gridlines/legend.
* Called only when `_renderMode === "map"`. Default no-op so
* cartesian charts still go through `renderAxesChrome`.
*/
renderMapChrome(
_canvas: import("../canvas-types").Canvas2D | null,
_layout: import("../../layout/plot-layout").PlotLayout,
_theme: import("../../theme/theme").Theme,
_dpr: number,
): void {
// no-op for cartesian charts
}
// GL resources
// Shared: gradient LUT texture (used by both glyphs for color mapping).
_gradientCache: GradientTextureCache | null = null;
// Column roles
_xName = "";
_yName = "";
_xLabel = "";
_yLabel = "";
_xIsRowIndex = false;
_colorName = "";
_sizeName = "";
_labelName = "";
_colorIsString = false;
/**
* When the X (or Y) axis source column is post-aggregation
* `string`-typed, the build pipeline writes per-row dictionary slot
* indices into `_xData` (or `_yData`) instead of numeric values,
* and the render pass dispatches `renderCategoricalXTicks` /
* `renderCategoricalYTicks` instead of the numeric axis painter.
*
* The companion `_xCategoryDictionary` / `_xCategorySeen` pair is
* built lazily during `processCartesianChunk` in first-seen row
* order; `(null)` is appended on first encounter of an invalid row
* rather than reserved at slot 0, so charts without missing values
* don't get a phantom slot.
*
* `_xCategoryDomain` is materialized once per frame in
* `cartesian-render` and held for chrome overlay redraws (same
* lifecycle as `_lastXDomain` on the numeric path).
*/
_xIsString = false;
_yIsString = false;
_xCategoryDictionary: string[] = [];
_yCategoryDictionary: string[] = [];
_xCategorySeen: Map<string, number> = new Map();
_yCategorySeen: Map<string, number> = new Map();
_xCategoryDomain: CategoricalDomain | null = null;
_yCategoryDomain: CategoricalDomain | null = null;
_splitGroups: SplitGroup[] = [];
// Data extents
_xMin = Infinity;
_xMax = -Infinity;
_yMin = Infinity;
_yMax = -Infinity;
/**
* Origin used to rebase x values before f32 narrowing. With datetime
* x columns the absolute timestamp is ~1.7e12, beyond f32 precision;
* storing `(x - _xOrigin)` keeps sub-millisecond fidelity in the
* `_xData` mirror, the GPU position attribute, and the projection
* matrix's `tx` term, avoiding the catastrophic cancellation that
* would otherwise push points outside the clip volume. NaN until
* the first valid x sample is observed.
*/
_xOrigin = NaN;
_yOrigin = NaN;
_colorMin = Infinity;
_colorMax = -Infinity;
_sizeMin = Infinity;
_sizeMax = -Infinity;
/**
* `domain_mode: "expand"` accumulators. The build pipeline seeds
* `_xMin/_xMax/_yMin/_yMax/_colorMin/_colorMax/_sizeMin/_sizeMax`
* from these instead of `±Infinity` when expand mode is active, so
* the per-row scan naturally unions new data into the running
* extent. Mirrored back from the live fields at the end of every
* `processCartesianChunk` so multi-chunk uploads accumulate into
* the same union. Cleared via `resetExpandedDomain` (called from
* the worker's `resetAllZooms` and the view-config setters on
* `AbstractChart`).
*/
_expandedXMin = Infinity;
_expandedXMax = -Infinity;
_expandedYMin = Infinity;
_expandedYMax = -Infinity;
_expandedColorMin = Infinity;
_expandedColorMax = -Infinity;
_expandedSizeMin = Infinity;
_expandedSizeMax = -Infinity;
// Data buffers (per-series slotted)
// Series `s` owns indices `[s*_seriesCapacity, (s+1)*_seriesCapacity)`
// in the flat `_xData`/`_yData`/`_colorData` arrays and their GPU
// counterparts. `_seriesUploadedCounts[s]` tracks how many slots at
// the head of series `s` hold valid data; glyphs dispatch tight
// per-series draws using this count so the tail slots are never
// rasterized.
_seriesCapacity = 0;
_seriesUploadedCounts: number[] = [];
_maxSeriesUploaded = 0;
_xData: Float32Array | null = null;
_yData: Float32Array | null = null;
_colorData: Float32Array | null = null;
/**
* Source view row index for each slot in `_xData` / `_yData`,
* sized and laid out identically. Split expansion duplicates the
* same arrow source row across every series; this sidecar stores
* that source index so lazy tooltip fetches can retrieve the
* original row. Int32 for compactness — at 1M points this is
* ~4 MB, a small fraction of the ~70 MB that the prior eager
* row-data buffers cost.
*/
_rowIndexData: Int32Array | null = null;
/**
* Slot-indexed string store for the scatter "Label" column. `null`
* when no label column was wired. See {@link LabelInterner} — the
* three formerly-separate label fields (`_labelData`,
* `_labelDictionary`, `_labelDictMap`) live there as one unit, so
* future label-related state stays cohesive instead of accreting
* sibling fields on the chart.
*/
_labels: LabelInterner | null = null;
_dataCount = 0;
_uniqueColorLabels: Map<string, number> = new Map();
/**
* Lazy-tooltip cache. `lines` is `null` until the async row fetch
* resolves — the chrome overlay skips the tooltip text box in
* that state but still paints the crosshair + highlight ring
* from geometry data so the hover cue is immediate. The
* controller owns the serial dance that drops stale resolves
* when the user moves before the fetch returns. Target type is
* the flat slot index of the hovered point.
*/
_lazyTooltip = new LazyTooltip<number>();
// Staging scratch (reused across chunks)
_stagingPositions: Float32Array | null = null;
_stagingColors: Float32Array | null = null;
_stagingSizes: Float32Array | null = null;
_stagingChunkSize = 0;
// Interaction
_hitTest = new SpatialHitTester();
_lastLayout: PlotLayout | null = null;
_hoveredIndex = -1;
_pinnedIndex = -1;
/**
* Source facet for the current hover (`-1` when not over any facet).
* Drives coordinated hover indicator painting in other facets.
*/
_hoveredFacet = -1;
// Facet state (set when rendering in grid mode)
_facetGrid: import("../../layout/facet-grid").FacetGrid | null = null;
// Last-frame cache (for chrome overlay-only redraws)
_lastXDomain: AxisDomain | null = null;
_lastYDomain: AxisDomain | null = null;
_lastXTicks: number[] | null = null;
_lastYTicks: number[] | null = null;
_lastGradientStops: import("../../theme/gradient").GradientStop[] | null =
null;
_lastHasColorCol = false;
// Memoized categorical LUT stops — `ensureGradientTexture` uses
// reference-equality on this array to skip rebuilding the 256-sample
// texture. The cache key carries the inputs that determine the
// resolved palette: `seriesPalette` reference (changes per theme,
// since `_resolveTheme` returns a fresh `Theme` after
// `invalidateTheme()` clears the cache) plus `labelCount`. Without
// the `seriesPalette` reference compare a `restyle()` could leave
// the chart painting with the prior theme's colors — same
// `labelCount`/palette length but different RGB values.
_lastLutStops: import("../../theme/gradient").GradientStop[] | null = null;
_lastLutSeriesPalette: [number, number, number][] | null = null;
_lastLutLabelCount = -1;
protected override tooltipCallbacks() {
return {
onHover: (mx: number, my: number) =>
handleCartesianHover(this, mx, my),
onLeave: () => {
if (this._hoveredIndex !== -1) {
this._hoveredIndex = -1;
renderCartesianChromeOverlay(this);
}
},
onPin: (mx: number, my: number) => {
// Refresh the hit-test at the click coords so the pin
// path doesn't depend on the RAF-throttled hover state
// — see comment in `series.ts` `onPin`.
handleCartesianHover(this, mx, my);
if (this._hoveredIndex >= 0) {
const flatIdx = this._hoveredIndex;
showCartesianPinnedTooltip(this, flatIdx);
void this._emitCartesianClickSelect(flatIdx);
}
},
onUnpin: () => {
this.emitUnselect();
},
};
}
/**
* Resolve a clicked cartesian point into a `PerspectiveClickDetail`
* and emit both `perspective-click` and
* `perspective-global-filter selected:true`.
*
* Cartesian charts don't use `group_by` for positioning; X and Y
* come from explicit user-selected columns. The only filter clause
* we can build is the split-by prefix (when present). The source
* row index is the chart's per-point `_rowIndexData[flatIdx]`
* mirror — same lookup the lazy tooltip uses.
*/
private async _emitCartesianClickSelect(flatIdx: number): Promise<void> {
if (!this._rowIndexData) {
return;
}
const rowIdx = this._rowIndexData[flatIdx];
const yColumn = this._columnSlots[1] || this._columnSlots[0] || "";
let splitByValues: (string | null)[] = [];
if (this._splitGroups.length > 0 && this._seriesCapacity > 0) {
const seriesIdx = Math.floor(flatIdx / this._seriesCapacity);
const sg = this._splitGroups[seriesIdx];
if (sg?.prefix && this._splitBy.length > 0) {
splitByValues = sg.prefix.split("|");
}
}
await this.emitClickAndSelect({
rowIdx: rowIdx != null && rowIdx >= 0 ? rowIdx : null,
columnName: yColumn,
groupByValues: [],
splitByValues,
});
}
async uploadAndRender(
glManager: WebGLContextManager,
columns: ColumnDataMap,
startRow: number,
endRow: number,
): Promise<void> {
const chunkLength = endRow - startRow;
this._glManager = glManager;
if (startRow === 0) {
initCartesianPipeline(this, glManager, columns, endRow);
}
if (chunkLength === 0) {
return;
}
processCartesianChunk(
this,
glManager,
columns,
startRow,
chunkLength,
endRow,
);
// `domain_mode: "expand"` mirror-back. `processCartesianChunk`
// updates `_xMin/_xMax` etc. in place against the seeded value
// (the prior accumulator); the union is in `_xMin` etc., so we
// copy it back. Idempotent across multi-chunk uploads — every
// chunk leaves the accumulator equal to the running union.
//
// Categorical axes opt out: slot indices are first-seen-order
// and only meaningful within a single frame's dictionary, so
// expanding across frames would mix dictionaries and shift
// every category's slot. Force-fit those axes per frame
// instead.
if (this._pluginConfig.domain_mode === "expand") {
if (!this._xIsString) {
this._expandedXMin = this._xMin;
this._expandedXMax = this._xMax;
}
if (!this._yIsString) {
this._expandedYMin = this._yMin;
this._expandedYMax = this._yMax;
}
this._expandedColorMin = this._colorMin;
this._expandedColorMax = this._colorMax;
this._expandedSizeMin = this._sizeMin;
this._expandedSizeMax = this._sizeMax;
}
await this.requestRender(glManager);
}
override resetExpandedDomain(): void {
this._expandedXMin = Infinity;
this._expandedXMax = -Infinity;
this._expandedYMin = Infinity;
this._expandedYMax = -Infinity;
this._expandedColorMin = Infinity;
this._expandedColorMax = -Infinity;
this._expandedSizeMin = Infinity;
this._expandedSizeMax = -Infinity;
}
_fullRender(glManager: WebGLContextManager): void {
if (glManager.uploadedCount === 0 && this._dataCount === 0) {
return;
}
this._glManager = glManager;
renderCartesianFrame(this, glManager);
}
protected destroyInternal(): void {
this.glyph.destroy(this);
this._gradientCache = null;
this._xData = null;
this._yData = null;
this._colorData = null;
this._rowIndexData = null;
this._labels = null;
this._lazyTooltip.clearHover();
this._uniqueColorLabels.clear();
this._hitTest.clear();
this._stagingPositions = null;
this._stagingColors = null;
this._stagingSizes = null;
this._splitGroups = [];
this._seriesUploadedCounts = [];
dismissCartesianPinnedTooltip(this);
}
}
// Convenience subclasses with nullary constructors
// `index.ts` registers plugin tags via `new ImplClass()`, so each chart
// type needs a parameterless constructor. These wrappers pin the glyph.
import { PointGlyph } from "./glyphs/points";
import { LineGlyph } from "./glyphs/lines";
import { DensityGlyph } from "./glyphs/density";
/**
* X/Y Scatter — continuous chart with the point glyph.
*/
export class ScatterChart extends CartesianChart {
constructor() {
super(new PointGlyph());
}
}
/**
* X/Y Line — continuous chart with the line glyph.
*/
export class LineChart extends CartesianChart {
constructor() {
super(new LineGlyph());
}
}
/**
* Density — continuous chart that rasterizes each row as an
* additive radial splat, producing a density field over the plot rect.
* Shares the cartesian pipeline (build, hit-test, zoom, facets,
* tooltips); the glyph swaps the per-point glyph for the heat
* accumulation + resolve pair.
*/
export class DensityChart extends CartesianChart {
constructor() {
super(new DensityGlyph());
}
}
@@ -0,0 +1,81 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { WebGLContextManager } from "../../webgl/context-manager";
import type { CartesianChart } from "./cartesian";
/**
* A Glyph is a pluggable renderer for a {@link CartesianChart}. The
* chart owns all data and shared pipeline (init, chunk processing, hover,
* chrome, tooltip plumbing); the glyph owns its shader program, draw
* call, and per-glyph tooltip lines.
*/
export interface Glyph {
/**
* `"point"` for scatter-style markers; `"line"` for polylines;
* `"density"` for the density-field shader glyph.
*/
readonly name: "point" | "line" | "density";
/**
* Compile the program + cache attrib/uniform locations on first
* frame. Subsequent frames are a no-op.
*/
ensureProgram(chart: CartesianChart, glManager: WebGLContextManager): void;
/**
* Issue the draw call(s) for this glyph's visible geometry.
*/
draw(
chart: CartesianChart,
glManager: WebGLContextManager,
projection: Float32Array,
): void;
/**
* Issue draw calls for a single series' slice only. Used by
* faceted rendering: one facet per split, each facet's scissor
* clips to its plot rect and only that series rasterizes inside.
*
* Implementations should bind uniforms/buffers once (same as
* `draw`) and dispatch only the drawArrays call(s) for
* `seriesIdx`.
*/
drawSeries(
chart: CartesianChart,
glManager: WebGLContextManager,
projection: Float32Array,
seriesIdx: number,
): void;
/**
* Per-hover tooltip content for the point at `flatIdx`. Returns a
* Promise because some glyphs (notably `PointGlyph`) need to fetch
* the source row from the view on demand for extra-column lookups.
* Glyphs whose tooltip is geometry-only (e.g. `LineGlyph`) return
* a microtask-resolved promise.
*/
buildTooltipLines(
chart: CartesianChart,
flatIdx: number,
): Promise<string[]>;
/**
* Hover-overlay options (crosshair, highlight radius).
*/
tooltipOptions(): { crosshair: boolean; highlightRadius: number };
/**
* Release GL resources created by `ensureProgram`.
*/
destroy(chart: CartesianChart): void;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,318 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { WebGLContextManager } from "../../../webgl/context-manager";
import type { CartesianChart } from "../cartesian";
import type { Glyph } from "../glyph";
import { bindGradientTexture } from "../../../webgl/gradient-texture";
import {
createLineCornerBuffer,
getInstancing,
} from "../../../webgl/instanced-attrs";
import { compileProgram } from "../../../webgl/program-cache";
import { formatTickValue, formatDateTickValue } from "../../../layout/ticks";
import lineVert from "../../../shaders/line.vert.glsl";
import lineFrag from "../../../shaders/line.frag.glsl";
interface LineCache {
program: WebGLProgram;
cornerBuffer: WebGLBuffer;
u_projection: WebGLUniformLocation | null;
u_resolution: WebGLUniformLocation | null;
u_line_width: WebGLUniformLocation | null;
u_color_range: WebGLUniformLocation | null;
u_gradient_lut: WebGLUniformLocation | null;
a_start: number;
a_end: number;
a_color_start: number;
a_color_end: number;
a_corner: number;
}
/**
* Polyline glyph — instanced triangle-strip segments between adjacent
* same-series points. Segments are scoped per-series via byte-offset
* rebinding (see `drawLineSeries`); the shader reads the endpoints'
* raw color values and samples the gradient LUT via the same sign-
* aware `(v - cmin) / (cmax - cmin)` mapping the scatter glyph uses.
*/
export class LineGlyph implements Glyph {
readonly name = "line" as const;
private _cache: LineCache | null = null;
ensureProgram(
_chart: CartesianChart,
glManager: WebGLContextManager,
): void {
if (this._cache) {
return;
}
const partial = compileProgram<Omit<LineCache, "cornerBuffer">>(
glManager,
"line",
lineVert,
lineFrag,
[
"u_projection",
"u_resolution",
"u_line_width",
"u_color_range",
"u_gradient_lut",
],
["a_start", "a_end", "a_color_start", "a_color_end", "a_corner"],
);
this._cache = {
...partial,
cornerBuffer: createLineCornerBuffer(glManager.gl),
};
}
draw(
chart: CartesianChart,
glManager: WebGLContextManager,
projection: Float32Array,
): void {
const cache = this._cache;
if (!cache) {
return;
}
const bind = bindLineState(cache, chart, glManager, projection);
if (!bind) {
return;
}
const numSeries = Math.max(1, chart._splitGroups.length);
for (let s = 0; s < numSeries; s++) {
drawLineSeries(cache, chart, glManager, s);
}
unbindLineDivisors(cache, glManager);
}
drawSeries(
chart: CartesianChart,
glManager: WebGLContextManager,
projection: Float32Array,
seriesIdx: number,
): void {
const cache = this._cache;
if (!cache) {
return;
}
if (!bindLineState(cache, chart, glManager, projection)) {
return;
}
drawLineSeries(cache, chart, glManager, seriesIdx);
unbindLineDivisors(cache, glManager);
}
// helpers
async buildTooltipLines(
chart: CartesianChart,
flatIdx: number,
): Promise<string[]> {
const lines: string[] = [];
if (!chart._xData || !chart._yData) {
return lines;
}
if (chart._splitGroups.length > 0 && chart._seriesCapacity > 0) {
const seriesIdx = Math.floor(flatIdx / chart._seriesCapacity);
const sg = chart._splitGroups[seriesIdx];
if (sg) {
lines.push(sg.prefix);
}
}
const xVal = chart._xData[flatIdx];
const yVal = chart._yData[flatIdx];
const xType = chart._columnTypes[chart._xLabel] || "";
const xIsDate = xType === "date" || xType === "datetime";
const xFormatted = xIsDate
? formatDateTickValue(xVal)
: formatTickValue(xVal);
lines.push(`${chart._xLabel || "Row"}: ${xFormatted}`);
const yType = chart._columnTypes[chart._yLabel] || "";
const yIsDate = yType === "date" || yType === "datetime";
const yFormatted = yIsDate
? formatDateTickValue(yVal)
: formatTickValue(yVal);
lines.push(`${chart._yLabel}: ${yFormatted}`);
return lines;
}
tooltipOptions() {
return { crosshair: true, highlightRadius: 5 };
}
destroy(chart: CartesianChart): void {
const cache = this._cache;
if (cache?.cornerBuffer && chart._glManager) {
chart._glManager.gl.deleteBuffer(cache.cornerBuffer);
}
this._cache = null;
}
}
/**
* Shared pre-draw state setup for `draw` and `drawSeries`. Binds the
* program, uploads uniforms + gradient texture, binds the static corner
* buffer, enables the instanced attributes. Returns false if the
* gradient cache is missing.
*/
function bindLineState(
cache: LineCache,
chart: CartesianChart,
glManager: WebGLContextManager,
projection: Float32Array,
): boolean {
const gl = glManager.gl;
if (!chart._gradientCache) {
return false;
}
const dpr = glManager.dpr;
gl.useProgram(cache.program);
gl.uniformMatrix4fv(cache.u_projection, false, projection);
gl.uniform2f(cache.u_resolution, gl.canvas.width, gl.canvas.height);
gl.uniform1f(cache.u_line_width, chart._pluginConfig.line_width_px * dpr);
if (chart._colorMin < chart._colorMax) {
gl.uniform2f(cache.u_color_range, chart._colorMin, chart._colorMax);
} else {
gl.uniform2f(cache.u_color_range, 0.0, 0.0);
}
bindGradientTexture(
glManager,
chart._gradientCache.texture,
cache.u_gradient_lut,
0,
);
const instancing = getInstancing(glManager);
const { setDivisor } = instancing;
gl.bindBuffer(gl.ARRAY_BUFFER, cache.cornerBuffer);
gl.enableVertexAttribArray(cache.a_corner);
gl.vertexAttribPointer(cache.a_corner, 1, gl.FLOAT, false, 0, 0);
setDivisor(cache.a_corner, 0);
gl.enableVertexAttribArray(cache.a_start);
setDivisor(cache.a_start, 1);
gl.enableVertexAttribArray(cache.a_end);
setDivisor(cache.a_end, 1);
gl.enableVertexAttribArray(cache.a_color_start);
setDivisor(cache.a_color_start, 1);
gl.enableVertexAttribArray(cache.a_color_end);
setDivisor(cache.a_color_end, 1);
return true;
}
/**
* Dispatch one instanced draw for series `s`. Rebinds start/end attrib
* pointers with byte offsets into the slotted buffer so instance 0 is
* the series' first segment.
*/
function drawLineSeries(
cache: LineCache,
chart: CartesianChart,
glManager: WebGLContextManager,
s: number,
): void {
const count = chart._seriesUploadedCounts[s] ?? 0;
if (count < 2) {
return;
}
const gl = glManager.gl;
const cap = chart._seriesCapacity;
const posStride = 2 * Float32Array.BYTES_PER_ELEMENT;
const idStride = Float32Array.BYTES_PER_ELEMENT;
// Render-path uses `peek`. If buffers haven't been uploaded
// yet (pan/zoom render landing between a pending draw's
// `ensureBufferCapacity` and its `uploadChunk`), skip — drawing
// against a recreated zero-filled buffer would produce one frame
// of empty plot area.
const posBuf = glManager.bufferPool.peek("a_position");
const idBuf = glManager.bufferPool.peek("a_color_value");
if (!posBuf || !idBuf) {
return;
}
const posBase = s * cap * posStride;
gl.bindBuffer(gl.ARRAY_BUFFER, posBuf.buffer);
gl.vertexAttribPointer(
cache.a_start,
2,
gl.FLOAT,
false,
posStride,
posBase,
);
gl.vertexAttribPointer(
cache.a_end,
2,
gl.FLOAT,
false,
posStride,
posBase + posStride,
);
const idBase = s * cap * idStride;
gl.bindBuffer(gl.ARRAY_BUFFER, idBuf.buffer);
gl.vertexAttribPointer(
cache.a_color_start,
1,
gl.FLOAT,
false,
idStride,
idBase,
);
gl.vertexAttribPointer(
cache.a_color_end,
1,
gl.FLOAT,
false,
idStride,
idBase + idStride,
);
getInstancing(glManager).drawArraysInstanced(
gl.TRIANGLE_STRIP,
0,
4,
count - 1,
);
}
function unbindLineDivisors(
cache: LineCache,
glManager: WebGLContextManager,
): void {
const { setDivisor } = getInstancing(glManager);
setDivisor(cache.a_start, 0);
setDivisor(cache.a_end, 0);
setDivisor(cache.a_color_start, 0);
setDivisor(cache.a_color_end, 0);
}
@@ -0,0 +1,234 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { WebGLContextManager } from "../../../webgl/context-manager";
import type { CartesianChart } from "../cartesian";
import type { Glyph } from "../glyph";
import { bindGradientTexture } from "../../../webgl/gradient-texture";
import { compileProgram } from "../../../webgl/program-cache";
import { buildPointRowTooltipLines } from "../tooltip-lines";
import scatterVert from "../../../shaders/scatter.vert.glsl";
import scatterFrag from "../../../shaders/scatter.frag.glsl";
type GL = WebGL2RenderingContext | WebGLRenderingContext;
interface PointCache {
program: WebGLProgram;
u_projection: WebGLUniformLocation | null;
u_point_size: WebGLUniformLocation | null;
u_color_range: WebGLUniformLocation | null;
u_gradient_lut: WebGLUniformLocation | null;
u_size_range: WebGLUniformLocation | null;
u_point_size_range: WebGLUniformLocation | null;
a_position: number;
a_color_value: number;
a_size_value: number;
}
/**
* `gl.POINTS` glyph — one squared/antialiased point per data row. Color
* and size are driven by the shared `a_color_value` / `a_size_value`
* buffers; the vertex shader does sign-aware color-t mapping and samples
* the gradient LUT. One draw call per series (the slot layout leaves
* gaps at each series' tail that we can't safely include in a single
* draw — dispatching `count[s]` per series skips them).
*/
export class PointGlyph implements Glyph {
readonly name = "point" as const;
private _cache: PointCache | null = null;
ensureProgram(
_chart: CartesianChart,
glManager: WebGLContextManager,
): void {
if (this._cache) {
return;
}
this._cache = compileProgram<PointCache>(
glManager,
"scatter",
scatterVert,
scatterFrag,
[
"u_projection",
"u_point_size",
"u_color_range",
"u_gradient_lut",
"u_size_range",
"u_point_size_range",
],
["a_position", "a_color_value", "a_size_value"],
);
}
draw(
chart: CartesianChart,
glManager: WebGLContextManager,
projection: Float32Array,
): void {
const cache = this._cache;
if (!cache) {
return;
}
if (!bindPointState(cache, chart, glManager, projection)) {
return;
}
// Per-series tight draws: each series `s` occupies slots
// `[s*cap, s*cap + count[s])`. Dispatching `count[s]` avoids
// rasterizing unused tail slots. All attribs have divisor=0 so
// `first` shifts them together.
const gl = glManager.gl;
const numSeries = Math.max(1, chart._splitGroups.length);
const cap = chart._seriesCapacity;
for (let s = 0; s < numSeries; s++) {
const count = chart._seriesUploadedCounts[s] ?? 0;
if (count <= 0) {
continue;
}
gl.drawArrays(gl.POINTS, s * cap, count);
}
}
drawSeries(
chart: CartesianChart,
glManager: WebGLContextManager,
projection: Float32Array,
seriesIdx: number,
): void {
const cache = this._cache;
if (!cache) {
return;
}
if (!bindPointState(cache, chart, glManager, projection)) {
return;
}
const count = chart._seriesUploadedCounts[seriesIdx] ?? 0;
if (count <= 0) {
return;
}
const gl = glManager.gl;
const cap = chart._seriesCapacity;
gl.drawArrays(gl.POINTS, seriesIdx * cap, count);
}
buildTooltipLines(
chart: CartesianChart,
flatIdx: number,
): Promise<string[]> {
return buildPointRowTooltipLines(chart, flatIdx);
}
tooltipOptions() {
return { crosshair: true, highlightRadius: 6 };
}
destroy(_chart: CartesianChart): void {
// Program lifetime is owned by the shader registry; just drop
// the cache reference. No private GPU resources to free.
this._cache = null;
}
}
function setUniforms(
cache: PointCache,
gl: GL,
projection: Float32Array,
chart: CartesianChart,
dpr: number,
): void {
gl.uniformMatrix4fv(cache.u_projection, false, projection);
gl.uniform1f(cache.u_point_size, chart._pluginConfig.point_size_px * dpr);
if (chart._colorMin < chart._colorMax) {
gl.uniform2f(cache.u_color_range, chart._colorMin, chart._colorMax);
} else {
gl.uniform2f(cache.u_color_range, 0.0, 0.0);
}
if (chart._sizeMin < chart._sizeMax) {
gl.uniform2f(cache.u_size_range, chart._sizeMin, chart._sizeMax);
} else {
gl.uniform2f(cache.u_size_range, 0.0, 0.0);
}
const size_scale_factor = Math.min(chart._pluginConfig.point_size_px, 3);
gl.uniform2f(
cache.u_point_size_range,
Math.max(
2 * dpr,
(chart._pluginConfig.point_size_px / size_scale_factor) * dpr,
),
chart._pluginConfig.point_size_px * size_scale_factor * dpr,
);
}
/**
* Shared pre-draw state setup for `draw` and `drawSeries`. Binds the
* program, uploads uniforms + gradient texture, wires the three per-
* vertex attributes. Returns false if the gradient cache is missing.
*/
function bindPointState(
cache: PointCache,
chart: CartesianChart,
glManager: WebGLContextManager,
projection: Float32Array,
): boolean {
const gl = glManager.gl;
if (!chart._gradientCache) {
return false;
}
gl.useProgram(cache.program);
setUniforms(cache, gl, projection, chart, glManager.dpr);
bindGradientTexture(
glManager,
chart._gradientCache.texture,
cache.u_gradient_lut,
0,
);
// Render-path uses `peek` (not `getOrCreate`) so we never
// recreate buffers from the draw path. If a buffer hasn't been
// uploaded yet — e.g. pan/zoom render landing between a pending
// draw's `ensureBufferCapacity` and its `uploadChunk` — return
// false and let the caller skip `drawArrays`. Painting against
// a freshly-recreated zero-filled buffer would show one frame
// of empty plot area while gridlines/chrome remain correct.
const posBuf = glManager.bufferPool.peek("a_position");
const colorBuf = glManager.bufferPool.peek("a_color_value");
const sizeBuf = glManager.bufferPool.peek("a_size_value");
if (!posBuf || !colorBuf || !sizeBuf) {
return false;
}
gl.bindBuffer(gl.ARRAY_BUFFER, posBuf.buffer);
gl.enableVertexAttribArray(cache.a_position);
gl.vertexAttribPointer(cache.a_position, 2, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, colorBuf.buffer);
gl.enableVertexAttribArray(cache.a_color_value);
gl.vertexAttribPointer(cache.a_color_value, 1, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, sizeBuf.buffer);
gl.enableVertexAttribArray(cache.a_size_value);
gl.vertexAttribPointer(cache.a_size_value, 1, gl.FLOAT, false, 0, 0);
return true;
}
@@ -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). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
/**
* Slot-indexed string store for the scatter "Label" column. Strings
* are deduplicated across split-by facets so identical labels share a
* dictionary entry; the per-slot `Int32Array` then holds dictionary
* indices (`-1` means "no label for this slot").
*/
export class LabelInterner {
readonly data: Int32Array;
readonly dictionary: string[] = [];
private readonly dictMap: Map<string, number> = new Map();
constructor(capacity: number) {
this.data = new Int32Array(capacity);
this.data.fill(-1);
}
/**
* Insert (or look up) `label` and write its dictionary index into
* the slot at `flatIdx`. Returns the assigned dictionary index.
*/
set(flatIdx: number, label: string): number {
let mapped = this.dictMap.get(label);
if (mapped === undefined) {
mapped = this.dictionary.length;
this.dictionary.push(label);
this.dictMap.set(label, mapped);
}
this.data[flatIdx] = mapped;
return mapped;
}
/**
* Resolve a slot's label string, or `null` if unset.
*/
get(flatIdx: number): string | null {
const idx = this.data[flatIdx];
if (idx < 0) {
return null;
}
return this.dictionary[idx];
}
}
@@ -0,0 +1,80 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { CartesianChart } from "./cartesian";
/**
* Build the per-row tooltip for a point-style glyph (scatter, gradient
* heatmap). Resolves the source arrow row via the chart's lazy row
* fetcher, then surfaces every non-null column under the (split-aware)
* prefix filter formatted by column type.
*
* Returns `[]` when the chart has no row-index mirror or no fetcher;
* callers should fall back to a geometry-only tooltip in that case.
*/
export async function buildPointRowTooltipLines(
chart: CartesianChart,
flatIdx: number,
): Promise<string[]> {
const lines: string[] = [];
if (!chart._rowIndexData || !chart._lazyRows) {
return lines;
}
const rowIdx = chart._rowIndexData[flatIdx];
if (rowIdx < 0) {
return lines;
}
if (chart._splitGroups.length > 0 && chart._seriesCapacity > 0) {
const seriesIdx = Math.floor(flatIdx / chart._seriesCapacity);
const sg = chart._splitGroups[seriesIdx];
if (sg?.prefix) {
lines.push(sg.prefix);
}
}
const row = await chart._lazyRows.fetchRow(rowIdx);
const prefixFilter =
chart._splitGroups.length > 0 && chart._seriesCapacity > 0
? (chart._splitGroups[Math.floor(flatIdx / chart._seriesCapacity)]
?.prefix ?? null)
: null;
for (const [colName, value] of row) {
if (value === null || value === undefined) {
continue;
}
let displayName = colName;
if (prefixFilter !== null) {
const expected = `${prefixFilter}|`;
if (!colName.startsWith(expected)) {
continue;
}
displayName = colName.substring(expected.length);
} else if (colName.includes("|")) {
continue;
}
if (typeof value === "number") {
const formatted = chart.getColumnFormatter(colName, "value")(value);
lines.push(`${displayName}: ${formatted}`);
} else {
lines.push(`${displayName}: ${value}`);
}
}
return lines;
}
@@ -0,0 +1,854 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 {
createNumberFormatter,
createDatetimeFormatter,
createDateFormatter,
sourceColumn,
type NumberFormatConfig,
type DateFormatConfig,
} from "@perspective-dev/viewer/src/ts/column-format.js";
import type { ColumnDataMap } from "../data/view-reader";
import { LazyRowFetcher } from "../data/lazy-row";
import { formatTickValue, formatDateTickValue } from "../layout/ticks";
import type { WebGLContextManager } from "../webgl/context-manager";
import {
ZoomController,
type ZoomConfig,
} from "../interaction/zoom-controller";
import {
DEFAULT_FACET_CONFIG,
DEFAULT_PLUGIN_CONFIG,
type ChartImplementation,
type FacetConfig,
type PluginConfig,
} from "./chart";
import {
TooltipController,
type HostSink,
type TooltipCallbacks,
type UserClickPayload,
type UserSelectPayload,
} from "../interaction/tooltip-controller";
import type { PerspectiveClickDetail } from "../event-detail";
import type { ViewConfig } from "@perspective-dev/client";
import { resolveThemeFromVars, type Theme } from "../theme/theme";
import { requestRender as scheduleRender } from "../render/scheduler";
// TODO I don't know if this is the behavior we want. On the plus side, this
// ad-hoc formatter scales well to small and large data ranges, making a good
// guess at the right format without user input. On the minus side, this
// behavior is inconsistent with datagrid and the rest of the app, and the ad-hoc
// surprising behavior when overriding one field in `number_format` and suddenly
// the entire formatter is replaced.
const REGRESSION_BEHAVIOR = true;
/**
* Locale-aware fallback formatter applied to numeric tooltip / legend
* values when the column has no `number_format` configured. Two
* fractional digits matches the legacy datagrid default and gives
* tooltips a stable display width.
*/
const DEFAULT_VALUE_FORMATTER: (v: number) => string = ((): ((
v: number,
) => string) => {
if (REGRESSION_BEHAVIOR) {
return formatTickValue;
} else {
const intl = createNumberFormatter("float");
return (v) => intl.format(v);
}
})();
/**
* Locale-aware fallback formatter for datetime tooltip / legend values
* when the column has no `date_format` configured. Uses the locale
* default (no `dateStyle` / `timeStyle`) to match what most users
* expect from an `Intl.DateTimeFormat()` constructed with no options.
*/
const DEFAULT_DATETIME_FORMATTER: (v: number) => string = ((): ((
v: number,
) => string) => {
if (REGRESSION_BEHAVIOR) {
return formatDateTickValue;
} else {
const intl = createDatetimeFormatter();
return (v) => intl.format(v);
}
})();
/**
* Base class for WebGL chart implementations. Owns the common lifecycle
* plumbing (canvas wiring, viewer config setters, tooltip controller)
* so each concrete chart only implements data pipeline, rendering, and
* destruction hooks.
*
* ## Frame lifecycle (three phases)
*
* Every render of a chart passes through three phases:
*
* 1. `uploadAndRender(glManager, columns, startRow, endRow)`.
* Driven by the plugin wrapper once per data chunk. The subclass
* runs its build pipeline (axis/series resolution, record
* generation, domain accumulation) and pushes typed-array results
* into GPU buffers via `glManager.bufferPool`. Most charts also
* compile their shaders lazily here on first call.
*
* 2. `requestRender(glManager)` — single entrypoint for triggering a
* paint. Routes through the module-level scheduler
* ([render/scheduler.ts]) which coalesces by glManager and runs
* `_fullRender` + `awaitGpuFence` + `endFrame` on the next RAF.
* Concurrent requests collapse to one `_fullRender` per frame and
* fence waits across charts run in parallel, so per-chart latency
* is bounded by that chart's own GPU work.
*
* 3. `_fullRender(glManager)` — the subclass implements its own draw
* loop: resolve visible domains from the zoom controller, build
* projection matrices, call into its glyph draw helpers, and paint
* the chrome overlay (axes, legend, tooltip).
*
* `destroy()` is called by the plugin wrapper on teardown. It detaches
* tooltip listeners, then invokes the subclass's `destroyInternal()`
* to free chart-specific GL resources.
*
* ## What subclasses implement
* - `uploadAndRender` — phase 1; ends by `await this.requestRender(glManager)`.
* - `tooltipCallbacks()` — return chart-specific hover/click handlers.
* - `_fullRender` — phase 3; must be safe to call with no data
* (subclass guards on its own state machine — empty trees, missing
* programs, etc — and returns early without touching GL).
* - `destroyInternal` — release chart-specific resources.
*
* `getZoomConfig()` is an optional override; default = both axes
* zoom-unlocked. See {@link ZoomConfig}.
*/
export abstract class AbstractChart implements ChartImplementation {
// Access is `public` so the per-chart helper modules
// (e.g. `./bar/bar-build.ts`) can read/write these without fighting
// TypeScript's `protected` check. The underscore prefix marks them
// as internal by convention.
_glManager: WebGLContextManager | null = null;
_gridlineCanvas: HTMLCanvasElement | OffscreenCanvas | null = null;
_chromeCanvas: HTMLCanvasElement | OffscreenCanvas | null = null;
/**
* Host-supplied CSS-variable map. The host snapshots its DOM via
* `snapshotThemeVars(el)` and ships it over the control channel;
* the chart decodes via `resolveThemeFromVars` lazily in
* `_resolveTheme()`. The chart never reads the DOM itself (it
* always runs inside `WorkerRenderer`, possibly off-thread).
*/
_themeVars: Record<string, string> = {};
_zoomController: ZoomController | null = null;
/**
* Per-facet zoom controllers. Populated when `zoom_mode ===
* "independent"` and the chart enters faceted mode; each facet's
* render path reads its own viewport from the matching entry.
*
* Shared-zoom mode leaves this empty; `_zoomController` is the
* single domain used for every facet.
*/
_facetZoomControllers: ZoomController[] = [];
_columnSlots: (string | null)[] = [];
_groupBy: string[] = [];
_splitBy: string[] = [];
_columnTypes: Record<string, string> = {};
/**
* Effective shared-axis flags for the most recent faceted frame.
* Derived per-frame from `_facetConfig.shared_x_axis` /
* `shared_y_axis` and `zoom_mode` via
* {@link computeEffectiveFacetFlags} — independent-zoom mode forces
* both off because an outer axis band has no single domain it could
* display. Stored here (rather than mutated back onto
* `_facetConfig`) so the user's configured shared-axis preferences
* survive a "shared → independent → shared" round-trip. Read by
* chrome-overlay code (e.g. `renderFacetedChromeOverlay`,
* `renderFacetedHeatmapChromeOverlay`) after the main render pass
* sets them.
*/
_lastEffectiveSharedX = false;
_lastEffectiveSharedY = false;
/**
* Source-column types for `group_by` columns — sourced from
* `table.schema()` (plain columns) merged with `view.expression_schema()`
* (expression-typed group_bys). Distinct from `_columnTypes` (which
* is the post-aggregation `view.schema()` map): the level-type
* lookup for `__ROW_PATH_N__` columns must use the unaggregated
* type, since `view.schema()` doesn't key these synthetic columns.
*/
_groupByTypes: Record<string, string> = {};
_columnsConfig: Record<string, any> = {};
/**
* Pre-compiled per-column value formatters, keyed by the **source**
* column name (synthetic split-by paths are normalized via
* `sourceColumn`). Rebuilt by `setColumnsConfig` from the active
* plugin's `column_config_schema` output, then consulted by axis /
* tooltip / legend paths via {@link getColumnFormatter}.
*
* `undefined` means "no configured formatter for this column" — the
* caller falls back to the chart's hand-rolled tick formatter.
*/
_columnFormatters: Map<string, (v: number) => string> = new Map();
_defaultChartType: string | undefined = undefined;
_facetConfig: FacetConfig = { ...DEFAULT_FACET_CONFIG };
/**
* Plugin-scoped global configuration. Updated by `setPluginConfig`
* (driven from the host's `plugin.restore()`) and read by render-
* path glyphs (`line_width_px`, `point_size_px`, etc.) and by the
* build pipelines (`auto_alt_y_axis`, `band_inner_frac`,
* `bar_inner_pad`). Defaults preserve the previous compile-time
* constants so first-frame rendering before `restore()` matches
* the pre-refactor output.
*/
_pluginConfig: PluginConfig = { ...DEFAULT_PLUGIN_CONFIG };
_tooltip = new TooltipController();
/**
* Reference to the active host sink, captured in {@link attachTooltip}.
* Used to emit `perspective-click` / `perspective-global-filter` user
* events back to the host. Distinct from `_tooltip._host` to avoid
* reaching into the tooltip controller's internals.
*/
_hostSink: HostSink | null = null;
/**
* Promise chain that serializes user-event emissions so a rapid
* pin → unpin sequence stays in order even when `buildClickDetail`
* awaits `_lazyRows.fetchRow`. Without the queue, click 1's async
* row fetch could resolve AFTER click 2's synchronous `emitUnselect`
* — flipping the host's observed event order. All emit helpers
* (`emitClickAndSelect`, `emitUserClick`, `emitUserSelect`,
* `emitUnselect`) chain through this.
*/
_emitQueue: Promise<void> = Promise.resolve();
/**
* Cached resolved theme — populated on first `_resolveTheme()` call,
* cleared by `invalidateTheme()` (driven from `plugin.restyle()`).
* `getComputedStyle` / `getPropertyValue` reads cost ~100µs each;
* zoom/hover dispatch redraws at 60Hz so we resolve once and reuse.
*/
_theme: Theme | null = null;
/**
* On-demand single-row fetcher used by lazy tooltip column
* lookups. Reset on every `setView` call; subclasses read
* `_lazyRows.fetchRow(rowIdx)` from their hover/pin paths and
* compare a captured serial against the current hovered/pinned
* state at resolution time, so stale fetches never paint.
*
* Can be `null` on chart types that don't surface the View
* (unit-tested charts) or before the first `draw`.
*/
_lazyRows: LazyRowFetcher | null = null;
// ChartImplementation setters (trivial stores)
setGridlineCanvas(canvas: HTMLCanvasElement | OffscreenCanvas): void {
this._gridlineCanvas = canvas;
}
setChromeCanvas(canvas: HTMLCanvasElement | OffscreenCanvas): void {
this._chromeCanvas = canvas;
}
setTheme(vars: Record<string, string>): void {
this._themeVars = vars;
this._theme = null;
}
setZoomController(zc: ZoomController): void {
this._zoomController = zc;
zc.configure(this.getZoomConfig());
}
/**
* Resolve the zoom controller that owns facet `idx`. In shared-zoom
* mode (default) this is always the chart's single `_zoomController`.
* In independent-zoom mode the router provisions one controller per
* facet; this returns the matching entry, allocating on demand so
* the render path never has to check `zoom_mode` itself.
*/
getZoomControllerForFacet(idx: number): ZoomController | null {
if (this._facetConfig.zoom_mode === "shared") {
return this._zoomController;
}
if (!this._zoomController) {
return null;
}
let zc = this._facetZoomControllers[idx];
if (!zc) {
zc = new ZoomController();
zc.configure(this.getZoomConfig());
this._facetZoomControllers[idx] = zc;
}
return zc;
}
/**
* Derive the effective shared-X / shared-Y flags for the current
* frame and stamp them onto `_lastEffectiveSharedX/Y` for downstream
* chrome-overlay code to consume. Independent-zoom mode forces both
* shared flags off — the outer axis band cannot display per-cell
* viewports — without mutating the user's stored `_facetConfig`.
*
* Returns `{ independentZoom, effectiveSharedX, effectiveSharedY }`
* for callers that need the values immediately (e.g. to pass
* `xAxis: "outer" | "cell"` into `buildFacetGrid`).
*/
computeEffectiveFacetFlags(): {
independentZoom: boolean;
effectiveSharedX: boolean;
effectiveSharedY: boolean;
} {
const independentZoom = this._facetConfig.zoom_mode === "independent";
const effectiveSharedX =
!independentZoom && this._facetConfig.shared_x_axis;
const effectiveSharedY =
!independentZoom && this._facetConfig.shared_y_axis;
this._lastEffectiveSharedX = effectiveSharedX;
this._lastEffectiveSharedY = effectiveSharedY;
return { independentZoom, effectiveSharedX, effectiveSharedY };
}
/**
* Wire every active zoom controller's layout pointer for the
* supplied facet cells. In shared-zoom mode every
* `getZoomControllerForFacet(i)` returns the same `_zoomController`,
* so iterating past the first cell would just re-write the same
* pointer — `break`-on-shared keeps the cost O(1) and avoids the
* subtle bug where every facet's `updateLayout` overwrites the
* previous one with the last cell's layout.
*/
syncFacetZoomLayouts(
cells: ReadonlyArray<{
layout: import("../layout/plot-layout").PlotLayout;
}>,
): void {
const independent = this._facetConfig.zoom_mode === "independent";
for (let i = 0; i < cells.length; i++) {
this.getZoomControllerForFacet(i)?.updateLayout(cells[i].layout);
if (!independent) {
return;
}
}
}
/**
* Set base domain on every zoom controller owned by this chart.
*/
setZoomBaseDomain(
xMin: number,
xMax: number,
yMin: number,
yMax: number,
): void {
if (this._zoomController) {
this._zoomController.setBaseDomain(xMin, xMax, yMin, yMax);
}
for (const zc of this._facetZoomControllers) {
if (zc) {
zc.setBaseDomain(xMin, xMax, yMin, yMax);
}
}
}
/**
* Zoom-controller config for this chart type. Subclasses override to
* pin an axis (e.g. bar charts pin the categorical axis). Default:
* both axes freely zoomable.
*/
protected getZoomConfig(): ZoomConfig {
return {};
}
setColumnSlots(slots: (string | null)[]): void {
this._columnSlots = slots;
}
setViewPivots(groupBy: string[], splitBy: string[]): void {
this._groupBy = groupBy;
this._splitBy = splitBy;
}
setColumnTypes(schema: Record<string, string>): void {
this._columnTypes = schema;
this._rebuildColumnFormatters();
}
/**
* Clear any `domain_mode: "expand"` accumulator state. Driven by
* `plugin.draw()` (a fresh `draw` always indicates a view-level
* change — viewer config, filters, sorts, etc. — that invalidates
* the previously-accumulated extent) and by the worker's
* `resetAllZooms` path (user clicked "Reset Zoom"). `plugin.update()`
* deliberately does *not* call this — same view, more data, the
* accumulator should keep growing. No-op on the base; chart
* families that hold accumulator fields override.
*/
resetExpandedDomain(): void {}
setGroupByTypes(schema: Record<string, string>): void {
this._groupByTypes = schema;
}
setColumnsConfig(cfg: Record<string, any>): void {
this._columnsConfig = cfg ?? {};
this._rebuildColumnFormatters();
}
/**
* Rebuild {@link _columnFormatters} from `_columnsConfig` +
* `_columnTypes`. Called from both `setColumnsConfig` and
* `setColumnTypes` since either side of the (config, types) pair
* can arrive first depending on the host's restore order. Idempotent.
*/
private _rebuildColumnFormatters(): void {
this._columnFormatters = new Map();
for (const [name, columnCfg] of Object.entries(this._columnsConfig)) {
// `_columnTypes` is the post-aggregation `view.schema()` map
// and doesn't key group_by source columns; fall back to
// `_groupByTypes` so a configured `date_format` on a
// group_by column (e.g. an "Order Date" pivot) still
// compiles to an `Intl.DateTimeFormat` rather than being
// silently dropped.
const type = this._columnTypes[name] ?? this._groupByTypes[name];
const fmt = this._compileColumnFormatter(type, columnCfg);
if (fmt) {
this._columnFormatters.set(name, fmt);
}
}
}
private _compileColumnFormatter(
type: string | undefined,
cfg: Record<string, any> | undefined,
): ((v: number) => string) | undefined {
if (!type || !cfg) {
return undefined;
}
if (type === "integer" || type === "float") {
const numberFormat = cfg.number_format as
| NumberFormatConfig
| undefined;
if (!numberFormat) {
return undefined;
}
const intl = createNumberFormatter(type, numberFormat);
return (v) => intl.format(v);
}
if (type === "datetime") {
const dateFormat = cfg.date_format as DateFormatConfig | undefined;
if (!dateFormat) {
return undefined;
}
const intl = createDatetimeFormatter(dateFormat);
return (v) => intl.format(v);
}
if (type === "date") {
const dateFormat = cfg.date_format as DateFormatConfig | undefined;
if (!dateFormat) {
return undefined;
}
const intl = createDateFormatter(dateFormat);
return (v) => intl.format(v);
}
return undefined;
}
/**
* Returns the formatter for `columnName` if one has been configured
* (via `column_config_schema` + the user's sidebar choices), else a
* type-appropriate fallback for the chart context.
*
* @param columnName May be a synthetic split-by path
* (`<split_val>|...|<source_col>`); the source column is recovered
* internally before lookup.
* @param context `"tick"` returns `undefined` when no per-column
* formatter is configured, so the receiving axis renderer can
* apply its own step-aware default (adaptive date precision from
* tick spacing, K/M/B suffixes for numerics). `"value"` returns
* a precise `Intl.NumberFormat` / `Intl.DateTimeFormat` fallback —
* appropriate for tooltips, legends, overlays where the caller
* invokes the formatter directly and needs a guaranteed function.
*/
getColumnFormatter(
columnName: string | null | undefined,
context: "tick",
): ((v: number) => string) | undefined;
getColumnFormatter(
columnName: string | null | undefined,
context?: "value",
): (v: number) => string;
getColumnFormatter(
columnName: string | null | undefined,
context: "tick" | "value" = "value",
): ((v: number) => string) | undefined {
if (columnName) {
const formatter = this._columnFormatters.get(
sourceColumn(columnName),
);
if (formatter) {
return formatter;
}
}
if (context === "tick") {
return undefined;
}
// `_columnTypes` is the post-aggregation schema and doesn't
// key group_by source columns (their post-aggregate form is
// `__ROW_PATH_N__`); fall back to `_groupByTypes` so date /
// datetime group_by axes don't get formatted as numbers.
const sourceName = columnName ? sourceColumn(columnName) : undefined;
const type = sourceName
? (this._columnTypes[sourceName] ?? this._groupByTypes[sourceName])
: undefined;
if (type === "date" || type === "datetime") {
return DEFAULT_DATETIME_FORMATTER;
}
return DEFAULT_VALUE_FORMATTER;
}
setDefaultChartType(chartType: string): void {
this._defaultChartType = chartType;
}
setFacetConfig(cfg: FacetConfig): void {
this._facetConfig = { ...cfg };
}
/**
* Apply plugin-scoped global config. Stores `cfg` for later reads
* and mirrors the overlapping fields onto adjacent state so deep
* render code keeps reading the single struct it already does:
*
* - `facet_mode` / `facet_zoom_mode` sync into `_facetConfig` so
* `cartesian-render.ts` (and the treemap/sunburst grid checks)
* keep working unchanged.
* - `series_zoom_mode` toggles the `_autoFitValue` flag declared
* on `CategoricalYChart` ("dynamic" = refit on zoom, "fixed" =
* pinned to full extent). Harmless write on charts that don't
* expose the field.
*
* Render-path uniform fields (`line_width_px`, `point_size_px`,
* `wick_width_px`, `ohlc_line_width_px`) are read directly from
* `_pluginConfig` by their respective glyphs on each draw — no
* sync needed. Build-time fields (`auto_alt_y_axis`,
* `band_inner_frac`, `bar_inner_pad`) are read by the pipeline
* inputs in `uploadAndRender`; they take effect on next data load.
*/
setPluginConfig(cfg: PluginConfig): void {
this._pluginConfig = { ...cfg };
this._facetConfig = {
...this._facetConfig,
facet_mode: cfg.facet_mode,
zoom_mode: cfg.facet_zoom_mode,
};
(this as { _autoFitValue?: boolean })._autoFitValue =
cfg.series_zoom_mode === "dynamic";
}
/**
* Lazily decode the host-supplied theme vars. Subsequent calls hit
* the cache until `invalidateTheme()` clears it. Render-path
* callers should always read theme values through this method so
* the parsed `Theme` (gradient stops, palette, etc.) amortizes
* across an entire frame.
*/
_resolveTheme(): Theme {
if (!this._theme) {
this._theme = resolveThemeFromVars(this._themeVars);
}
return this._theme;
}
/**
* Drop the cached theme so the next `_resolveTheme()` call re-decodes
* from `_themeVars`. Wired to `plugin.restyle()` — the host pushes
* a fresh var snapshot before invalidating.
*/
invalidateTheme(): void {
this._theme = null;
}
/**
* Install a new view for lazy row fetches. Disposes any prior
* fetcher and dismisses the pinned tooltip — the prior pinned
* row index has no guaranteed correspondence in the new view
* (pivot / filter / sort changes can all reshuffle rows).
*/
setView(view: View): void {
if (this._lazyRows) {
this._lazyRows.dispose();
}
this._lazyRows = new LazyRowFetcher(view);
// A view change (filter / pivot / sort / schema) implicitly
// dismisses any active pin — the prior row index has no
// guaranteed correspondence in the new view. Emit a matching
// `selected: false` so downstream filter-coordinated consumers
// can roll back their derived state.
const wasPinned = this._tooltip.isPinned;
this._tooltip.dismiss();
if (wasPinned) {
this.emitUnselect();
}
}
/**
* Build the chart-specific {@link TooltipCallbacks} object — the
* `onHover` / `onLeave` / `onClickPre` / `onPin` / `onDblClick`
* surface that mediates between the cursor and chart state.
* Subclasses override this; the base returns a no-op pair.
*/
protected tooltipCallbacks(): TooltipCallbacks {
return {
onHover: () => {},
onLeave: () => {},
};
}
/**
* Wire the chart's `TooltipController` for virtual-dispatch
* `InteractionEvent`s forwarded from the host, and install the
* host sink that materializes pinned tooltips and cursor changes
* host-side.
*/
attachTooltip(host: HostSink): void {
this._tooltip.attach(this.tooltipCallbacks());
this._tooltip.setHost(host);
this._hostSink = host;
}
/**
* Build a `PerspectiveClickDetail` payload from a per-family
* resolved click target. Fetches the source-view row via
* `_lazyRows` (returns `row: {}` if the row can't be resolved —
* e.g., aggregate / density cells), and concatenates the
* `group_by` and `split_by` pivot values into a
* `viewer.restore({ filter })`-shaped patch.
*
* Mirrors the filter-building logic in datagrid's
* `getCellConfig` ([packages/viewer-datagrid/src/ts/get_cell_config.ts]),
* but operates on `AbstractChart` state rather than a `DatagridModel`.
*/
async buildClickDetail(target: {
rowIdx: number | null;
columnName: string;
groupByValues: (string | number | null)[];
splitByValues: (string | number | null)[];
}): Promise<PerspectiveClickDetail> {
let row: Record<string, unknown> = {};
if (target.rowIdx != null && target.rowIdx >= 0 && this._lazyRows) {
try {
const r = await this._lazyRows.fetchRow(target.rowIdx);
row = Object.fromEntries(r);
} catch {
// Fetcher may have been disposed mid-flight; treat as
// "no row" and emit the filter-only detail anyway.
row = {};
}
}
const filter: Array<[string, "==", string | number]> = [];
for (let i = 0; i < this._groupBy.length; i++) {
const v = target.groupByValues[i];
if (v != null && v !== "") {
filter.push([this._groupBy[i], "==", v]);
}
}
for (let i = 0; i < this._splitBy.length; i++) {
const v = target.splitByValues[i];
if (v != null && v !== "") {
filter.push([this._splitBy[i], "==", v]);
}
}
return {
row,
column_names: [target.columnName],
config: { filter } as Partial<ViewConfig>,
};
}
/**
* Forward a `perspective-click` to the host. No-op when the chart
* has not been wired to a host sink (e.g., unit-tested charts).
* Synchronous; callers needing ordering with async emits should
* chain through `_emitQueue`.
*/
emitUserClick(detail: PerspectiveClickDetail): void {
const payload: UserClickPayload = {
row: detail.row,
column_names: detail.column_names,
config: detail.config as { filter?: unknown[] },
};
this._hostSink?.emitUserClick?.(payload);
}
/**
* Forward a `perspective-global-filter` to the host. The host
* transport materializes a `PerspectiveSelectDetail` from this plus
* its cached previous-insert config and dispatches. Synchronous.
*/
emitUserSelect(args: {
selected: boolean;
row: Record<string, unknown>;
column_names: string[];
insertConfig: Partial<ViewConfig>;
}): void {
const payload: UserSelectPayload = {
selected: args.selected,
row: args.row,
column_names: args.column_names,
insertConfig: args.insertConfig as { filter?: unknown[] },
};
this._hostSink?.emitUserSelect?.(payload);
}
/**
* Convenience: fire both `perspective-click` and
* `perspective-global-filter` (`selected: true`) from a resolved
* click target. Used by chart families where every click both
* "selects" and "filters" (series, heatmap, candlestick, scatter,
* treemap-leaf, etc.). Treemap branch / breadcrumb gestures use
* the lower-level helpers directly.
*
* Chains through `_emitQueue` so the row-fetch await can't reorder
* this emit behind a follow-up `emitUnselect`.
*/
emitClickAndSelect(target: {
rowIdx: number | null;
columnName: string;
groupByValues: (string | number | null)[];
splitByValues: (string | number | null)[];
}): Promise<void> {
const next = this._emitQueue.then(async () => {
const detail = await this.buildClickDetail(target);
this.emitUserClick(detail);
this.emitUserSelect({
selected: true,
row: detail.row,
column_names: detail.column_names,
insertConfig: detail.config,
});
});
// Swallow errors on the chain so a single failure doesn't
// poison subsequent emits; surface to console for debugging.
this._emitQueue = next.catch((e) => {
console.error("emitClickAndSelect failed", e);
});
return next;
}
/**
* Fire a `perspective-global-filter` with `selected: false`. Used
* by treemap / sunburst breadcrumb navigation and by chart-base's
* own `setView` when a view change implicitly dismisses any active
* pin. Chains through `_emitQueue` so it lands AFTER any in-flight
* `emitClickAndSelect`.
*/
emitUnselect(
args: {
row?: Record<string, unknown>;
column_names?: string[];
} = {},
): void {
const next = this._emitQueue.then(() => {
this.emitUserSelect({
selected: false,
row: args.row ?? {},
column_names: args.column_names ?? [],
insertConfig: { filter: [] },
});
});
this._emitQueue = next.catch((e) => {
console.error("emitUnselect failed", e);
});
}
// Render entrypoint
/**
* Public coalesced render. Routes through the module-level
* scheduler so concurrent calls collapse to one `_fullRender` per
* RAF and the host blitter receives one bitmap per frame. The
* returned promise resolves after this chart's `awaitGpuFence` +
* `endFrame` chain — independent of other charts in the same
* RAF, which run their fence waits in parallel.
*
* Every render-triggering caller — upload chunks, zoom / pan,
* resize, theme invalidation, host-driven redraws — calls this.
* The only sanctioned bypass is `snapshotPng`, which calls
* `_fullRender` directly to keep the GL backbuffer intact for
* `gl.readPixels`.
*/
requestRender(glManager: WebGLContextManager): Promise<void> {
return scheduleRender(glManager, () => this._fullRender(glManager));
}
// Lifecycle
destroy(): void {
this._tooltip.detach();
this._tooltip.dismiss();
if (this._lazyRows) {
this._lazyRows.dispose();
this._lazyRows = null;
}
this.destroyInternal();
}
// Abstract surface
abstract uploadAndRender(
glManager: WebGLContextManager,
columns: ColumnDataMap,
startRow: number,
endRow: number,
): Promise<void>;
abstract _fullRender(glManager: WebGLContextManager): void;
/**
* Release chart-specific GL/CPU resources. `destroy` calls this.
*/
protected abstract destroyInternal(): void;
}
@@ -0,0 +1,427 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { ColumnDataMap } from "../data/view-reader";
import type { WebGLContextManager } from "../webgl/context-manager";
import type { ZoomController } from "../interaction/zoom-controller";
import type { HostSink } from "../interaction/tooltip-controller";
export interface ChartImplementation {
uploadAndRender(
glManager: WebGLContextManager,
columns: ColumnDataMap,
startRow: number,
endRow: number,
): void;
/**
* The single render entrypoint. Every render-triggering caller —
* upload chunks, zoom / pan, resize, theme invalidation,
* host-driven redraws — calls this. Routes through the
* module-level scheduler ([render/scheduler.ts]) so concurrent
* calls collapse to one `_fullRender` per RAF and the host
* blitter receives one bitmap per frame per chart.
*
* The returned promise resolves after this entry's `_fullRender`
* + `awaitGpuFence` + `endFrame` chain completes — independent
* of other charts in the same RAF, which run their fence waits
* in parallel.
*
* The synchronous-render bypass for `snapshotPng` (calls
* `_fullRender` directly, skips `endFrame`) is the only
* sanctioned exception and lives inside the worker renderer.
*/
requestRender(glManager: WebGLContextManager): Promise<void>;
/**
* The chart-specific frame builder. The scheduler wraps this with
* fence + `endFrame`; callers must not invoke it directly except
* for `snapshotPng`, which needs an intact GL backbuffer for
* `gl.readPixels` and so must skip the `endFrame` pair.
*/
_fullRender(glManager: WebGLContextManager): void;
/**
* Hand the current View to the chart so it can make on-demand
* per-row queries (for lazy tooltip column lookups). Called on
* every `draw`; the chart disposes any prior fetcher and clears
* dependent UI (pinned tooltip) so stale rows never surface.
*
* TODO: pinned tooltips are dismissed on view update today. A
* future enhancement is to keep a pinned tooltip visible (with its
* captured data) until the user dismisses it, even after the
* underlying view no longer contains that row.
*/
setView?(view: View): void;
/**
* Set the gridline canvas (behind WebGL, for gridlines).
*/
setGridlineCanvas?(canvas: HTMLCanvasElement | OffscreenCanvas): void;
/**
* Set the chrome canvas (above WebGL, for axes/labels/legend/tooltip).
*/
setChromeCanvas?(canvas: HTMLCanvasElement | OffscreenCanvas): void;
/**
* Hand the chart a pre-computed CSS-variable map produced on the
* main thread via `snapshotThemeVars(el)`, which it can decode into
* a full `Theme` without touching the DOM (charts always run inside
* the renderer scope, which has no `getComputedStyle`).
*/
setTheme?(vars: Record<string, string>): void;
/**
* Set the zoom controller for interactive zoom/pan.
*/
setZoomController?(zc: ZoomController): void;
/**
* Wire the chart's `TooltipController` for virtual-dispatch hover /
* click events forwarded from the host. The renderer drives
* `dispatchHover` / `dispatchLeave` / `dispatchClick` /
* `dispatchDblClick` from `InteractionEvent`s; the supplied
* `HostSink` posts pin / dismiss / setCursor intents back to the
* host so the resulting DOM mutations happen there (the renderer
* scope has no DOM in worker mode, and uses the same channel
* in-process for symmetry).
*/
attachTooltip?(host: HostSink): 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<string, string>): void;
/**
* Set the source-column types used for `group_by` level lookups —
* sourced from `table.schema()` + `view.expression_schema()`. Used
* by categorical-axis charts to detect numeric / date / boolean
* group_by levels (which are not keyed in `view.schema()` because
* they surface as `__ROW_PATH_N__` columns).
*/
setGroupByTypes?(schema: Record<string, string>): void;
/**
* Set per-column render config (the second argument to `plugin.restore`).
* Key is the aggregate base name; value is an open object whose
* `chart_type` / `stack` fields are consumed by the Y-bar glyph router.
*/
setColumnsConfig?(cfg: Record<string, any>): void;
/**
* Set the plugin's default glyph type. Used by the Y-series chart
* family (Y Bar / Y Line / Y Scatter / Y Area): each tag is the same
* `BarChart` impl with a different starting `chart_type` applied to
* columns that lack an explicit entry in `columns_config`.
*/
setDefaultChartType?(chartType: string): void;
/**
* Set the faceting config: one small-multiple sub-plot per
* `split_by` group, optional shared axes, coordinated tooltip, and
* zoom routing mode. Seeded from `DEFAULT_FACET_CONFIG` at init;
* `plugin_config.facet_mode` / `facet_zoom_mode` override the
* matching fields via `AbstractChart.setPluginConfig`.
*/
setFacetConfig?(cfg: FacetConfig): void;
/**
* Set the plugin-scoped global configuration — the values backing
* `plugin_config_schema` / `plugin_config` in `restore`. Replaces
* the previous module-level constants (`LINE_WIDTH_PX`,
* `POINT_SIZE_PX`, `BAND_INNER_FRAC`, `BAR_INNER_PAD`,
* `WICK_WIDTH_PX`, `OHLC_LINE_WIDTH_PX`, `AUTO_ALT_Y_AXIS`) plus
* the faceted/series zoom-mode semantics described in
* {@link PluginConfig}.
*/
setPluginConfig?(cfg: PluginConfig): void;
/**
* Drop any cached theme values so the next render re-reads CSS
* variables. Driven from `plugin.restyle()`.
*/
invalidateTheme?(): void;
/**
* Clear the `domain_mode: "expand"` accumulator state so the next
* data load starts from the current data extent. Driven from
* `resetAllZooms` (the user clicked "Reset Zoom"). View-config
* mutations route through `AbstractChart`'s `setColumnSlots` /
* `setViewPivots` / `setColumnTypes` setters, which call the same
* hook internally.
*/
resetExpandedDomain?(): void;
destroy(): void;
}
export interface FacetConfig {
/**
* "grid" = small multiples (default); "overlay" = legacy single-plot.
*/
facet_mode: "grid" | "overlay";
/**
* Share one bottom X axis across all columns of facets.
*/
shared_x_axis: boolean;
/**
* Share one left Y axis across all rows of facets.
*/
shared_y_axis: boolean;
/**
* Paint a tooltip in every facet (otherwise only the source facet).
*/
coordinated_tooltip: boolean;
/**
* "shared" = one viewport for all facets; "independent" = per-facet.
*/
zoom_mode: "shared" | "independent";
/**
* Pixel gap between adjacent facet cells in grid mode.
*/
facet_padding: number;
}
export const DEFAULT_FACET_CONFIG: FacetConfig = {
facet_mode: "grid",
shared_x_axis: true,
shared_y_axis: true,
coordinated_tooltip: false,
zoom_mode: "shared",
facet_padding: 6,
};
/**
* Plugin-scoped global configuration — the user-facing settings backing
* `plugin_config_schema()` / the `plugin_config` slot in `restore`.
*
* Each chart type's `plugin_config_schema` returns only the fields that
* are applicable for that chart (see `applicable_plugin_fields` on
* `ChartTypeConfig`); inapplicable fields are hidden in the UI. The
* chart impl receives the full struct on `setPluginConfig` and reads
* only the fields its render / build pipeline cares about.
*
* Some fields overlap with {@link FacetConfig} (`facet_mode`,
* `facet_zoom_mode`); the base `AbstractChart.setPluginConfig` syncs
* those onto `_facetConfig` so deep render code keeps reading the
* single facet struct it already does. `series_zoom_mode` toggles the
* categorical-Y chart base's `_autoFitValue` flag.
*/
export interface PluginConfig {
/**
* Auto-detect Y dual-axis splits when aggregate magnitudes differ
* by more than `DUAL_Y_RATIO_THRESHOLD`×. Series charts only.
* Replaces the `AUTO_ALT_Y_AXIS` compile-time toggle.
*/
auto_alt_y_axis: boolean;
/**
* Faceting strategy when `split_by` is non-empty.
*
* - `"grid"` — one small-multiple sub-plot per split group.
* - `"overlay"` — single plot with split groups differentiated by
* color. Synced into `_facetConfig.facet_mode`.
*/
facet_mode: "grid" | "overlay";
/**
* Faceted-cartesian zoom routing. `"shared"` — one viewport across
* all facets; `"independent"` — wheel/pan routes to the facet under
* the cursor with its own viewport. Synced into
* `_facetConfig.zoom_mode`.
*/
facet_zoom_mode: "shared" | "independent";
/**
* Series-chart value-axis behavior on zoom.
*
* - `"dynamic"` — value axis refits to the visible categorical
* slice (current default; `CategoricalYChart._autoFitValue` =
* true).
* - `"fixed"` — value axis stays pinned to the full-data extent.
*/
series_zoom_mode: "fixed" | "dynamic";
/**
* Anchor the value axis to zero — when true, `0` is forced into
* the rendered domain even if all data sits well above or below
* it. Natural for bar / area glyphs (which grow from the zero
* baseline) and surprising for line / scatter (where the
* interesting variation often lives far from zero). Per-chart-type
* defaults route through `ChartTypeConfig.plugin_field_defaults`:
* `true` for Y Bar / Y Area / X Bar, `false` elsewhere.
*/
include_zero: boolean;
/**
* Domain accumulation policy across successive `View` updates.
*
* - `"fit"` — every update recomputes the rendered domain (and on
* cartesian charts, the X/Y range and color/size scales) from
* the current data extent. Can grow or shrink frame-to-frame.
* - `"expand"` — the rendered domain monotonically *grows*: each
* update unions the new data extent with the previously rendered
* extent, so once a value is in scope it stays in scope. Reset
* by the "Reset Zoom" button, view-config changes (group_by /
* split_by / column-slot / column-type), or toggling back to
* `"fit"`.
*/
domain_mode: "fit" | "expand";
/**
* Width of polyline glyphs in CSS pixels (multiplied by DPR at GL
* upload). Replaces the duplicated `LINE_WIDTH_PX` constants in
* the cartesian + series line glyphs.
*/
line_width_px: number;
/**
* Diameter of scatter point glyphs in CSS pixels. Replaces
* `POINT_SIZE_PX`.
*/
point_size_px: number;
/**
* Fraction of each category band occupied by its slot(s). Replaces
* `BAND_INNER_FRAC`. Affects buffer contents — takes effect on
* next data load.
*/
band_inner_frac: number;
/**
* Relative inner padding between adjacent slots within a band.
* Replaces `BAR_INNER_PAD`. Affects buffer contents — takes effect
* on next data load.
*/
bar_inner_pad: number;
/**
* Candlestick wick stroke width in CSS pixels. Replaces
* `WICK_WIDTH_PX`.
*/
wick_width_px: number;
/**
* OHLC bar stroke width in CSS pixels. Replaces
* `OHLC_LINE_WIDTH_PX`.
*/
ohlc_line_width_px: number;
/**
* density splat radius in CSS pixels. Each data point is
* rasterized as a soft disk of this radius into the accumulation
* FBO before the gradient LUT pass resolves to a heat color.
*/
gradient_radius_px: number;
/**
* density per-splat intensity multiplier. Controls how
* fast the density grows when points overlap (low values produce
* a smoother, more diffuse field; high values produce sharper
* peaks).
*/
gradient_intensity: number;
/**
* density clamp on the maximum accumulated heat used for
* the gradient-LUT lookup. Lower values saturate sooner (more of
* the LUT's hot stops show up); higher values stay cooler. In
* every mode this controls the alpha (intensity) ramp; in
* `density` mode it also drives the hue, and in `signed` mode it
* scales the signed-sum-to-hue mapping.
*/
gradient_heat_max: number;
/**
* density color-reduction mode. Controls how each pixel's
* stack of overlapping splats is reduced to a single LUT-t / alpha
* pair in the resolve pass.
*
* - `mean` (default) — hue is the density-weighted average of
* per-point color-t. Reads as "the typical color-column value
* in this region." Uses robust (5th/95th-percentile) bounds so
* one outlier can't compress the rest of the data toward the
* gradient midpoint.
* - `density` — ignore the color column even when wired; hue and
* alpha both follow density. Reads as "where do points cluster."
* Useful when the color column is attached for tooltip lookup
* only.
* - `extreme` — keep the per-pixel maximum signed deviation of
* `t - 0.5` (split into positive and negative channels, MAX-
* blended). Reads as "where are the outliers." Density still
* drives alpha so a single-point extreme fades naturally.
* Requires a second framebuffer; uses MRT on WebGL2 hardware
* with `OES_draw_buffers_indexed`, two passes otherwise.
* - `signed` — accumulate signed `t - 0.5` and let positive vs
* negative cancel out. Reads as "net positive vs net negative
* in each region." Requires a float-capable framebuffer; on
* `UNSIGNED_BYTE` fallback hardware degrades silently to
* `mean` with a one-line console warning.
*/
gradient_color_mode: "mean" | "density" | "extreme" | "signed";
/**
* Map basemap tile provider. Applies only to map plugin tags
* (`map-scatter`, `map-line`, `map-density`). Cartesian charts
* ignore the field. Surfaced as an enum on the settings panel so
* users can switch light/dark/voyager without writing custom
* tile-source code.
*/
map_tile_provider: "carto-positron" | "carto-dark-matter" | "carto-voyager";
/**
* Map basemap alpha (0..1). Pre-multiplied into the tile fragment
* shader's output so the chart's glyph layer composites over a
* dimmer or brighter version of the basemap. `1.0` (default)
* shows the tiles at full opacity.
*/
map_tile_alpha: number;
}
export const DEFAULT_PLUGIN_CONFIG: PluginConfig = {
auto_alt_y_axis: false,
facet_mode: "grid",
facet_zoom_mode: "shared",
series_zoom_mode: "dynamic",
include_zero: false,
domain_mode: "expand",
line_width_px: 2.0,
point_size_px: 8.0,
band_inner_frac: 0.5,
bar_inner_pad: 0.1,
wick_width_px: 1.0,
ohlc_line_width_px: 1.0,
gradient_radius_px: 32.0,
gradient_intensity: 0.6,
gradient_heat_max: 4.0,
gradient_color_mode: "mean",
map_tile_provider: "carto-positron",
map_tile_alpha: 1.0,
};
@@ -0,0 +1,63 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
/**
* Shared per-category band geometry used by categorical-X charts (bar,
* candlestick, ohlc). The category axis uses unit-wide bands centered
* on integer indices; within each band, `numSlots` rectangles (bars,
* candles, …) are laid out side by side with a small inner padding.
*/
export interface SlotGeometry {
/**
* Width (in data-space units) of a single slot.
*/
slotWidth: number;
/**
* Half the drawable width of each slot after inner padding.
*/
halfWidth: number;
}
/**
* Compute slot geometry for `numSlots` rectangles per category band.
*
* `bandInnerFrac` is the fraction of each category's band width
* actually covered by slots; `barInnerPad` is the relative padding
* between adjacent slots within a band. Both come from
* {@link PluginConfig.band_inner_frac} / `bar_inner_pad`. Defaults
* match the previous hard-coded constants (0.5 / 0.1).
*/
export function computeSlotGeometry(
numSlots: number,
bandInnerFrac: number = 0.5,
barInnerPad: number = 0.1,
): SlotGeometry {
const slotWidth = bandInnerFrac / Math.max(1, numSlots);
const halfWidth = (slotWidth * (1 - barInnerPad)) / 2;
return { slotWidth, halfWidth };
}
/**
* X-center for slot `slotIdx` of `numSlots` in the band centered at
* `catIdx`. Matches bar's layout: slot 0 on the far left, numSlots-1 on
* the far right, symmetric about `catIdx`.
*/
export function slotCenter(
catIdx: number,
slotIdx: number,
numSlots: number,
slotWidth: number,
): number {
return catIdx + (slotIdx - (numSlots - 1) / 2) * slotWidth;
}
@@ -0,0 +1,81 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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";
import type { AxisDomain } from "../../axis/numeric-axis";
import type {
CategoricalDomain,
CategoricalLevel,
} from "../../axis/categorical-axis";
import type { ZoomConfig } from "../../interaction/zoom-controller";
import { AbstractChart } from "../chart-base";
/**
* Common base for charts with a categorical X axis, a numeric Y (value)
* axis, and an optional zoom-on-categorical interaction. Today that's
* Bar (all four Y-family plugins + X Bar) and Candlestick/OHLC.
*
* The class is deliberately thin: it consolidates the bookkeeping that
* genuinely repeats across those chart families (categorical domain
* state, last-frame cache for overlay-only redraws, value-axis lock
* default, shared GL-program + corner-buffer fields) and nothing more.
* Glyph rendering, hit-testing, build pipelines, and horizontal / dual-
* axis variance live in the concrete subclasses because they diverge
* too much to usefully share.
*/
export abstract class CategoricalYChart extends AbstractChart {
// Categorical X axis state
/**
* Row-path levels (group_by hierarchy) for X-axis tick rendering.
*/
_rowPaths: CategoricalLevel[] = [];
/**
* Number of categories on the X axis.
*/
_numCategories = 0;
/**
* Offset into the aggregated-row stream (total-rows are skipped).
*/
_rowOffset = 0;
// Shared GL resources
_program: WebGLProgram | null = null;
_cornerBuffer: WebGLBuffer | null = null;
// Last-frame cache (for chrome-only redraws)
_lastLayout: PlotLayout | null = null;
_lastXDomain: CategoricalDomain | null = null;
_lastYDomain: AxisDomain | null = null;
_lastYTicks: number[] | null = null;
// Auto-fit value axis (opt-in per chart)
/**
* When true, the value axis refits to the visible categorical
* window each frame — so zooming the categorical axis tightens the
* value axis to just the bars / candles in view. Subclasses own
* the per-frame cache object because the cache key shape varies
* (bar needs `hiddenSeries`, candlestick doesn't; bar may have
* dual-axis extents, candlestick is single-axis).
*/
_autoFitValue = true;
/**
* Lock the value axis by default — user wheel/pan should only
* scroll the categorical axis. Subclasses override to flip
* orientation (e.g. X Bar where the value axis is on X).
*/
protected override getZoomConfig(): ZoomConfig {
return { lockAxis: "y" };
}
}
@@ -0,0 +1,448 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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/view-reader";
import type {
CategoricalDomain,
CategoricalLevel,
} from "../../axis/categorical-axis";
import { buildGroupRuns } from "../../axis/categorical-axis-core";
import { formatTickValue, formatDateTickValue } from "../../layout/ticks";
export interface CategoryAxisResult {
/**
* Fully materialized hierarchical levels — labels and group runs are
* pre-resolved from the view's `__ROW_PATH_N__` dictionaries (or
* synthesized for non-string levels) so the chart can retain them
* past the `with_typed_arrays` callback scope. Empty when `groupBy`
* is empty.
*/
rowPaths: CategoricalLevel[];
/**
* Rows that actually contribute a category (post-offset).
*/
numCategories: number;
/**
* Leading rows skipped; callers use this to rebase per-row indices.
*/
rowOffset: number;
}
export type AxisMode =
| { mode: "category" }
| {
mode: "numeric";
numericType: "date" | "datetime" | "integer" | "float";
};
/**
* Numeric category-axis state. Shared across bar / candlestick / heatmap
* pipelines: when an axis is driven by exactly one non-string group_by /
* split_by level, glyphs anchor at real data values via `categoryPositions`
* and the chrome renders a numeric (date-aware) tick row.
*/
export interface NumericCategoryDomain {
min: number;
max: number;
isDate: boolean;
label: string;
/**
* Data-unit width of one category band, from min adjacent delta.
*/
bandWidth: number;
}
/**
* Compute `categoryPositions` (per-row real data values) plus a
* `NumericCategoryDomain` summarizing min/max/bandWidth for a numeric
* row-path column. `bandWidth` falls back to the full domain when there
* are <2 distinct positions. Pivot rows for a single group_by come ASC
* by default, so a forward sweep for `minDelta` is sufficient.
*
* Returns `null` when the row-path column is missing or carries no
* `values` array (e.g. dictionary-encoded string column).
*/
export function resolveNumericCategoryDomain(
rpValues: ArrayLike<number> | null | undefined,
numCategories: number,
rowOffset: number,
label: string,
isDate: boolean,
): {
categoryPositions: Float64Array;
numericCategoryDomain: NumericCategoryDomain;
} | null {
if (!rpValues || numCategories <= 0) {
return null;
}
const categoryPositions = new Float64Array(numCategories);
let minVal = Infinity;
let maxVal = -Infinity;
for (let catI = 0; catI < numCategories; catI++) {
const v = rpValues[catI + rowOffset] as number;
categoryPositions[catI] = v;
if (v < minVal) {
minVal = v;
}
if (v > maxVal) {
maxVal = v;
}
}
let minDelta = Infinity;
for (let i = 1; i < numCategories; i++) {
const d = Math.abs(categoryPositions[i] - categoryPositions[i - 1]);
if (d > 0 && d < minDelta) {
minDelta = d;
}
}
if (!isFinite(minDelta) || minDelta === 0) {
minDelta = Math.max(1, maxVal - minVal);
}
return {
categoryPositions,
numericCategoryDomain: {
min: minVal - minDelta / 2,
max: maxVal + minDelta / 2,
isDate,
label,
bandWidth: minDelta,
},
};
}
/**
* Decide whether the categorical axis should render as a stringified
* category axis or a true numeric axis. Numeric mode is only used when
* there is exactly one `group_by` level AND that level is a non-string,
* non-boolean numeric type. Boolean and any multi-level case → category.
*/
export function resolveAxisMode(
groupBy: string[],
groupByTypes: Record<string, string>,
): AxisMode {
if (groupBy.length !== 1) {
return { mode: "category" };
}
const t = groupByTypes[groupBy[0]];
if (t === "date" || t === "datetime" || t === "integer" || t === "float") {
return { mode: "numeric", numericType: t };
}
return { mode: "category" };
}
/**
* Stringify a single value from a non-string row-path column.
*/
function formatLevelValue(
value: number,
valid: boolean,
levelType: string,
): string {
if (!valid) {
return "";
}
if (levelType === "boolean") {
return value ? "true" : "false";
}
if (levelType === "date" || levelType === "datetime") {
return formatDateTickValue(value);
}
if (levelType === "integer") {
return String(value | 0);
}
if (levelType === "float") {
return formatTickValue(value);
}
return String(value);
}
/**
* Synthesize a `(indices, dictionary)` pair from a non-string row-path
* column so the rest of the categorical axis machinery (label
* pre-resolution, run-length encoding) can run unchanged. The dictionary
* uses `""` at index 0 as the rollup-row sentinel — this preserves the
* existing skip-rollup loop's `s !== ""` check.
*/
export function synthesizeStringLevel(
rp: ColumnData,
numRows: number,
levelType: string,
): { indices: Int32Array; dictionary: string[] } {
const values = rp.values!;
const valid = rp.valid;
const indices = new Int32Array(numRows);
const dictionary: string[] = [""];
const seen = new Map<string, number>();
seen.set("", 0);
for (let r = 0; r < numRows; r++) {
const isValid = valid ? !!((valid[r >> 3] >> (r & 7)) & 1) : true;
const v = values[r] as number;
const label = formatLevelValue(v, isValid, levelType);
let dictIdx = seen.get(label);
if (dictIdx === undefined) {
dictIdx = dictionary.length;
dictionary.push(label);
seen.set(label, dictIdx);
}
indices[r] = dictIdx;
}
return { indices, dictionary };
}
/**
* Resolve the category axis for a categorical-X chart (bar, candlestick,
* ohlc, …). Walks the `__ROW_PATH_N__` hierarchy columns, skips the
* rollup rows at the top ("Total" parent aggregates), and returns fully
* JS-owned level structures (precomputed labels + runs) plus the
* trimmed category count.
*
* Non-string row-path columns (date / datetime / integer / float /
* boolean group_by levels) are stringified into a synthetic dictionary
* so the downstream label / run-length machinery is type-agnostic.
*
* When `groupByLen === 0`, there are no row-path columns and the
* category axis falls back to the raw row index — callers infer that
* from `rowPaths.length === 0`.
*/
export function resolveCategoryAxis(
columns: ColumnDataMap,
numRows: number,
groupByLen: number,
levelTypes: string[] = [],
): CategoryAxisResult {
type RawLevel = { indices: Int32Array; dictionary: string[] };
const rawRowPaths: RawLevel[] = [];
for (let n = 0; ; n++) {
const rp = columns.get(`__ROW_PATH_${n}__`);
if (!rp) {
break;
}
if (rp.type === "string" && rp.indices && rp.dictionary) {
rawRowPaths.push({
indices: rp.indices,
dictionary: rp.dictionary,
});
} else if (rp.values) {
const levelType = levelTypes[n] ?? "string";
rawRowPaths.push(synthesizeStringLevel(rp, numRows, levelType));
} else {
break;
}
}
let rowOffset = 0;
if (groupByLen > 0 && rawRowPaths.length > 0) {
while (rowOffset < numRows) {
let anyNonEmpty = false;
for (const rp of rawRowPaths) {
const s = rp.dictionary[rp.indices[rowOffset]];
if (s != null && s !== "") {
anyNonEmpty = true;
break;
}
}
if (anyNonEmpty) {
break;
}
rowOffset++;
}
}
const numCategories = Math.max(0, numRows - rowOffset);
const L = rawRowPaths.length;
const rowPaths: CategoricalLevel[] =
groupByLen > 0 && L > 0
? rawRowPaths.map((rp, levelIdx) => {
const labels = new Array<string>(numCategories);
let maxLabelChars = 0;
for (let r = 0; r < numCategories; r++) {
const s = rp.dictionary[rp.indices[r + rowOffset]] ?? "";
labels[r] = s;
if (s.length > maxLabelChars) {
maxLabelChars = s.length;
}
}
// Only outer levels need the run-length encoding for
// bracket rendering; leaves render per-row.
const runs =
levelIdx === L - 1
? []
: buildGroupRuns(
rp.indices,
rp.dictionary,
rowOffset,
rowOffset + numCategories,
).map((run) => ({
startIdx: run.startIdx - rowOffset,
endIdx: run.endIdx - rowOffset,
label: run.label,
}));
return { labels, runs, maxLabelChars };
})
: [];
return { rowPaths, numCategories, rowOffset };
}
export interface ValueCategoryColumn {
/**
* Source aggregate column name; used only for the axis label fallback.
*/
name: string;
/**
* Post-aggregation perspective type string from `chart._columnTypes`
* (`"string"` is what triggers categorical mode).
*/
type: string;
/**
* The actual `ColumnData` from the view. May be undefined when the
* caller couldn't resolve the column (treated as all-null).
*/
data: ColumnData | undefined;
}
export interface ValueCategoryDomain {
/**
* Single-level `CategoricalDomain` shared across all input columns.
* `levels[0].labels` is the dictionary in slot order.
*/
domain: CategoricalDomain;
/**
* Per-column slot-index buffers. Length === `numCategories`.
* Indexed in the same order as the input `columns` array.
*/
perColumnSlots: Int32Array[];
}
/**
* Build a single shared categorical domain across one or more aggregate
* columns that land on the same axis side (primary or alt). Implements
* the "all-or-nothing per axis side" rule: returns `null` (= caller stays
* numeric) when any column is non-string; otherwise returns a single-
* level domain with the dictionary built in first-seen row order plus
* per-column slot indices the build pipeline writes into its pixel/slot
* buffer.
*
* Null / invalid rows surface as a `"(null)"` slot that's lazily added
* to the dictionary on first encounter — no reserved slot 0 when the
* data has no missing values.
*/
export function resolveValueCategoryDomain(
columns: ValueCategoryColumn[],
numRows: number,
rowOffset: number,
axisLabel: string,
): ValueCategoryDomain | null {
if (columns.length === 0) {
return null;
}
for (const c of columns) {
if (c.type !== "string") {
return null;
}
}
const numCategories = Math.max(0, numRows - rowOffset);
const dictionary: string[] = [];
const seen = new Map<string, number>();
const perColumnSlots: Int32Array[] = columns.map(
() => new Int32Array(numCategories),
);
const slotFor = (s: string): number => {
let slot = seen.get(s);
if (slot === undefined) {
slot = dictionary.length;
dictionary.push(s);
seen.set(s, slot);
}
return slot;
};
for (let ci = 0; ci < columns.length; ci++) {
const col = columns[ci].data;
const slots = perColumnSlots[ci];
for (let r = 0; r < numCategories; r++) {
const rowIdx = r + rowOffset;
let label: string;
if (!col) {
label = "(null)";
} else {
const valid = col.valid;
const isValid = valid
? !!((valid[rowIdx >> 3] >> (rowIdx & 7)) & 1)
: true;
if (!isValid) {
label = "(null)";
} else if (col.indices && col.dictionary) {
label = col.dictionary[col.indices[rowIdx]] ?? "(null)";
} else if (col.values) {
const v = col.values[rowIdx];
label = v == null ? "(null)" : String(v);
} else {
label = "(null)";
}
}
slots[r] = slotFor(label);
}
}
let maxLabelChars = 0;
for (const s of dictionary) {
if (s.length > maxLabelChars) {
maxLabelChars = s.length;
}
}
const level: CategoricalLevel = {
labels: dictionary.slice(),
runs: [],
maxLabelChars,
};
const domain: CategoricalDomain = {
levels: [level],
numRows: dictionary.length,
levelLabels: [axisLabel],
};
return { domain, perColumnSlots };
}
@@ -0,0 +1,79 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { Canvas2D, Context2D } from "../canvas-types";
export interface ChromeCacheChart {
_chromeCanvas: Canvas2D | null;
_chromeCache: ImageBitmap | null;
_chromeCacheDirty: boolean;
_chromeCacheGen: number;
}
/**
* Run the static-chrome cache pattern shared by sunburst + treemap.
* Resizes the canvas, paints the static layer (and snapshots it as an
* `ImageBitmap`) when dirty, otherwise blits the cache; then runs the
* caller-provided overlay layer for hover/highlight state.
*
* Returns the prepared `ctx` already in DPR-scaled space so the overlay
* callback can paint in CSS pixels — except `null` if either the canvas
* is missing a 2D context or the chart has nothing to paint.
*/
export function withChromeCache(
chart: ChromeCacheChart,
canvas: Canvas2D,
dpr: number,
cssWidth: number,
cssHeight: number,
drawStatic: (ctx: Context2D) => void,
drawOverlay: ((ctx: Context2D) => void) | null,
): void {
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;
chart._chromeCacheDirty = true;
}
const ctx = canvas.getContext("2d") as Context2D | null;
if (!ctx) {
return;
}
if (chart._chromeCacheDirty) {
chart._chromeCache?.close();
chart._chromeCache = null;
chart._chromeCacheDirty = false;
const gen = ++chart._chromeCacheGen;
drawStatic(ctx);
createImageBitmap(canvas).then((bmp) => {
if (chart._chromeCacheGen === gen) {
chart._chromeCache?.close();
chart._chromeCache = bmp;
} else {
bmp.close();
}
});
} else if (chart._chromeCache) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(chart._chromeCache, 0, 0);
}
if (drawOverlay) {
ctx.save();
ctx.scale(dpr, dpr);
drawOverlay(ctx);
ctx.restore();
}
}
@@ -0,0 +1,84 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { Context2D } from "../canvas-types";
import type { Theme } from "../../theme/theme";
/**
* Draw a freestanding tooltip box anchored near (cx, cy), measuring
* lines, sizing/clamping the box, painting bg/border, and laying out
* text rows. Shared by sunburst + treemap which need a non-PlotLayout
* anchor.
*/
export function drawTooltipBox(
ctx: Context2D,
theme: Theme,
lines: string[],
cx: number,
cy: number,
cssWidth: number,
cssHeight: number,
fontFamily: string,
): void {
if (lines.length === 0) {
return;
}
const { tooltipBg, tooltipText, tooltipBorder } = theme;
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 = 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);
}
}
@@ -0,0 +1,40 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
/**
* Numeric extent — used by series + candlestick build pipelines for
* value / category axis domains.
*/
export interface Domain {
min: number;
max: number;
}
/**
* Union `next` (a freshly-computed extent) with `prev` (the prior
* accumulator) IN PLACE on `next`, then return a fresh copy to store
* back as the new accumulator. Idempotent when `prev` is null — `next`
* is left untouched.
*
* Used by the `domain_mode: "expand"` mirror-back step in the series /
* candlestick / cartesian build pipelines: mutating `next` in place
* means every downstream assignment that reads from the pipeline
* result struct automatically picks up the grown extent.
*/
export function expandDomainInPlace(prev: Domain | null, next: Domain): Domain {
if (prev) {
next.min = Math.min(prev.min, next.min);
next.max = Math.max(prev.max, next.max);
}
return { min: next.min, max: next.max };
}
@@ -0,0 +1,92 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { TreeChartBase } from "./tree-chart";
import type { Vec3 } from "../../theme/palette";
import {
colorValueToT,
sampleGradient,
type GradientStop,
} from "../../theme/gradient";
/**
* Perceptual luminance for a 0..1 RGB triple. Used by tree-chart label
* painters to pick a contrasting text color over each leaf's fill.
*/
export function luminance(r: number, g: number, b: number): number {
return 0.299 * r + 0.587 * g + 0.114 * b;
}
/**
* Sample a gradient and drop the alpha channel. Treemap / sunburst
* fills carry alpha separately (see {@link leafRGBA}); this is the
* "just give me the RGB" entry point.
*/
export function sampleRGB(
stops: GradientStop[],
t: number,
): [number, number, number] {
const c = sampleGradient(stops, t);
return [c[0], c[1], c[2]];
}
/**
* Resolve a leaf's fill color according to the chart's color mode:
* - `"numeric"` — sign-aware gradient sample via `colorValueToT`.
* - `"series"` / `"empty"` — discrete palette lookup keyed by the
* node's `colorLabel` (composite of group_by levels in series mode;
* `""` in empty mode, which maps to `palette[0]`).
*
* Returns RGB only; the alpha channel is applied separately by
* {@link leafRGBA} using `negativeAlpha` for leaves whose raw size was
* negative.
*/
export function leafColor(
chart: TreeChartBase,
nodeId: number,
stops: GradientStop[],
palette: Vec3[],
): [number, number, number] {
const store = chart._nodeStore;
const colorValue = store.colorValue[nodeId];
if (
chart._colorMode === "numeric" &&
!isNaN(colorValue) &&
chart._colorMax > chart._colorMin
) {
return sampleRGB(
stops,
colorValueToT(colorValue, chart._colorMin, chart._colorMax),
);
}
const idx = chart._uniqueColorLabels.get(store.colorLabel[nodeId]) ?? 0;
return palette[idx % palette.length] ?? [0, 0, 0];
}
/**
* `leafColor` + an alpha channel. Negative-size leaves receive
* `negativeAlpha` (mirrors `theme.areaOpacity` for area charts) so
* they stay visually distinguishable from positive leaves without
* disappearing.
*/
export function leafRGBA(
chart: TreeChartBase,
nodeId: number,
stops: GradientStop[],
palette: Vec3[],
negativeAlpha: number,
): [number, number, number, number] {
const rgb = leafColor(chart, nodeId, stops, palette);
const alpha = chart._nodeStore.sizeSign[nodeId] < 0 ? negativeAlpha : 1.0;
return [rgb[0], rgb[1], rgb[2], alpha];
}
@@ -0,0 +1,235 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
/**
* Struct-of-arrays storage for a hierarchical tree (treemap + sunburst).
* A node is a numeric `id` into parallel typed arrays; hot numeric
* fields live in `Float32Array` / `Int32Array` so iteration is
* cache-linear and heap pressure stays flat.
*
* Children are a singly-linked list per parent (`firstChild` /
* `nextSibling`, with `lastChild` for O(1) append). The `NULL_NODE = -1`
* sentinel marks "no node" for `parent`, end-of-list for `nextSibling`,
* and "leaf" for `firstChild`.
*
* Layout fields come in two flavors:
* - `x0 / y0 / x1 / y1` — rectangular coords (treemap, future bar
* hierarchies)
* - `a0 / a1 / r0 / r1` — polar coords (sunburst)
* Each chart populates only its own flavor; the other set wastes ~16 B
* per node (32 MB at 2M nodes) — a small price for keeping a single
* unified store across hierarchical chart types.
*
* At typical tree shapes the SOA + linked-list representation is ~10×
* cheaper on heap and ~4× faster to build than per-node `Object` +
* `Array<Child>` (which allocates ~300 B / node and is O(N²) for naive
* child-name lookup). At 2M nodes the former OOMs the tab; the latter
* stays under 100 MB.
*/
export const NULL_NODE = -1;
export class NodeStore {
// Hot numeric fields (tight-loop iteration in layout / render).
size!: Float32Array;
value!: Float32Array;
colorValue!: Float32Array;
/**
* Sign of the leaf's raw size column value: `-1` when the source row
* was negative, `1` otherwise. `size` itself always stores the
* magnitude so layout code continues to treat negatives as positive
* area; render code uses `sizeSign` to apply a lower alpha on
* negative leaves. Always `1` for branches.
*/
sizeSign!: Int8Array;
// Rectangular layout (treemap).
x0!: Float32Array;
y0!: Float32Array;
x1!: Float32Array;
y1!: Float32Array;
// Polar layout (sunburst). `a0 / a1` in radians, `r0 / r1` in pixels.
a0!: Float32Array;
a1!: Float32Array;
r0!: Float32Array;
r1!: Float32Array;
depth!: Int32Array;
// Tree topology.
parent!: Int32Array;
firstChild!: Int32Array;
nextSibling!: Int32Array;
lastChild!: Int32Array;
childCount!: Int32Array;
// Leaf source-row link. `-1` for branches; for leaves, the index
// into the chart's row-data column buffers (enables O(1) tooltip
// lookup without per-node `Map` allocations).
leafRowIdx!: Int32Array;
// Cold (only read on hover / label draw): plain JS arrays.
name!: string[];
colorLabel!: string[];
count = 0;
capacity = 0;
constructor(initialCapacity = 1024) {
this._allocate(initialCapacity);
}
reset(): void {
// Zero the layout typed arrays for slots that were occupied
// before. `squarify` / `partitionSunburst` overwrite visited
// nodes before any read, but early-bail branches (node area
// below `MIN_VISIBLE_AREA`) skip the recursion and leave
// descendants untouched. Re-using those slots in the next
// render with stale `x0/y0/x1/y1/a0/a1/r0/r1` leaks the
// previous render's rectangles / arcs through `collectVisible`
// into the new scene — the "leftover hoverable cells" bug.
//
// Fill only up to the prior `count` (the tree's logical size)
// — capacity can be much larger after growth and filling it
// whole is O(capacity) unnecessary work.
if (this.count > 0) {
this.x0.fill(0, 0, this.count);
this.y0.fill(0, 0, this.count);
this.x1.fill(0, 0, this.count);
this.y1.fill(0, 0, this.count);
this.a0.fill(0, 0, this.count);
this.a1.fill(0, 0, this.count);
this.r0.fill(0, 0, this.count);
this.r1.fill(0, 0, this.count);
}
this.count = 0;
}
/**
* Reserve a new node id. Caller must immediately initialize the
* hot numeric fields and set `parent` / `depth`; topology fields
* default to `NULL_NODE`.
*/
allocate(): number {
if (this.count === this.capacity) {
this._allocate(this.capacity * 2);
}
const id = this.count++;
this.firstChild[id] = NULL_NODE;
this.nextSibling[id] = NULL_NODE;
this.lastChild[id] = NULL_NODE;
this.childCount[id] = 0;
this.leafRowIdx[id] = NULL_NODE;
this.size[id] = 0;
this.value[id] = 0;
this.colorValue[id] = NaN;
this.sizeSign[id] = 1;
this.name[id] = "";
this.colorLabel[id] = "";
return id;
}
/**
* O(1) append `childId` as the last child of `parentId`.
*/
appendChild(parentId: number, childId: number): void {
this.parent[childId] = parentId;
this.depth[childId] = this.depth[parentId] + 1;
this.nextSibling[childId] = NULL_NODE;
const last = this.lastChild[parentId];
if (last === NULL_NODE) {
this.firstChild[parentId] = childId;
} else {
this.nextSibling[last] = childId;
}
this.lastChild[parentId] = childId;
this.childCount[parentId]++;
}
/**
* `true` if the node has no children (branches set firstChild when they acquire one).
*/
isLeaf(id: number): boolean {
return this.firstChild[id] === NULL_NODE;
}
private _allocate(newCapacity: number): void {
const cap = Math.max(newCapacity, 1024);
const grow = <T extends Float32Array | Int32Array | Int8Array>(
old: T | undefined,
ctor: { new (n: number): T },
): T => {
const next = new ctor(cap);
if (old && old.length > 0) {
next.set(old);
}
return next;
};
this.size = grow(this.size, Float32Array);
this.value = grow(this.value, Float32Array);
this.colorValue = grow(this.colorValue, Float32Array);
this.sizeSign = grow(this.sizeSign, Int8Array);
this.x0 = grow(this.x0, Float32Array);
this.y0 = grow(this.y0, Float32Array);
this.x1 = grow(this.x1, Float32Array);
this.y1 = grow(this.y1, Float32Array);
this.a0 = grow(this.a0, Float32Array);
this.a1 = grow(this.a1, Float32Array);
this.r0 = grow(this.r0, Float32Array);
this.r1 = grow(this.r1, Float32Array);
this.depth = grow(this.depth, Int32Array);
this.parent = grow(this.parent, Int32Array);
this.firstChild = grow(this.firstChild, Int32Array);
this.nextSibling = grow(this.nextSibling, Int32Array);
this.lastChild = grow(this.lastChild, Int32Array);
this.childCount = grow(this.childCount, Int32Array);
this.leafRowIdx = grow(this.leafRowIdx, Int32Array);
// JS arrays: preserve existing, extend with empty slots.
if (!this.name) {
this.name = new Array(cap);
} else {
this.name.length = cap;
}
if (!this.colorLabel) {
this.colorLabel = new Array(cap);
} else {
this.colorLabel.length = cap;
}
this.capacity = cap;
}
}
/**
* Walk the ancestor chain back to (but not including) the synthetic
* root, topmost first. Allocates a fresh string array — callers that
* care about allocations should inline the walk.
*/
export function ancestorNames(store: NodeStore, id: number): string[] {
const out: string[] = [];
let n = id;
while (store.parent[n] !== NULL_NODE) {
out.push(store.name[n]);
n = store.parent[n];
}
return out.reverse();
}
@@ -0,0 +1,92 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { AbstractChart } from "../chart-base";
import type { ColumnDataMap } from "../../data/view-reader";
import { NodeStore, NULL_NODE } from "./node-store";
import { LazyTooltip } from "../../interaction/lazy-tooltip";
/**
* Sentinel fallback for the Size slot when the user hasn't picked one:
* use the first non-metadata column in the incoming view. Tree charts
* still need *some* numeric-ish column to size geometry.
*/
export function firstNonMetadataColumn(columns: ColumnDataMap): string {
for (const k of columns.keys()) {
if (!k.startsWith("__")) {
return k;
}
}
return "";
}
/**
* Shared state for hierarchical charts (treemap, sunburst). Holds the
* tree store + streaming-insert scaffolding + per-row tooltip data
* buffers. Concrete chart classes extend this and add their own
* layout / render / interact state.
*
* Fields are `public` so the `tree-data.ts` helpers and per-chart
* layout modules can read/write them without friction.
*/
export abstract class TreeChartBase extends AbstractChart {
// Shared column-slot resolution
_sizeName = "";
_colorName = "";
/**
* Color-slot semantics.
* - `"empty"`: no Color slot → single palette[0], legend suppressed.
* - `"numeric"`: Color column is float / integer / date / datetime →
* continuous gradient via `colorValueToT`.
* - `"series"`: Color column is any other type → discrete series
* palette keyed by the composite of group_by level values.
*/
_colorMode: "empty" | "numeric" | "series" = "empty";
// Tree storage (SOA + linked-list children)
_nodeStore: NodeStore = new NodeStore();
_rootId: number = NULL_NODE;
_currentRootId: number = NULL_NODE;
_breadcrumbIds: number[] = [];
/**
* Per-parent `Map<childName, childId>` for O(1) find-or-create
* during streaming tree insertion. Rebuilt on each dataset reset.
*/
_childLookup: Map<number, Map<string, number>> = new Map();
// Streaming-insert row counter
// Source-view row offset tracked across chunks so `leafRowIdx` on
// each leaf points back to the correct view row for lazy tooltip
// fetches via `AbstractChart._lazyRows`.
_rowCount = 0;
// Color extents / categorical key table
_colorMin = Infinity;
_colorMax = -Infinity;
_uniqueColorLabels: Map<string, number> = new Map();
// Visible-node cache (populated per frame by layout/collect) ──────
_visibleNodeIds: Int32Array | null = null;
_visibleNodeCount = 0;
/**
* Lazy-tooltip cache. `lines` is `null` until the async row fetch
* resolves; the controller's serial dance drops stale results so
* rapid mouse motion doesn't paint out-of-date text. The render
* path consults `hoveredTarget` to verify cached lines belong to
* the currently hovered node before painting.
*/
_lazyTooltip = new LazyTooltip<number>();
}
@@ -0,0 +1,208 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { Canvas2D, Context2D } from "../canvas-types";
import type { PlotRect } from "../../layout/plot-layout";
import { PlotLayout } from "../../layout/plot-layout";
import type { GradientStop } from "../../theme/gradient";
import type { Vec3 } from "../../theme/palette";
import type { Theme } from "../../theme/theme";
import {
renderCategoricalLegend,
renderCategoricalLegendAt,
renderLegend,
} from "../../axis/legend";
import type { TreeChartBase } from "./tree-chart";
import { drawTooltipBox } from "./draw-tooltip-box";
/**
* Click target for one breadcrumb segment. Tree-chart hit-testing
* checks `_breadcrumbRegions` first so a click on the trail re-roots
* the view to that ancestor.
*/
export interface BreadcrumbRegion {
nodeId: number;
x0: number;
y0: number;
x1: number;
y1: number;
}
const BREADCRUMB_HEIGHT = 24;
const BREADCRUMB_PAD_X = 8;
const BREADCRUMB_TEXT_Y = 12;
const BREADCRUMB_HIT_PAD = 2;
const BREADCRUMB_SEPARATOR = " ";
/**
* Paint the ancestor-path strip across the top of a tree chart and
* record per-crumb hit regions on `chart._breadcrumbRegions`. Mirrors
* the structure used by both treemap and sunburst — they only differ
* in their hover-highlight geometry, so this strip is shared verbatim.
*/
export function renderBreadcrumbs(
chart: TreeChartBase & { _breadcrumbRegions: BreadcrumbRegion[] },
ctx: Context2D,
cssWidth: number,
fontFamily: string,
textColor: string,
): void {
chart._breadcrumbRegions = [];
const bgColor = chart._resolveTheme().tooltipBg;
ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, cssWidth, BREADCRUMB_HEIGHT);
ctx.font = `11px ${fontFamily}`;
ctx.textAlign = "left";
ctx.textBaseline = "middle";
let x = BREADCRUMB_PAD_X;
const store = chart._nodeStore;
for (let i = 0; i < chart._breadcrumbIds.length; i++) {
const crumbId = chart._breadcrumbIds[i];
const isLast = i === chart._breadcrumbIds.length - 1;
const label = store.name[crumbId];
ctx.fillStyle = textColor;
const textW = ctx.measureText(label).width;
ctx.fillText(label, x, BREADCRUMB_TEXT_Y);
chart._breadcrumbRegions.push({
nodeId: crumbId,
x0: x - BREADCRUMB_HIT_PAD,
y0: 0,
x1: x + textW + BREADCRUMB_HIT_PAD,
y1: BREADCRUMB_HEIGHT,
});
x += textW;
if (!isLast) {
ctx.fillText(BREADCRUMB_SEPARATOR, x, BREADCRUMB_TEXT_Y);
x += ctx.measureText(BREADCRUMB_SEPARATOR).width;
}
}
}
/**
* Paint the lazy-tooltip box for a tree-chart node, anchored at
* `(cx, cy)`. Returns early when no lines are cached for `nodeId`
* (the lazy lookup hasn't resolved yet, or `nodeId` doesn't match the
* currently-hovered target). Both treemap (rect-center) and sunburst
* (arc-mid) charts only differ in how they compute the anchor.
*/
export function renderTreeTooltip(
chart: TreeChartBase,
ctx: Context2D,
nodeId: number,
cx: number,
cy: number,
cssWidth: number,
cssHeight: number,
fontFamily: string,
): void {
const lines =
chart._lazyTooltip.hoveredTarget === nodeId
? (chart._lazyTooltip.lines ?? [])
: [];
if (lines.length === 0) {
return;
}
drawTooltipBox(
ctx,
chart._resolveTheme(),
lines,
cx,
cy,
cssWidth,
cssHeight,
fontFamily,
);
}
/**
* Paint a color legend (categorical swatches or numeric gradient bar)
* for a tree chart. Shared by sunburst + treemap; both consult
* `_colorMode` / `_uniqueColorLabels.size` / `_colorMin..max` the same
* way.
*
* `categoricalRect`, when non-null, is used as the explicit rect for
* the categorical-swatch variant (sunburst's faceted mode passes
* `FacetGrid.legendRect` here). Numeric mode always derives from a
* synthetic single-plot `PlotLayout` to match the legacy per-chart
* branch — its gradient bar's vertical span doesn't fit the
* categorical legend's compact rect.
*
* Returns silently when the color slot is empty, when categorical mode
* has only one label, or when numeric mode has a degenerate
* (`min >= max`) extent.
*/
export function renderTreeColorLegend(
chart: TreeChartBase,
canvas: Canvas2D,
palette: Vec3[],
stops: GradientStop[],
theme: Theme,
cssWidth: number,
cssHeight: number,
categoricalRect: PlotRect | null = null,
): void {
if (chart._colorMode === "series" && chart._uniqueColorLabels.size > 1) {
if (categoricalRect) {
renderCategoricalLegendAt(
canvas,
categoricalRect,
chart._uniqueColorLabels,
palette,
theme,
);
} else {
renderCategoricalLegend(
canvas,
syntheticLegendLayout(cssWidth, cssHeight),
chart._uniqueColorLabels,
palette,
theme,
);
}
} else if (
chart._colorMode === "numeric" &&
chart._colorMin < chart._colorMax
) {
renderLegend(
canvas,
syntheticLegendLayout(cssWidth, cssHeight),
{
min: chart._colorMin,
max: chart._colorMax,
label: chart._colorName,
},
stops,
theme,
chart.getColumnFormatter(chart._colorName, "value"),
);
}
}
function syntheticLegendLayout(
cssWidth: number,
cssHeight: number,
): PlotLayout {
return new PlotLayout(cssWidth, cssHeight, {
hasXLabel: false,
hasYLabel: false,
hasLegend: true,
});
}
@@ -0,0 +1,623 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
/**
* Streaming tree pipeline shared by treemap and sunburst. Rows arrive
* incrementally; each chunk inserts its rows directly into the SOA
* tree. Per-leaf tooltip columns are fetched lazily on pin via the
* chart's `_lazyRows`; the tree only retains `leafRowIdx` per leaf
* (small, O(leaves)) as the handle back to the source view row.
* After a chunk is processed, `finalizeTree` recomputes `value`
* bottom-up.
*
* Color mode:
* - `"numeric"` — `readColor` reads the row's numeric value; the
* render path maps it through the continuous gradient.
* - `"series"` — `readColor` reads the row's string value from the
* color column's dictionary, and `seedColorLabels` pre-populates
* `_uniqueColorLabels` in dictionary-index order. Render picks
* `palette[dictIdx % paletteSize]`.
* - `"empty"` — no color column; every leaf gets `palette[0]`.
*
* When `_splitBy` is populated, every row is duplicated — one insertion
* per split prefix, with that prefix pushed as the top-level path
* segment so the top-level children of the synthetic root become
* facet roots. The per-prefix `size` / `color` columns (named
* `${prefix}|${base}`) feed the facet's values. `seedColorLabels`
* runs once per split so every split's dictionary contributes to the
* shared legend.
*/
import type { ColumnDataMap, ColumnData } from "../../data/view-reader";
import { buildSplitGroups } from "../../data/split-groups";
import { synthesizeStringLevel } from "./category-axis-resolver";
import { NULL_NODE } from "./node-store";
import type { TreeChartBase } from "./tree-chart";
// Reset
/**
* Reset the shared tree state. Called on the first chunk
* (`startRow === 0`) of each dataset load.
*/
export function resetTreeState(chart: TreeChartBase): void {
chart._nodeStore.reset();
chart._childLookup.clear();
// Allocate the synthetic root (id 0 by convention).
const rootId = chart._nodeStore.allocate();
chart._nodeStore.name[rootId] = "Total";
chart._nodeStore.depth[rootId] = 0;
chart._nodeStore.parent[rootId] = NULL_NODE;
chart._childLookup.set(rootId, new Map());
chart._rootId = rootId;
chart._currentRootId = rootId;
chart._breadcrumbIds = [rootId];
chart._rowCount = 0;
chart._colorMin = Infinity;
chart._colorMax = -Infinity;
chart._uniqueColorLabels.clear();
chart._visibleNodeIds = null;
chart._visibleNodeCount = 0;
}
// Tree insertion
/**
* Find-or-create a child of `parentId` named `segment`. Uses a per-
* parent `Map<name, childId>` for O(1) lookup.
*/
function childByName(
chart: TreeChartBase,
parentId: number,
segment: string,
): number {
let lookup = chart._childLookup.get(parentId);
if (!lookup) {
lookup = new Map();
chart._childLookup.set(parentId, lookup);
}
const existing = lookup.get(segment);
if (existing !== undefined) {
return existing;
}
const childId = chart._nodeStore.allocate();
chart._nodeStore.name[childId] = segment;
chart._nodeStore.appendChild(parentId, childId);
lookup.set(segment, childId);
return childId;
}
/**
* Insert one row into the tree.
*/
function insertRow(
chart: TreeChartBase,
rowPath: string[],
sizeValue: number,
colorValue: number,
colorLabel: string,
rowIdx: number,
groupByLen: number,
): void {
let currentId = chart._rootId;
const depth = rowPath.length;
for (let d = 0; d < depth; d++) {
const childId = childByName(chart, currentId, rowPath[d]);
if (d === depth - 1) {
if (groupByLen === 0 || depth === groupByLen) {
// Store `|size|` for layout; remember the sign so
// render can dim negative leaves (matches the area
// chart's `theme.areaOpacity`).
chart._nodeStore.size[childId] = Math.abs(sizeValue);
chart._nodeStore.sizeSign[childId] = sizeValue < 0 ? -1 : 1;
chart._nodeStore.leafRowIdx[childId] = rowIdx;
}
if (!isNaN(colorValue)) {
chart._nodeStore.colorValue[childId] = colorValue;
if (colorValue < chart._colorMin) {
chart._colorMin = colorValue;
}
if (colorValue > chart._colorMax) {
chart._colorMax = colorValue;
}
}
if (colorLabel) {
chart._nodeStore.colorLabel[childId] = colorLabel;
if (!chart._uniqueColorLabels.has(colorLabel)) {
chart._uniqueColorLabels.set(
colorLabel,
chart._uniqueColorLabels.size,
);
}
}
}
currentId = childId;
}
}
function readColor(
chart: TreeChartBase,
colorCol: ColumnData | null | undefined,
rowIdx: number,
): { colorValue: number; colorLabel: string } {
let colorValue = NaN;
let colorLabel = "";
if (!colorCol) {
return { colorValue, colorLabel };
}
if (chart._colorMode === "numeric" && colorCol.values) {
colorValue = colorCol.values[rowIdx] as number;
} else if (
chart._colorMode === "series" &&
colorCol.indices &&
colorCol.dictionary
) {
// Read the dictionary-decoded string for this row. The palette
// index that render uses is `_uniqueColorLabels.get(label)`,
// which `seedColorLabels` seeds in dictionary-index order so
// the end result is `palette[dictIdx % paletteSize]`.
colorLabel = colorCol.dictionary[colorCol.indices[rowIdx]];
}
return { colorValue, colorLabel };
}
/**
* Seed `_uniqueColorLabels` with the color column's dictionary in
* index order. Using insertion-order-guarded `.set` means later
* chunks (or later splits in split_by mode) append new entries
* without disturbing already-assigned indices; for a single stable
* dictionary this yields `_uniqueColorLabels.get(dict[i]) === i`.
*
* No-op outside `"series"` mode or when the column lacks a
* dictionary.
*/
function seedColorLabels(
chart: TreeChartBase,
colorCol: ColumnData | null | undefined,
): void {
if (chart._colorMode !== "series") {
return;
}
if (!colorCol?.dictionary) {
return;
}
const dict = colorCol.dictionary;
for (let i = 0; i < dict.length; i++) {
const s = dict[i];
if (!chart._uniqueColorLabels.has(s)) {
chart._uniqueColorLabels.set(s, chart._uniqueColorLabels.size);
}
}
}
// Chunk processor
interface SplitSource {
prefix: string;
sizeCol: ColumnData | null;
colorCol: ColumnData | null;
}
/**
* Resolve the per-split size / color columns. Returns `null` when
* `_splitBy` is empty — callers then take the non-split fast path.
*/
function resolveSplitSources(
chart: TreeChartBase,
columns: ColumnDataMap,
): SplitSource[] | null {
if (chart._splitBy.length === 0) {
return null;
}
const required: string[] = chart._sizeName ? [chart._sizeName] : [];
const optional: string[] = chart._colorName ? [chart._colorName] : [];
const groups = buildSplitGroups(columns, required, optional);
if (groups.length === 0) {
return null;
}
return groups.map((g) => ({
prefix: g.prefix,
sizeCol: chart._sizeName
? (columns.get(`${g.prefix}|${chart._sizeName}`) ?? null)
: null,
colorCol: chart._colorName
? (columns.get(`${g.prefix}|${chart._colorName}`) ?? null)
: null,
}));
}
/**
* Process one incoming chunk: grow row-data buffers, walk every row,
* capture column values, and insert into the tree.
*/
export function processTreeChunk(
chart: TreeChartBase,
columns: ColumnDataMap,
): void {
const rpCols: { indices: Int32Array; dictionary: string[] }[] = [];
for (let n = 0; ; n++) {
const rp = columns.get(`__ROW_PATH_${n}__`);
if (!rp) {
break;
}
if (rp.type === "string" && rp.indices && rp.dictionary) {
rpCols.push({ indices: rp.indices, dictionary: rp.dictionary });
} else if (rp.values) {
// Non-string group_by (integer / float / date / datetime /
// boolean) — synthesize a string-indexed dictionary so the
// tree insertion loop can treat every level uniformly.
// Uses the same formatter as `resolveCategoryAxis`.
const levelName = chart._groupBy[n];
const levelType = chart._groupByTypes[levelName] ?? "string";
rpCols.push(synthesizeStringLevel(rp, rp.values.length, levelType));
} else {
break;
}
}
const hasGroupBy = rpCols.length > 0;
const groupByLen = chart._groupBy.length;
const splitSources = resolveSplitSources(chart, columns);
const hasSplits = splitSources !== null;
const sizeCol = chart._sizeName ? columns.get(chart._sizeName) : null;
const colorCol = chart._colorName ? columns.get(chart._colorName) : null;
const firstSizeCol = hasSplits ? splitSources![0].sizeCol : sizeCol;
const numRows = hasGroupBy
? rpCols[0].indices.length
: (firstSizeCol?.values?.length ?? 0);
if (numRows === 0) {
return;
}
// Seed palette label indices from the color column's dictionary
// BEFORE inserting rows, so the first row doesn't assign label 0
// to whichever dict value it happens to reference. For splits we
// seed once per split's own color column so every dict value is
// known to the shared legend.
if (hasSplits) {
for (const src of splitSources!) {
seedColorLabels(chart, src.colorCol);
}
} else {
seedColorLabels(chart, colorCol);
}
// `base` is the source-view row offset the tree should tag its
// leaves with. `_rowCount` tracks how many rows prior chunks
// occupied so `leafRowIdx[childId] = base + i` still points back
// to the correct view row after multiple chunk arrivals.
const base = chart._rowCount;
// The split expansion inserts every row under one extra leading
// path segment — the split prefix. When `group_by` is also active
// the path is `[prefix, ...groupByLevels]` (length `groupByLen + 1`)
// and the leaf store gate inside `insertRow` (`depth === groupByLen`)
// needs the `+1` to find the leaf. When `group_by` is empty we fall
// through the no-group-by branch below, which writes paths of the
// form `[prefix, label]`; that path's leaf is the row label, and
// sizing it relies on `insertRow`'s `groupByLen === 0`
// "every leaf gets sized" branch — passing `0` here lets that
// branch fire so each row's leaf actually receives a size. The
// historical `+1`-always form quietly produced an effective
// `groupByLen=1` against a depth-2 path, so the size store was
// skipped and treemap / sunburst rendered nothing.
const effectiveGroupLen =
hasSplits && hasGroupBy ? groupByLen + 1 : groupByLen;
if (!hasGroupBy) {
// Flat fallback: synthesize a single-segment path per row from
// the first string column (or a "Row N" sentinel).
//
// In `split_by` mode every column the view emits is pivoted as
// `${splitPrefix}|${baseName}`, so two correctness traps need
// to be avoided here:
//
// 1. The size / color column the user picked appears under
// its prefixed form (`First Class|Region`), so a literal
// `name === chart._sizeName/_colorName` skip would miss
// them and the loop would happily pick the color column
// itself as the label source.
// 2. Reading a pivoted column by row index returns values
// keyed to ONE specific split (the prefix that happened
// to win the iteration order), not to that row's actual
// split — so even a "different" pivoted dictionary column
// isn't a legitimate per-row label. And once any pivoted
// column is used as a label source, `childByName` collapses
// every row sharing a `(prefix, label)` pair onto a single
// node — last-write-wins for `size` — which truncates the
// visible cell count to `cardinality(label) × cardinality(splits)`.
//
// The two checks below address both: compare base names (post-`|`)
// for the size/color skip, and reject any pivoted column outright
// in split mode. If nothing remains, `labelCol` stays undefined
// and the per-row loop falls through to the `Row N` sentinel,
// which gives every row a unique key.
let labelCol: ColumnData | undefined;
for (const [name, col] of columns) {
if (name.startsWith("__")) {
continue;
}
if (hasSplits && name.includes("|")) {
continue;
}
const pipeIdx = name.lastIndexOf("|");
const baseName =
pipeIdx === -1 ? name : name.substring(pipeIdx + 1);
if (baseName === chart._sizeName || baseName === chart._colorName) {
continue;
}
if (col.type === "string" && col.indices && col.dictionary) {
labelCol = col;
break;
}
}
const sources = hasSplits ? splitSources! : null;
for (let i = 0; i < numRows; i++) {
const label =
labelCol?.indices && labelCol?.dictionary
? labelCol.dictionary[labelCol.indices[i]]
: `Row ${base + i}`;
if (sources) {
for (const src of sources) {
// Pass the signed value through; `insertRow` stores
// `|size|` and `sizeSign` separately so the
// render pass can dim negative leaves.
const sizeValue = src.sizeCol?.values
? (src.sizeCol.values[i] as number)
: 1;
const { colorValue, colorLabel } = readColor(
chart,
src.colorCol,
i,
);
insertRow(
chart,
[src.prefix, label],
sizeValue,
colorValue,
colorLabel,
base + i,
effectiveGroupLen,
);
}
} else {
const sizeValue = sizeCol?.values
? (sizeCol.values[i] as number)
: 1;
const { colorValue, colorLabel } = readColor(
chart,
colorCol,
i,
);
insertRow(
chart,
[label],
sizeValue,
colorValue,
colorLabel,
base + i,
effectiveGroupLen,
);
}
}
chart._rowCount = base + numRows;
return;
}
// Hierarchical (group_by present): reuse a scratch path buffer
// across rows to avoid per-row array allocation. When splits are
// active the scratch is one slot longer to hold the leading prefix.
const extra = hasSplits ? 1 : 0;
const pathScratch: string[] = new Array(rpCols.length + extra);
for (let i = 0; i < numRows; i++) {
let pathLen = 0;
for (let d = 0; d < rpCols.length; d++) {
const rp = rpCols[d];
const label = rp.dictionary[rp.indices[i]];
if (!label && label !== "0") {
break;
}
pathScratch[extra + pathLen++] = label;
}
if (pathLen === 0) {
continue;
} // skip total row
if (hasSplits) {
for (const src of splitSources!) {
pathScratch[0] = src.prefix;
const rowPath = pathScratch.slice(0, pathLen + 1);
const sizeValue = src.sizeCol?.values
? (src.sizeCol.values[i] as number)
: 1;
const { colorValue, colorLabel } = readColor(
chart,
src.colorCol,
i,
);
insertRow(
chart,
rowPath,
sizeValue,
colorValue,
colorLabel,
base + i,
effectiveGroupLen,
);
}
} else {
const rowPath = pathScratch.slice(0, pathLen);
const sizeValue = sizeCol?.values
? (sizeCol.values[i] as number)
: 1;
const { colorValue, colorLabel } = readColor(chart, colorCol, i);
insertRow(
chart,
rowPath,
sizeValue,
colorValue,
colorLabel,
base + i,
effectiveGroupLen,
);
}
}
chart._rowCount = base + numRows;
}
// Finalize
/**
* Post-chunk finalization.
* 1. Recompute `value` bottom-up from `size` via an iterative
* post-order walk.
* 2. Re-resolve `_currentRootId` from the breadcrumb name-path so
* drill state survives incremental chunk arrivals.
*
* `colorLabel` is set at insert time (`readColor`) and needs no
* post-pass: in `"series"` mode it comes from the color column's
* dictionary, and in `"numeric"` / `"empty"` modes it's unused.
*/
export function finalizeTree(chart: TreeChartBase): void {
const store = chart._nodeStore;
const value = store.value;
const size = store.size;
const firstChild = store.firstChild;
const nextSibling = store.nextSibling;
// Iterative post-order. Stack holds `(id, state)` pairs; state
// 0 = pre-visit, 1 = post-visit.
let stack = new Int32Array(128);
stack[0] = chart._rootId;
stack[1] = 0;
let sp = 1;
// Reset value accumulators.
for (let i = 0; i < store.count; i++) {
value[i] = 0;
}
while (sp > 0) {
sp--;
const id = stack[sp * 2];
const s = stack[sp * 2 + 1];
if (s === 0) {
stack[sp * 2 + 1] = 1;
sp++;
for (let c = firstChild[id]; c !== NULL_NODE; c = nextSibling[c]) {
if ((sp + 1) * 2 > stack.length) {
const bigger = new Int32Array(stack.length * 2);
bigger.set(stack);
stack = bigger;
}
stack[sp * 2] = c;
stack[sp * 2 + 1] = 0;
sp++;
}
} else {
if (firstChild[id] === NULL_NODE) {
value[id] = Math.max(0, size[id]);
} else {
let sum = 0;
for (
let c = firstChild[id];
c !== NULL_NODE;
c = nextSibling[c]
) {
sum += value[c];
}
value[id] = sum;
}
}
}
// Preserve drill state across incremental chunk arrivals: walk the
// breadcrumb name-path from the root and re-resolve. If a segment
// is missing (shouldn't happen with incremental build, but
// defensively), fall back to the root.
if (chart._breadcrumbIds.length > 1) {
const breadcrumbNames: string[] = [];
for (let i = 1; i < chart._breadcrumbIds.length; i++) {
breadcrumbNames.push(store.name[chart._breadcrumbIds[i]]);
}
let node = chart._rootId;
let valid = true;
for (const seg of breadcrumbNames) {
const lookup = chart._childLookup.get(node);
const next = lookup?.get(seg);
if (next === undefined) {
valid = false;
break;
}
node = next;
}
if (valid && store.firstChild[node] !== NULL_NODE) {
chart._currentRootId = node;
rebuildBreadcrumbs(chart, node);
return;
}
}
chart._currentRootId = chart._rootId;
chart._breadcrumbIds = [chart._rootId];
}
// Breadcrumbs
/**
* Rebuild `chart._breadcrumbIds` by walking up from `nodeId`.
*/
export function rebuildBreadcrumbs(chart: TreeChartBase, nodeId: number): void {
const ids: number[] = [];
let n = nodeId;
while (n !== NULL_NODE) {
ids.unshift(n);
n = chart._nodeStore.parent[n];
}
chart._breadcrumbIds = ids;
}
@@ -0,0 +1,209 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { TreeChartBase } from "./tree-chart";
import { NULL_NODE, ancestorNames } from "./node-store";
import { rebuildBreadcrumbs } from "./tree-data";
/**
* Common subset of `TreemapChart` / `SunburstChart` reached by the
* shared interaction helpers — anything that lives on `TreeChartBase`
* plus the pinned/hover/facet-drill state the two charts both declare
* with identical shape but on the subclass (so we type it as an
* intersection).
*/
export type TreeInteractChart = TreeChartBase & {
_pinnedNodeId: number;
_hoveredNodeId: number;
_facetDrillRoots: Map<string, number>;
};
/**
* Emit `perspective-click` + `perspective-global-filter selected:true`
* for a treemap/sunburst node. The path is walked via `ancestorNames`
* and split into split-by prefix + group-by levels using
* `_splitBy.length` as the boundary; faceted mode keeps the depth-0
* ancestor as the split prefix.
*/
export async function emitTreeNodeEvent(
chart: TreeInteractChart,
nodeId: number,
kind: "leaf" | "branch",
): Promise<void> {
const store = chart._nodeStore;
const path = ancestorNames(store, nodeId);
const isFaceted =
chart._splitBy.length > 0 && chart._facetConfig.facet_mode === "grid";
const splitByValues: (string | null)[] = isFaceted
? path.slice(0, chart._splitBy.length)
: [];
const groupByValues: (string | null)[] = isFaceted
? path.slice(
chart._splitBy.length,
chart._splitBy.length + chart._groupBy.length,
)
: path.slice(0, chart._groupBy.length);
const rowIdx = kind === "leaf" ? (store.leafRowIdx[nodeId] ?? null) : null;
await chart.emitClickAndSelect({
rowIdx: rowIdx != null && rowIdx >= 0 ? rowIdx : null,
columnName: chart._sizeName,
groupByValues,
splitByValues,
});
}
/**
* Build tooltip lines for `nodeId`: ancestor name path + aggregate
* value + (numeric) color value + per-row tooltip columns from
* `_lazyRows` for leaves. The leaf branch awaits the source-view row
* fetch; branch nodes have no underlying row so they emit a Children
* count instead.
*/
export async function buildTreeTooltipLines(
chart: TreeInteractChart,
nodeId: number,
): Promise<string[]> {
const store = chart._nodeStore;
const lines: string[] = [];
const pathNames: string[] = [];
let p = nodeId;
while (store.parent[p] !== NULL_NODE) {
pathNames.push(store.name[p]);
p = store.parent[p];
}
pathNames.reverse();
if (pathNames.length > 0) {
lines.push(pathNames.join(" "));
} else {
lines.push(store.name[nodeId]);
}
const sizeFmt = chart.getColumnFormatter(chart._sizeName, "value");
lines.push(`Value: ${sizeFmt(store.value[nodeId])}`);
if (chart._colorName && !isNaN(store.colorValue[nodeId])) {
const colorFmt = chart.getColumnFormatter(chart._colorName, "value");
lines.push(
`${chart._colorName}: ${colorFmt(store.colorValue[nodeId])}`,
);
}
const rowIdx = store.leafRowIdx[nodeId];
const isLeaf =
store.firstChild[nodeId] === NULL_NODE && rowIdx !== NULL_NODE;
if (isLeaf && chart._lazyRows) {
const row = await chart._lazyRows.fetchRow(rowIdx);
for (const [name, value] of row) {
if (value === null || value === undefined) {
continue;
}
if (name === chart._colorName && !isNaN(store.colorValue[nodeId])) {
continue;
}
if (typeof value === "number") {
lines.push(
`${name}: ${chart.getColumnFormatter(name, "value")(value)}`,
);
} else {
lines.push(`${name}: ${value}`);
}
}
}
if (store.firstChild[nodeId] !== NULL_NODE) {
lines.push(`Children: ${store.childCount[nodeId]}`);
}
return lines;
}
/**
* Pin a tooltip at the chart-supplied anchor. Lines are fetched lazily;
* the `_pinnedNodeId` check on resolve discards stale results from a
* prior pin or dismissal.
*/
export function showTreePinnedTooltip(
chart: TreeInteractChart,
nodeId: number,
anchor: { cx: number; cy: number },
renderChromeOverlay: () => void,
): void {
chart._tooltip.dismiss();
chart._pinnedNodeId = nodeId;
const cssWidth = chart._glManager?.cssWidth ?? 0;
const cssHeight = chart._glManager?.cssHeight ?? 0;
buildTreeTooltipLines(chart, nodeId).then((lines) => {
if (chart._pinnedNodeId !== nodeId) {
return;
}
if (lines.length === 0) {
return;
}
chart._tooltip.pin(
lines,
{ px: anchor.cx, py: anchor.cy },
{ cssWidth, cssHeight },
);
});
chart._hoveredNodeId = NULL_NODE;
renderChromeOverlay();
}
export function dismissTreePinnedTooltip(chart: TreeInteractChart): void {
chart._tooltip.dismiss();
chart._pinnedNodeId = NULL_NODE;
}
/**
* Drill the clicked facet (or the whole chart in non-facet mode).
* Faceted drill walks up to the facet root (top-level child of
* `_rootId`), records the new drill node under that facet's label, and
* re-renders.
*/
export function treeDrillTo(
chart: TreeInteractChart,
nodeId: number,
renderFrame: () => void,
): void {
const store = chart._nodeStore;
if (chart._splitBy.length > 0 && chart._facetConfig.facet_mode === "grid") {
let p = nodeId;
while (p !== NULL_NODE && store.parent[p] !== chart._rootId) {
p = store.parent[p];
}
if (p !== NULL_NODE) {
chart._facetDrillRoots.set(store.name[p], nodeId);
}
chart._hoveredNodeId = NULL_NODE;
renderFrame();
return;
}
chart._currentRootId = nodeId;
rebuildBreadcrumbs(chart, nodeId);
chart._hoveredNodeId = NULL_NODE;
renderFrame();
}
@@ -0,0 +1,112 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
/**
* Generic "find the value-axis extent among records whose category
* position falls inside a visible window" helper. Used by Y Bar,
* X Bar, Candlestick, and any future categorical-axis chart that
* wants an auto-fit value axis on zoom.
*
* Takes a pre-extracted numeric tuple per record via the `extract`
* callback instead of reading fields directly, so the same code path
* handles both Bar's `{ catIdx, y0, y1, axis, seriesId }` shape and
* Candlestick's `{ xCenter, low, high }` shape.
*
* TODO(perf): linear scan. Callers that order records by category
* index could pre-sort once and binary-search the slice to reduce
* this to O(log N + K_visible). Deferred until profiling shows the
* scan in the hot path.
*/
export interface VisibleExtent {
min: number;
max: number;
hasFit: boolean;
}
export interface VisibleExtentRecord {
/**
* Position on the categorical axis — compared to the visible window.
*/
cat: number;
/**
* Low bound of the value-axis extent for this record.
*/
lo: number;
/**
* High bound of the value-axis extent for this record.
*/
hi: number;
/**
* True to skip this record (hidden series / wrong axis / etc.).
*/
skip: boolean;
}
/**
* Walk `items`, filter by `visCatMin <= cat <= visCatMax` (and the
* caller-supplied `skip` flag), and return min/max over `lo`/`hi`.
*
* Returns `hasFit: false` when the window matches no records, so
* callers can fall back to the base domain.
*
* Zero-range guard: if every visible record shares a single value
* (flat run), pad by `±|value|` so the axis doesn't collapse to a
* single pixel.
*/
export function computeVisibleExtent<T>(
items: readonly T[],
visCatMin: number,
visCatMax: number,
extract: (item: T, out: VisibleExtentRecord) => void,
out: VisibleExtent,
): VisibleExtent {
let min = Infinity;
let max = -Infinity;
// Reuse a single scratch record across the walk. `extract` mutates
// it in place — zero allocations per iteration.
const scratch: VisibleExtentRecord = { cat: 0, lo: 0, hi: 0, skip: false };
for (let i = 0; i < items.length; i++) {
scratch.skip = false;
extract(items[i], scratch);
if (scratch.skip) {
continue;
}
if (scratch.cat < visCatMin || scratch.cat > visCatMax) {
continue;
}
if (scratch.lo < min) {
min = scratch.lo;
}
if (scratch.hi > max) {
max = scratch.hi;
}
}
const hasFit = isFinite(min) && isFinite(max);
if (hasFit && min === max) {
const pad = Math.abs(min) || 1;
min -= pad;
max += pad;
}
out.min = min;
out.max = max;
out.hasFit = hasFit;
return out;
}
@@ -0,0 +1,426 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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/view-reader";
import type { CategoricalLevel } from "../../axis/categorical-axis";
import { buildGroupRuns } from "../../axis/categorical-axis-core";
import {
resolveAxisMode,
resolveCategoryAxis,
resolveNumericCategoryDomain,
type AxisMode,
type NumericCategoryDomain,
} from "../common/category-axis-resolver";
export interface HeatmapCell {
xIdx: number;
yIdx: number;
value: number;
}
export interface HeatmapPipelineInput {
columns: ColumnDataMap;
numRows: number;
groupBy: string[];
splitBy: string[];
/**
* Source-column types keyed by column name (table.schema() merged
* with view.expression_schema()). Drives both the X-axis level-type
* lookup (for non-string row-paths) and the Y-axis numeric-mode
* decision when there's a single split_by.
*/
groupByTypes: Record<string, string>;
}
export interface HeatmapPipelineResult {
/**
* Hierarchical row_path levels driving the X axis (outermost-first).
*/
xLevels: CategoricalLevel[];
/**
* Arrow column names in iteration order; `yIdx === index in this list`.
*/
yColumnNames: string[];
/**
* Hierarchical Y levels derived by splitting each name on `|`.
*/
yLevels: CategoricalLevel[];
numX: number;
numY: number;
rowOffset: number;
cells: HeatmapCell[];
/**
* O(1) lookup by `yIdx * numX + xIdx`; `null` means no-data.
*/
cells2D: (HeatmapCell | null)[];
colorMin: number;
colorMax: number;
/**
* X-axis mode. `numeric` fires when the single group_by is
* date/datetime/integer/float; positions live in `xPositions`
* and the domain in `xNumericDomain`.
*/
xAxisMode: AxisMode;
yAxisMode: AxisMode;
xPositions: Float64Array | null;
yPositions: Float64Array | null;
xNumericDomain: NumericCategoryDomain | null;
yNumericDomain: NumericCategoryDomain | null;
}
/**
* Pure heatmap pipeline. Y indexing maps 1:1 to the arrow column iteration
* order — `yIdx` is the position of a value column in the ordered
* `ColumnDataMap` (after skipping `__ROW_PATH_N__` metadata). No
* aggregate/split reconstruction; the column name *is* the Y label.
*
* Externally enforced: only one entry sits in the `Color` slot, so every
* non-metadata column is a splitwise expansion of that single aggregate.
*
* Numeric-axis mode (matching bar/candlestick): when there's exactly one
* non-string group_by, the X axis switches to a real numeric/date axis
* with `xPositions[xIdx]` carrying the data-space center. Y mirrors this
* for a single non-string split_by, parsed best-effort out of the column
* name leaf segment; on parse failure it falls back to category mode.
*/
export function buildHeatmapPipeline(
input: HeatmapPipelineInput,
): HeatmapPipelineResult {
const { columns, numRows, groupBy, splitBy, groupByTypes } = input;
const xAxisMode = resolveAxisMode(groupBy, groupByTypes);
const empty: HeatmapPipelineResult = {
xLevels: [],
yColumnNames: [],
yLevels: [],
numX: 0,
numY: 0,
rowOffset: 0,
cells: [],
cells2D: [],
colorMin: 0,
colorMax: 1,
xAxisMode,
yAxisMode: { mode: "category" },
xPositions: null,
yPositions: null,
xNumericDomain: null,
yNumericDomain: null,
};
const levelTypes = groupBy.map((name) => groupByTypes[name] ?? "string");
const {
rowPaths: xLevels,
numCategories: numX,
rowOffset,
} = resolveCategoryAxis(columns, numRows, groupBy.length, levelTypes);
// Numeric X domain: sourced from `__ROW_PATH_0__`'s raw values when
// the single group_by is non-string.
let xPositions: Float64Array | null = null;
let xNumericDomain: NumericCategoryDomain | null = null;
if (xAxisMode.mode === "numeric" && numX > 0) {
const rp = columns.get("__ROW_PATH_0__");
const resolved = resolveNumericCategoryDomain(
rp?.values,
numX,
rowOffset,
groupBy[0] ?? "",
xAxisMode.numericType === "date" ||
xAxisMode.numericType === "datetime",
);
if (resolved) {
xPositions = resolved.categoryPositions;
xNumericDomain = resolved.numericCategoryDomain;
}
}
// Enumerate Y columns in arrow iteration order, skipping metadata.
const yColumnNames: string[] = [];
for (const name of columns.keys()) {
if (name.startsWith("__")) {
continue;
}
const col = columns.get(name);
if (!col?.values) {
continue;
}
yColumnNames.push(name);
}
const numY = yColumnNames.length;
if (numX === 0 || numY === 0) {
return { ...empty, xLevels, rowOffset };
}
// Build hierarchical Y levels by splitting each name on `|`, coalescing
// consecutive equal tokens per level into a shared dictionary entry.
// Shape mirrors `CategoricalLevel`: one `Int32Array` of dictionary
// indices (length `numY`) + a string dictionary per level.
const yLevels = buildYLevelsFromNames(yColumnNames);
// Y-numeric mode: only when split_by has exactly one non-string level
// AND every column name parses into a finite number. The leaf segment
// is the (split_value, aggregate) `splitVal` token — leading segment
// when there's a trailing `|aggregate`, or the whole name when there
// is none.
const yAxisModeRaw = resolveYAxisMode(splitBy, groupByTypes);
let yAxisMode: AxisMode = { mode: "category" };
let yPositions: Float64Array | null = null;
let yNumericDomain: NumericCategoryDomain | null = null;
if (yAxisModeRaw.mode === "numeric") {
const parsed = parseYPositions(yColumnNames, yAxisModeRaw.numericType);
if (parsed) {
const resolved = resolveNumericCategoryDomain(
parsed,
numY,
0,
splitBy[0] ?? "",
yAxisModeRaw.numericType === "date" ||
yAxisModeRaw.numericType === "datetime",
);
if (resolved) {
yAxisMode = yAxisModeRaw;
yPositions = resolved.categoryPositions;
yNumericDomain = resolved.numericCategoryDomain;
}
}
}
// Walk cells. Per-column loop (outer) lets us exploit arrow-contiguous
// value arrays; validity checks are bit-mask reads.
const cells: HeatmapCell[] = [];
const cells2D: (HeatmapCell | null)[] = new Array(numX * numY).fill(null);
let colorMin = Infinity;
let colorMax = -Infinity;
for (let yIdx = 0; yIdx < numY; yIdx++) {
const col = columns.get(yColumnNames[yIdx])!;
const values = col.values!;
const valid = col.valid;
for (let xIdx = 0; xIdx < numX; xIdx++) {
const row = xIdx + rowOffset;
if (valid) {
const bit = (valid[row >> 3] >> (row & 7)) & 1;
if (!bit) {
continue;
}
}
const v = values[row] as number;
if (!isFinite(v)) {
continue;
}
const cell: HeatmapCell = { xIdx, yIdx, value: v };
cells.push(cell);
cells2D[yIdx * numX + xIdx] = cell;
if (v < colorMin) {
colorMin = v;
}
if (v > colorMax) {
colorMax = v;
}
}
}
if (!isFinite(colorMin) || !isFinite(colorMax)) {
colorMin = 0;
colorMax = 1;
} else if (colorMin === colorMax) {
// Degenerate: all equal — nudge so the normalized t is 0 throughout.
colorMax = colorMin + 1;
}
return {
xLevels,
yColumnNames,
yLevels,
numX,
numY,
rowOffset,
cells,
cells2D,
colorMin,
colorMax,
xAxisMode,
yAxisMode,
xPositions,
yPositions,
xNumericDomain,
yNumericDomain,
};
}
/**
* Y-axis mode for heatmap. Only fires when `splitBy.length === 1` and the
* split column is non-string non-boolean. Multi-split chains stringify
* each segment so the numeric round-trip is ambiguous; keep them on the
* categorical path.
*/
function resolveYAxisMode(
splitBy: string[],
splitByTypes: Record<string, string>,
): AxisMode {
if (splitBy.length !== 1) {
return { mode: "category" };
}
const t = splitByTypes[splitBy[0]];
if (t === "date" || t === "datetime" || t === "integer" || t === "float") {
return { mode: "numeric", numericType: t };
}
return { mode: "category" };
}
/**
* Best-effort parse of the leading `|`-segment of every column name back
* into a numeric value. Returns `null` if any name fails to parse —
* caller falls back to category mode.
*
* Date/datetime split values are written by the engine as ISO-ish text;
* `Date.parse` accepts both `YYYY-MM-DD` and `YYYY-MM-DD HH:MM:SS.fff`.
* Integer/float go through `Number()`.
*/
function parseYPositions(
names: string[],
numericType: "date" | "datetime" | "integer" | "float",
): Float64Array | null {
const positions = new Float64Array(names.length);
for (let i = 0; i < names.length; i++) {
const name = names[i];
const pipeIdx = name.indexOf("|");
const seg = pipeIdx === -1 ? name : name.slice(0, pipeIdx);
let v: number;
if (numericType === "date" || numericType === "datetime") {
v = Date.parse(seg);
if (!isFinite(v)) {
// Engine sometimes emits `YYYY-MM-DD HH:MM:SS.fff` with a
// space separator that older browsers reject; retry with
// `T` substitution.
v = Date.parse(seg.replace(" ", "T"));
}
} else {
v = Number(seg);
}
if (!isFinite(v)) {
return null;
}
positions[i] = v;
}
return positions;
}
/**
* Partition a `ColumnDataMap` into one sub-map per user column. Every
* arrow value column is assigned to the partition whose user column name
* matches its terminal segment (everything after the last `|`, which
* equals the whole name when there's no `split_by`). `__ROW_PATH_N__`
* and `__GROUPING_ID__` metadata columns are copied into every partition
* since they describe the shared X axis.
*
* Used to render one heatmap per user column in a facet grid.
*/
export function partitionColumnsPerFacet(
columns: ColumnDataMap,
userColumns: string[],
): Array<{ label: string; columns: ColumnDataMap }> {
return userColumns.map((userCol) => {
const partition: ColumnDataMap = new Map();
for (const [name, col] of columns) {
if (name.startsWith("__ROW_PATH_") || name === "__GROUPING_ID__") {
partition.set(name, col);
continue;
}
const pipeIdx = name.lastIndexOf("|");
const leaf = pipeIdx === -1 ? name : name.slice(pipeIdx + 1);
if (leaf === userCol) {
partition.set(name, col);
}
}
return { label: userCol, columns: partition };
});
}
/**
* Split each column name on `|` → hierarchical levels. Outermost segment
* is index 0; leaf (terminal) segment is `levels.length - 1`. Runs of
* identical consecutive outer tokens naturally coalesce later during
* render because the Y axis compares `indices[yIdx]` against neighbours.
*/
function buildYLevelsFromNames(names: string[]): CategoricalLevel[] {
if (names.length === 0) {
return [];
}
// Find max depth across all names so every Y entry has a value at
// every level.
let maxDepth = 0;
const segments: string[][] = names.map((n) => n.split("|"));
for (const s of segments) {
if (s.length > maxDepth) {
maxDepth = s.length;
}
}
if (maxDepth === 0) {
return [];
}
const levels: CategoricalLevel[] = [];
for (let d = 0; d < maxDepth; d++) {
const dictionary: string[] = [];
const dictIndex = new Map<string, number>();
const indices = new Int32Array(names.length);
const labels = new Array<string>(names.length);
let maxLabelChars = 0;
for (let i = 0; i < names.length; i++) {
const seg = segments[i][d] ?? "";
let idx = dictIndex.get(seg);
if (idx === undefined) {
idx = dictionary.length;
dictionary.push(seg);
dictIndex.set(seg, idx);
}
indices[i] = idx;
labels[i] = seg;
if (seg.length > maxLabelChars) {
maxLabelChars = seg.length;
}
}
const isLeaf = d === maxDepth - 1;
const runs = isLeaf
? []
: buildGroupRuns(indices, dictionary, 0, names.length);
levels.push({ labels, runs, maxLabelChars });
}
return levels;
}
@@ -0,0 +1,274 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { HeatmapChart } from "./heatmap";
import type { HeatmapCell } from "./heatmap-build";
import { renderCanvasTooltip } from "../../interaction/tooltip-controller";
import type { CategoricalLevel } from "../../axis/categorical-axis";
/**
* Find the heatmap cell under `(mx, my)`. O(1) via the prebuilt `cells2D`
* index. Sets `chart._hoveredCell` and schedules a re-render when the
* hovered cell changes.
*
* In multi-facet mode, iterates facets to find the one whose plot rect
* contains the cursor, then hit-tests against that facet's pipeline.
*/
export function handleHeatmapHover(
chart: HeatmapChart,
mx: number,
my: number,
): void {
chart._hoveredMouseX = mx;
chart._hoveredMouseY = my;
if (chart._facets.length > 0) {
for (let i = 0; i < chart._facets.length; i++) {
const facet = chart._facets[i];
const plot = facet.layout.plotRect;
if (
mx < plot.x ||
mx > plot.x + plot.width ||
my < plot.y ||
my > plot.y + plot.height
) {
continue;
}
const cell = hitCell(
facet.layout,
facet.pipeline.numX,
facet.pipeline.numY,
facet.pipeline.cells2D,
facet.pipeline.xPositions,
facet.pipeline.yPositions,
facet.pipeline.xNumericDomain?.bandWidth ?? 1,
facet.pipeline.yNumericDomain?.bandWidth ?? 1,
mx,
my,
);
setHovered(chart, cell, i);
return;
}
setHovered(chart, null, -1);
return;
}
if (!chart._lastLayout) {
return;
}
const cell = hitCell(
chart._lastLayout,
chart._numX,
chart._numY,
chart._cells2D,
chart._xPositions,
chart._yPositions,
chart._xNumericDomain?.bandWidth ?? 1,
chart._yNumericDomain?.bandWidth ?? 1,
mx,
my,
);
setHovered(chart, cell, -1);
}
/**
* Binary-search a sorted positions array for the entry closest to
* `value`. Returns -1 when the closest entry is more than half a band
* away (the cursor is in the gap between two cells).
*/
function nearestCategoryIdx(
positions: Float64Array,
value: number,
bandWidth: number,
): number {
if (positions.length === 0) {
return -1;
}
let lo = 0;
let hi = positions.length - 1;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (positions[mid] < value) {
lo = mid + 1;
} else {
hi = mid;
}
}
let idx = lo;
if (
idx > 0 &&
Math.abs(positions[idx - 1] - value) < Math.abs(positions[idx] - value)
) {
idx -= 1;
}
if (Math.abs(positions[idx] - value) > bandWidth * 0.5) {
return -1;
}
return idx;
}
function hitCell(
layout: import("../../layout/plot-layout").PlotLayout,
numX: number,
numY: number,
cells2D: (HeatmapCell | null)[],
xPositions: Float64Array | null,
yPositions: Float64Array | null,
xBandWidth: number,
yBandWidth: number,
mx: number,
my: number,
): HeatmapCell | null {
const plot = layout.plotRect;
if (
mx < plot.x ||
mx > plot.x + plot.width ||
my < plot.y ||
my > plot.y + plot.height
) {
return null;
}
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 xIdx = xPositions
? nearestCategoryIdx(xPositions, dataX, xBandWidth)
: Math.round(dataX);
const yIdx = yPositions
? nearestCategoryIdx(yPositions, dataY, yBandWidth)
: Math.round(dataY);
if (xIdx < 0 || xIdx >= numX || yIdx < 0 || yIdx >= numY) {
return null;
}
return cells2D[yIdx * numX + xIdx] ?? null;
}
function setHovered(
chart: HeatmapChart,
next: HeatmapCell | null,
facetIdx: number,
): void {
const prev = chart._hoveredCell;
const same =
(prev?.xIdx ?? -1) === (next?.xIdx ?? -1) &&
(prev?.yIdx ?? -1) === (next?.yIdx ?? -1) &&
chart._hoveredFacetIdx === facetIdx;
if (same) {
return;
}
chart._hoveredCell = next;
chart._hoveredFacetIdx = facetIdx;
if (chart._glManager && chart._renderChromeOverlay) {
// Only the chrome overlay changes on hover — leave WebGL cells
// alone to avoid a full re-upload on every mouse move.
chart._renderChromeOverlay();
}
}
/**
* Format a hierarchical path from a precomputed-label `CategoricalLevel` array.
*/
export function formatHierarchicalPath(
levels: CategoricalLevel[],
idx: number,
): string {
const parts: string[] = [];
for (const lev of levels) {
const s = lev.labels[idx];
if (s != null && s !== "") {
parts.push(s);
}
}
return parts.join(" / ");
}
/**
* Render a tooltip for the currently hovered cell.
*/
export function renderHeatmapTooltip(chart: HeatmapChart): void {
if (!chart._chromeCanvas || !chart._hoveredCell) {
return;
}
let layout: import("../../layout/plot-layout").PlotLayout | null;
let xLevels: CategoricalLevel[];
let yLevels: CategoricalLevel[];
let facetLabel: string | null = null;
if (chart._hoveredFacetIdx >= 0) {
const facet = chart._facets[chart._hoveredFacetIdx];
if (!facet) {
return;
}
layout = facet.layout;
xLevels = facet.pipeline.xLevels;
yLevels = facet.pipeline.yLevels;
facetLabel = facet.label;
} else {
if (!chart._lastLayout) {
return;
}
layout = chart._lastLayout;
xLevels = chart._xLevels;
yLevels = chart._yLevels;
}
const cell = chart._hoveredCell;
// Anchor the tooltip at the cursor rather than the cell center so
// the label tracks the mouse on coarse heatmaps where cells span
// many pixels.
const pos = { px: chart._hoveredMouseX, py: chart._hoveredMouseY };
const lines: string[] = [];
if (facetLabel) {
lines.push(facetLabel);
}
const xPath = formatHierarchicalPath(xLevels, cell.xIdx);
const yPath = formatHierarchicalPath(yLevels, cell.yIdx);
if (xPath) {
lines.push(xPath);
}
if (yPath) {
lines.push(yPath);
}
const valueFmt = chart.getColumnFormatter(chart._columnSlots[0], "value");
lines.push(`Value: ${valueFmt(cell.value)}`);
const theme = chart._resolveTheme();
renderCanvasTooltip(
chart._chromeCanvas,
pos,
lines,
layout,
theme,
chart._glManager?.dpr ?? 1,
);
}

Some files were not shown because too many files have changed in this diff Show More