chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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
|
||||
```
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env node --max-old-space-size=8192
|
||||
|
||||
require("@perspective-dev/cli");
|
||||
@@ -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>
|
||||
Executable
+178
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
x,y
|
||||
1,2
|
||||
3,4
|
||||
5,6
|
||||
|
@@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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
|
||||
```
|
||||
@@ -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();
|
||||
@@ -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,
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"packageManager": "python",
|
||||
"packageName": "perspective-python",
|
||||
"uninstallInstructions": "Use your Python package manager (pip, conda, etc.) to uninstall the package perspective-python"
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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%;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"shortcuts": [
|
||||
{
|
||||
"command": "runmenu:run-all",
|
||||
"keys": [
|
||||
"R",
|
||||
"R"
|
||||
],
|
||||
"selector": "[data-jp-kernel-user]:focus"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, "..", "..") },
|
||||
);
|
||||
// });
|
||||
@@ -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" }],
|
||||
}),
|
||||
],
|
||||
};
|
||||
@@ -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();
|
||||
@@ -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 });
|
||||
@@ -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`;
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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} />;
|
||||
};
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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 });
|
||||
@@ -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 (H–L 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 (H–L, 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;
|
||||
|
||||
// H–L 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
Reference in New Issue
Block a user