chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:25:07 +08:00
commit a26e856398
1681 changed files with 296950 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
# Bloc.ks
Examples designed to be hosted on
[GitHub Pages](https://perspective-dev.github.io). See the individual projects
in the `src/` directory for more info.
+52
View File
@@ -0,0 +1,52 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 LOCAL_EXAMPLES = [
"editable",
"file",
"duckdb",
"fractal",
"market",
"raycasting",
"evictions",
"nypd",
"streaming",
"covid",
"webcam",
"movies",
"superstore",
"olympics",
"dataset",
];
exports.LOCAL_EXAMPLES = LOCAL_EXAMPLES;
exports.get_examples = function get_examples(
root = "https://perspective-dev.github.io/",
) {
const standalone = [
// {
// img: "https://perspective-dev.github.io/img/jupyterlab.png?",
// url: "http://beta.mybinder.org/v2/gh/perspective-dev/perspective/master?urlpath=lab/tree/examples/jupyter-notebooks",
// name: "jupyterlab",
// },
];
const hashes = LOCAL_EXAMPLES.map((x) => ({
img: `${root}blocks/${x}/preview.png?`,
url: `${root}block?example=${x}`,
name: x,
}));
return hashes.concat(standalone);
};
+115
View File
@@ -0,0 +1,115 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 "fs";
import { get_examples, LOCAL_EXAMPLES } from "./examples.js";
import * as url from "url";
import * as path from "node:path";
import { execSync } from "child_process";
const version = JSON.parse(fs.readFileSync("./package.json")).version;
const __dirname = url.fileURLToPath(new URL(".", import.meta.url)).slice(0, -1);
// TODO jsdelivr has slightly different logic for trailing '/' that causes
// the wasm assets to not load correctly when using aliases, hence we must link
// directly to the assets.
const replacements = {
"/node_modules/": `https://cdn.jsdelivr.net/npm/`,
"@perspective-dev/client/dist/cdn/perspective.js": `@perspective-dev/client@${version}/dist/cdn/perspective.js`,
"@perspective-dev/viewer/dist/cdn/perspective-viewer.js": `@perspective-dev/viewer@${version}/dist/cdn/perspective-viewer.js`,
"@perspective-dev/viewer-datagrid/dist/cdn/perspective-viewer-datagrid.js": `@perspective-dev/viewer-datagrid@${version}/dist/cdn/perspective-viewer-datagrid.js`,
"@perspective-dev/viewer-charts/dist/cdn/perspective-viewer-charts.js": `@perspective-dev/viewer-charts@${version}/dist/cdn/perspective-viewer-charts.js`,
"@perspective-dev/workspace/dist/cdn/perspective-workspace.js": `@perspective-dev/workspace@${version}/dist/cdn/perspective-workspace.js`,
"@perspective-dev/server/dist/cdn/perspective.cpp.wasm": `@perspective-dev/client@${version}/dist/cdn/perspective.cpp.wasm`,
"@perspective-dev/viewer/dist/cdn/perspective.rx.wasm": `@perspective-dev/viewer@${version}/dist/cdn/perspective.rx.wasm`,
"@perspective-dev/client/dist/cdn/perspective.worker.js": `@perspective-dev/client@${version}/dist/cdn/perspective.worker.js`,
};
export async function dist_examples(
outpath = `${__dirname}/../../docs/static/blocks`,
) {
execSync(`mkdir -p ${outpath}`, { stdio: "inherit" });
const readme = generate_readme();
let existing = fs.readFileSync(`${__dirname}/../../README.md`).toString();
existing = existing.replace(
/<\!\-\- Examples \-\->([\s\S]+?)<\!\-\- Examples \-\->/gm,
`<!-- Examples -->\n${readme}\n<!-- Examples -->`,
);
fs.writeFileSync(`${__dirname}/../../README.md`, existing);
for (const name of LOCAL_EXAMPLES) {
// Copy
if (fs.existsSync(`${__dirname}/src/${name}`)) {
// Copy
for (const filename of fs.readdirSync(`${__dirname}/src/${name}`)) {
execSync(`mkdir -p ${outpath}/${name}`, { stdio: "inherit" });
if (
filename.endsWith(".mjs") ||
filename.endsWith(".js") ||
filename.endsWith(".html")
) {
let filecontents = fs
.readFileSync(`${__dirname}/src/${name}/${filename}`)
.toString();
for (const pattern of Object.keys(replacements)) {
filecontents = filecontents.replace(
new RegExp(pattern, "g"),
replacements[pattern],
);
}
fs.writeFileSync(
`${outpath}/${name}/${filename}`,
filecontents,
);
} else if (filename !== ".git") {
execSync(
`cp ${__dirname}/src/${name}/${filename} ${outpath}/${name}/${filename}`,
{ stdio: "inherit" },
);
}
}
// build
if (fs.existsSync(path.join(outpath, name, "build.mjs"))) {
console.log("Building " + name);
const script = `${outpath}/${name}/build.mjs`;
execSync(`node ${script}`, { stdio: "inherit" });
}
}
}
}
function partition(input, spacing) {
let output = [];
for (let i = 0; i < input.length; i += spacing) {
output[output.length] = input.slice(i, i + spacing);
}
return output;
}
function generate_readme() {
const all = get_examples();
return `<table><tbody>${partition(all, 3)
.map(
(row) =>
`<tr>${row
.map((y) => `<td>${y.name}</td>`)
.join("")}</tr><tr>${row
.map(
(y) =>
`<td><a href="${y.url}"><img height="125" src="${y.img}" /></a></td>`,
)
.join("")}</tr>`,
)
.join("")}</tbody></table>`;
}
+26
View File
@@ -0,0 +1,26 @@
{
"name": "blocks",
"private": true,
"version": "4.5.2",
"description": "A collection of simple client-side Perspective examples for `http://bl.ocks.org`.",
"scripts": {
"start": "mkdir -p dist && node --experimental-modules server.mjs",
"repl": "node --experimental-repl-await"
},
"main": "index.mjs",
"keywords": [],
"license": "Apache-2.0",
"dependencies": {
"@perspective-dev/client": "workspace:",
"@perspective-dev/server": "workspace:",
"@perspective-dev/viewer": "workspace:",
"@perspective-dev/viewer-charts": "workspace:",
"@perspective-dev/viewer-datagrid": "workspace:",
"@perspective-dev/workspace": "workspace:",
"superstore-arrow": "catalog:"
},
"devDependencies": {
"@zip.js/zip.js": "catalog:",
"esbuild": "catalog:"
}
}
+77
View File
@@ -0,0 +1,77 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 fs from "fs";
import { WebSocketServer } from "@perspective-dev/client";
import { dist_examples } from "./index.mjs";
import { fileURLToPath } from "url";
import { dirname } from "path";
const __dirname = dirname(fileURLToPath(import.meta.url));
await dist_examples(`${__dirname}/dist`);
const template = (body) => `
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=no">
</head>
<body>
<ul id="list">
${body}
</ul>
</body>
</html>
`;
const prod_template = (name) => `
<li>
<a href="src/${name}/index.html">local</a>
<a href="dist/${name}/index.html">dist</a>
${name}
</li>
`;
const dev_template = (name) => `
<li>
<a href="src/${name}/index.html">local</a>
${name}
</li>
`;
const gists = [
"duckdb",
"fractal",
"raycasting",
"evictions",
"streaming",
"covid",
"superstore",
"editable",
"file",
"webcam",
];
const lis = [];
for (const key of fs.readdirSync("src")) {
if (gists.indexOf(key) >= 0) {
lis.push(prod_template(key));
} else {
lis.push(dev_template(key));
}
}
fs.writeFileSync("dist/index.html", template(lis.join("\n")));
new WebSocketServer({ assets: [__dirname, "dist", "../../"] });
+1
View File
@@ -0,0 +1 @@
license: apache-2.0
+16
View File
@@ -0,0 +1,16 @@
Demo of [Perspective](https://github.com/perspective-dev/perspective), using US
COVID data from [https://covidtracking.com](https://covidtracking.com/).
The `Group By` column is a good example of using Perspective's ExprTK column
language to clean/reformat a column. The raw "date" column, as imported from
[https://covidtracking.com](https://covidtracking.com/), use the date format
`YYYYMMDD`. Using this expression, we can parse this format into a well-typed
`date()` column, then bucket this by week to get a sensible graph:
```
// Parsed \"date\" bucket by week
var year := integer(floor(\"date\" / 10000));
var month := integer(floor(\"date\" / 100)) - year * 100;
var day := integer(\"date\" % 100);
bucket(date(year, month, day), 'W')
```
+62
View File
@@ -0,0 +1,62 @@
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,minimum-scale=1,user-scalable=no" />
<link rel="preload" href="/node_modules/@perspective-dev/server/dist/wasm/perspective-server.wasm" as="fetch" type="application/wasm" crossorigin="anonymous" />
<link rel="preload" href="/node_modules/@perspective-dev/viewer/dist/wasm/perspective-viewer.wasm" as="fetch" type="application/wasm" crossorigin="anonymous" />
<link rel="stylesheet" crossorigin="anonymous" href="/node_modules/@perspective-dev/viewer/dist/css/themes.css" />
<script type="module">
import "/node_modules/@perspective-dev/viewer/dist/cdn/perspective-viewer.js";
import "/node_modules/@perspective-dev/viewer-datagrid/dist/cdn/perspective-viewer-datagrid.js";
import "/node_modules/@perspective-dev/viewer-charts/dist/cdn/perspective-viewer-charts.js";
import { worker } from "/node_modules/@perspective-dev/client/dist/cdn/perspective.js";
const WORKER = await worker();
const REQ = fetch("https://api.covidtracking.com/v1/states/daily.csv");
const LAYOUT = {
plugin: "Y Area",
plugin_config: {
legend: {
height: "106px",
left: "100px",
top: "25px",
width: "",
},
},
settings: true,
group_by: ['Parsed "date" bucket by week'],
split_by: ["state"],
columns: ["deathIncrease"],
filter: [],
sort: [["deathIncrease", "col desc"]],
expressions: {
[`Parsed "date" bucket by week`]: `var year := integer(floor("date" / 10000));
var month := integer(floor("date" / 100)) - year * 100;
var day := integer("date" % 100);
bucket(date(year, month, day), \'W\')`,
},
aggregates: {},
};
const resp = await REQ;
const csv = await resp.text();
const el = document.getElementsByTagName("perspective-viewer")[0];
const table = WORKER.table(csv);
el.load(table);
el.restore(LAYOUT);
</script>
<style>
perspective-viewer {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
</style>
</head>
<body>
<perspective-viewer editable> </perspective-viewer>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

+3
View File
@@ -0,0 +1,3 @@
A random dataset generator and system monitor based on the
<a href="https://docs.rs/perspective-client/latest/perspective_client/struct.Client.html#method.system_info">perspective_client::Client::system_info</a>
method.
+230
View File
@@ -0,0 +1,230 @@
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,minimum-scale=1,user-scalable=no" />
<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" />
<style>
perspective-workspace {
flex: 1;
overflow: visible;
border: 1px solid #666;
overflow: hidden;
}
body {
overflow: hidden;
}
#app {
display: flex;
flex-direction: column;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #f2f4f6;
}
#controls {
display: flex;
padding: 12px;
gap: 12px;
}
.range {
position: relative;
display: inline-flex;
flex-direction: column;
gap: 6px;
}
span,
input,
button {
font-family: "ui-monospace", "SFMono-Regular", "SF Mono", "Menlo", "Consolas", "Liberation Mono", monospace;
}
span {
font-size: 10px;
}
</style>
</head>
<body>
<div id="app">
<div id="controls">
<button id="run">Generate</button>
<button id="del">Delete</button>
<div class="range">
<span>Rows</span>
<input id="num_rows" min="25" max="1000000" type="number" placeholder="#" value="100000" />
<span>Update batches</span>
<input id="num_batches" min="1" max="100" type="number" placeholder="#" value="1000" />
<span>Update delay (ms)</span>
<input id="batch_delay" min="100" max="10000" type="number" placeholder="#" value="200" />
</div>
<div class="range">
<span>Float columns</span>
<input id="num_float" min="0" max="50" type="number" placeholder="#" value="5" />
</div>
<div class="range">
<span>Integer columns</span>
<input id="num_integer" min="0" max="50" type="number" placeholder="#" value="5" />
</div>
<div class="range">
<span>String columns</span>
<input id="num_string" min="0" max="50" type="number" placeholder="#" value="5" />
<span>Dictionary size</span>
<input id="num_strings" min="1" max="500" type="number" placeholder="Unique Strings" value="50" />
</div>
<div class="range">
<span>Datetime columns</span>
<input id="num_datetime" min="0" max="50" type="number" placeholder="#" value="5" />
</div>
<div class="range">
<span>Bool columns</span>
<input id="num_boolean" min="0" max="50" type="number" placeholder="#" value="1" />
</div>
</div>
<perspective-workspace id="psp_workspace">
<!-- <perspective-viewer editable id="viewer"></perspective-viewer>
<perspective-viewer editable id="stats"></perspective-viewer> -->
</perspective-workspace>
</div>
<script type="module">
import "/node_modules/@perspective-dev/viewer-datagrid/dist/cdn/perspective-viewer-datagrid.js";
import "/node_modules/@perspective-dev/viewer-charts/dist/cdn/perspective-viewer-charts.js";
import "/node_modules/@perspective-dev/viewer/dist/cdn/perspective-viewer.js";
import "/node_modules/@perspective-dev/workspace/dist/cdn/perspective-workspace.js";
import perspective from "/node_modules/@perspective-dev/client/dist/cdn/perspective.js";
const client = await perspective.worker();
const choose = (x) => x[Math.floor(Math.random() * x.length)];
const range = (x, y) => Math.random() * (y - x) + x;
const rand_string = () => Math.random().toString(36).substring(7);
const strings = (choices) => new Array(choices).fill(null).map(rand_string);
const colname = (name, x) => `${name.charAt(0).toUpperCase() + name.slice(1)} ${x}`;
function* col_iter() {
for (const name of ["float", "integer", "string", "datetime", "boolean"]) {
const ncols = window[`num_${name}`].value;
for (let x = 0; x < ncols; x++) {
yield [name, x];
}
}
}
const assign = (fn) => {
const obj = {};
for (const [name, x] of col_iter()) {
obj[colname(name, x)] = fn(name, x);
}
return obj;
};
const new_schema = () => assign((x) => x);
let string_cache;
function reset_strings_cache() {
string_cache = {};
}
reset_strings_cache();
const get_dict = (xs) => {
string_cache[xs] = string_cache[xs] || strings(parseInt(num_strings.value));
return string_cache[xs];
};
const cell_args = {
float: () => range(-10, 10),
integer: () => Math.floor(range(-10, 10)),
string: (xs) => choose(get_dict(xs)),
datetime: () => new Date(),
boolean: () => choose([true, false, null]),
};
const new_row = () => assign((name, x) => cell_args[name](x));
const gen_data = async () => {
reset_strings_cache();
let nrows = num_rows.value;
let rows = [];
const batch_size = Math.floor(nrows / num_batches.value);
const batch_freq = batch_delay.value;
const tbl = await client.table(new_schema(), { name: "superstore" });
(function batch() {
while (nrows > 0) {
rows.push(new_row());
nrows--;
if (nrows % batch_size === 0) {
tbl.update(rows);
rows = [];
setTimeout(batch, batch_freq);
break;
}
}
})();
return tbl;
};
// GUI
const make_run_click_callback = (state) => async () => {
state.table?.delete?.({ lazy: true });
state.table = gen_data();
// await window.psp_workspace.addTable("superstore", state.table);
};
const make_del_click_callback = (state) => async () => {
if (state.table) {
// await viewer.eject();
// await window.psp_workspace.removeTable("superstore");
for (const viewer of window.psp_workspace.children) {
const cfg = await viewer.save();
if (cfg.table === "superstore") {
await viewer.eject();
}
}
await state.table.then((x) => x.delete({ lazy: true }));
state.table = undefined;
}
};
const state = {};
// Main
run.addEventListener("click", make_run_click_callback(state));
del.addEventListener("click", make_del_click_callback(state));
run.dispatchEvent(new Event("click"));
const stats_table = await client.table(
{
heap_size: "float",
used_size: "float",
cpu_time: "integer",
cpu_time_epoch: "integer",
version: "integer",
timestamp: "datetime",
client_used: "float",
client_heap: "float",
},
{
limit: 2000,
name: "stats",
},
);
(async function check_mem_loop() {
await stats_table.update([await client.system_info()]);
setTimeout(check_mem_loop, 200);
})();
await window.psp_workspace.load(client);
await window.psp_workspace.restore(await fetch("layout.json").then((x) => x.json()));
</script>
</body>
</html>
+103
View File
@@ -0,0 +1,103 @@
{
"sizes": [1],
"detail": {
"main": {
"type": "split-area",
"orientation": "vertical",
"children": [
{
"type": "tab-area",
"widgets": ["PERSPECTIVE_GENERATED_ID_0"],
"currentIndex": 0
},
{
"type": "split-area",
"orientation": "horizontal",
"children": [
{
"type": "tab-area",
"widgets": ["PERSPECTIVE_GENERATED_ID_1"],
"currentIndex": 0
},
{
"type": "tab-area",
"widgets": ["PERSPECTIVE_GENERATED_ID_2"],
"currentIndex": 0
}
],
"sizes": [0.5, 0.5]
}
],
"sizes": [0.5, 0.5]
}
},
"viewers": {
"PERSPECTIVE_GENERATED_ID_0": {
"version": "4.5.2",
"plugin": "Datagrid",
"plugin_config": {
"columns": {},
"edit_mode": "READ_ONLY",
"scroll_lock": false
},
"columns_config": {},
"title": "Generated Data",
"group_by": ["String 0", "String 1"],
"split_by": ["String 2"],
"sort": [],
"filter": [],
"expressions": {},
"columns": [
"Float 2",
"Float 3",
"Float 4",
"Integer 0",
"Integer 1",
"Datetime 4",
"Boolean 0"
],
"aggregates": {
"Boolean 0": "dominant",
"Datetime 4": "dominant"
},
"table": "superstore",
"settings": false
},
"PERSPECTIVE_GENERATED_ID_1": {
"version": "4.5.2",
"plugin": "Y Area",
"plugin_config": {},
"columns_config": {},
"table": "stats",
"title": "Server Memory",
"group_by": ["timestamp"],
"split_by": [],
"sort": [],
"filter": [],
"expressions": {
"Free (mb)": "\"heap_size\" / 1024 / 1024",
"Heap (mb)": "\"used_size\" / 1024 / 1024"
},
"columns": ["Free (mb)", "Heap (mb)"],
"aggregates": {}
},
"PERSPECTIVE_GENERATED_ID_2": {
"version": "4.5.2",
"plugin": "Y Line",
"plugin_config": {},
"columns_config": {},
"title": "Server CPU",
"group_by": ["timestamp"],
"split_by": [],
"sort": [],
"filter": [],
"expressions": {
"CPU (%)": "\"cpu_time\" / \"cpu_time_epoch\""
},
"columns": ["CPU (%)"],
"aggregates": {},
"table": "stats",
"settings": false
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+15
View File
@@ -0,0 +1,15 @@
An example of [Perspective](https://github.com/perspective-dev/perspective)
using [DuckDB WASM](https://duckdb.org/docs/api/wasm/overview) as a virtual
server backend via the `DuckDBHandler` adapter.
Instead of using Perspective's built-in WebAssembly query engine, this example
demonstrates how to use DuckDB as the data processing layer while still
leveraging Perspective's visualization components. The `DuckDBHandler` translates
Perspective's view configuration (group by, split by, sort, filter, expressions,
aggregates) into DuckDB SQL queries, enabling Perspective to query data stored
in DuckDB tables.
This example loads the Superstore sample dataset into a DuckDB table, then
creates a Perspective viewer that queries the data through the DuckDB virtual
server. A separate log viewer displays the SQL queries being generated in
real-time, along with a timeline chart showing query frequency.
+40
View File
@@ -0,0 +1,40 @@
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,minimum-scale=1,user-scalable=no" />
<link rel="preload" href="/node_modules/@perspective-dev/server/dist/wasm/perspective-server.wasm" as="fetch" type="application/wasm" crossorigin="anonymous" />
<link rel="preload" href="/node_modules/@perspective-dev/viewer/dist/wasm/perspective-viewer.wasm" as="fetch" type="application/wasm" crossorigin="anonymous" />
<link rel="preload" href="/node_modules/superstore-arrow/superstore.lz4.arrow" as="fetch" type="arraybuffer" crossorigin="anonymous" />
<link rel="stylesheet" crossorigin="anonymous" href="/node_modules/@perspective-dev/viewer/dist/css/themes.css" />
<script type="module" src="index.js"></script>
<style>
#query {
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 30%;
}
#logger {
position: absolute;
top: 40%;
left: 70%;
bottom: 0%;
right: 0;
}
#logger2 {
position: absolute;
top: 0%;
left: 70%;
bottom: 60%;
right: 0;
}
</style>
</head>
<body>
<perspective-viewer id="query"></perspective-viewer>
<perspective-viewer id="logger"></perspective-viewer>
<perspective-viewer id="logger2"></perspective-viewer>
</body>
</html>
+99
View File
@@ -0,0 +1,99 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 "/node_modules/@perspective-dev/viewer/dist/cdn/perspective-viewer.js";
import "/node_modules/@perspective-dev/viewer-datagrid/dist/cdn/perspective-viewer-datagrid.js";
import "/node_modules/@perspective-dev/viewer-charts/dist/cdn/perspective-viewer-charts.js";
import perspective from "/node_modules/@perspective-dev/client/dist/cdn/perspective.js";
import { DuckDBHandler } from "/node_modules/@perspective-dev/client/dist/esm/virtual_servers/duckdb.js";
// Need to use jsDelivr's ESM features to load this as packaged.
import * as duckdb from "https://cdn.jsdelivr.net/npm/@duckdb/duckdb-wasm@1.33.1-dev18.0/+esm";
const LOGGER = {
log(entry) {
table2.update([{ timestamp: entry.timestamp, sql: entry.value }]);
},
};
const db = await initializeDuckDB();
const server = perspective.createMessageHandler(new DuckDBHandler(db));
const client = await perspective.worker(server);
const logworker = await perspective.worker();
const table2 = await logworker.table(
{ timestamp: "datetime", sql: "string" },
{ name: "logs", limit: 10_000 },
);
const log_element = document.querySelector("#logger");
log_element.load(logworker);
log_element.restore({
table: "logs",
sort: [["timestamp", "desc"]],
title: "SQL Log",
});
const log_element2 = document.querySelector("#logger2");
log_element2.load(logworker);
log_element2.restore({
table: "logs",
sort: [["timestamp", "desc"]],
columns: ["sql"],
group_by: ["1s"],
plugin: "Y Bar",
expressions: { "1s": `bucket("timestamp",'1s')` },
title: "SQL Timeline",
});
async function initializeDuckDB() {
const JSDELIVR_BUNDLES = duckdb.getJsDelivrBundles();
const bundle = await duckdb.selectBundle(JSDELIVR_BUNDLES);
const worker_url = URL.createObjectURL(
new Blob([`importScripts("${bundle.mainWorker}");`], {
type: "text/javascript",
}),
);
const duckdb_worker = new Worker(worker_url);
const db = new duckdb.AsyncDuckDB(LOGGER, duckdb_worker);
await db.instantiate(bundle.mainModule, bundle.pthreadWorker);
URL.revokeObjectURL(worker_url);
const conn = await db.connect();
return conn;
}
async function loadSampleData(db) {
const response = await fetch(
"/node_modules/superstore-arrow/superstore.lz4.arrow",
);
const text = await response.arrayBuffer();
await db.insertArrowFromIPCStream(new Uint8Array(text), {
name: "data_source_one",
create: true,
});
}
await loadSampleData(db);
const viewer = document.querySelector("#query");
viewer.load(client);
viewer.restore({
table: "memory.data_source_one",
group_by: ["Region", "State", "City"],
columns: ["Sales", "Profit", "Quantity", "Discount"],
plugin: "Datagrid",
theme: "Pro Dark",
settings: true,
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 411 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

+1
View File
@@ -0,0 +1 @@
license: apache-2.0
+6
View File
@@ -0,0 +1,6 @@
A simple example of
[Perspective](https://github.com/perspective-dev/perspective) with superstore
data, and editability enabled (not default but easy to toggle at runtime). This
example has no server component, and the edits occur only within the browser
session; refreshing the page will forget any edits and revert to the original
dataset.
+37
View File
@@ -0,0 +1,37 @@
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,minimum-scale=1,user-scalable=no" />
<link rel="preload" href="/node_modules/@perspective-dev/server/dist/wasm/perspective-server.wasm" as="fetch" type="application/wasm" crossorigin="anonymous" />
<link rel="preload" href="/node_modules/@perspective-dev/viewer/dist/wasm/perspective-viewer.wasm" as="fetch" type="application/wasm" crossorigin="anonymous" />
<link rel="preload" href="/node_modules/superstore-arrow/superstore.lz4.arrow" as="fetch" type="arraybuffer" crossorigin="anonymous" />
<link rel="stylesheet" crossorigin="anonymous" href="/node_modules/@perspective-dev/viewer/dist/css/themes.css" />
<script type="module">
import "/node_modules/@perspective-dev/viewer/dist/cdn/perspective-viewer.js";
import "/node_modules/@perspective-dev/viewer-datagrid/dist/cdn/perspective-viewer-datagrid.js";
import "/node_modules/@perspective-dev/viewer-charts/dist/cdn/perspective-viewer-charts.js";
import perspective from "/node_modules/@perspective-dev/client/dist/cdn/perspective.js";
const worker = await perspective.worker();
const resp = await fetch("/node_modules/superstore-arrow/superstore.lz4.arrow");
const arrow = await resp.arrayBuffer();
const viewer = document.getElementsByTagName("perspective-viewer")[0];
await worker.table(arrow, { name: "superstore" });
await viewer.load(worker);
await viewer.restore({ table: "superstore", settings: true, plugin_config: { edit_mode: "EDIT" } });
</script>
<style>
perspective-viewer {
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
}
</style>
</head>
<body>
<perspective-viewer></perspective-viewer>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

+1
View File
@@ -0,0 +1 @@
license: apache-2.0
+7
View File
@@ -0,0 +1,7 @@
Demo of [Perspective](https://github.com/perspective-dev/perspective), using SF
eviciton data from 1997-present provided by
[DataSF](https://data.sfgov.org/Housing-and-Buildings/Eviction-Notices/5cei-gny5).
This example has no server component, and connects directly to DataSF's public
API from your browser. These 44,311 rows are ~2.5mb gzipped (at time of
writing), but are cached in `LocalStorage` on subsequent page visits.
+84
View File
@@ -0,0 +1,84 @@
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,minimum-scale=1,user-scalable=no" />
<link rel="preload" href="/node_modules/@perspective-dev/viewer/dist/wasm/perspective-viewer.wasm" as="fetch" type="application/wasm" crossorigin="anonymous" />
<link rel="preload" href="/node_modules/@perspective-dev/server/dist/wasm/perspective-server.wasm" as="fetch" type="application/wasm" crossorigin="anonymous" />
<link rel="stylesheet" crossorigin="anonymous" href="/node_modules/@perspective-dev/viewer/dist/css/themes.css" />
<script src="https://cdn.jsdelivr.net/npm/fflate@0.7.3/umd/index.min.js"></script>
<script type="module">
import "/node_modules/@perspective-dev/viewer/dist/cdn/perspective-viewer.js";
import "/node_modules/@perspective-dev/viewer-datagrid/dist/cdn/perspective-viewer-datagrid.js";
import "/node_modules/@perspective-dev/viewer-charts/dist/cdn/perspective-viewer-charts.js";
import { worker } from "/node_modules/@perspective-dev/client/dist/cdn/perspective.js";
const WORKER = await worker();
const URL = `https://data.sfgov.org/resource/5cei-gny5.csv?$limit=50000`;
async function get_evictions() {
const arrow = localStorage.getItem(URL);
if (arrow !== null) {
console.log("Using cached data from localStorage");
try {
const buffer = fflate.strToU8(arrow, true);
return await WORKER.table(fflate.decompressSync(buffer).buffer);
} catch (e) {
console.error("Can't load cached data", e);
localStorage.clear();
}
}
console.log("Downloading data from data.sfgov.org");
const resp = await fetch(
URL,
// "5cei-gny5.csv"
// "evictions.all.arrow"
);
const csv = await resp.text();
const table = await WORKER.table(csv);
(async () => {
console.log("Caching data");
const view = await table.view();
const arrow = await view.to_arrow();
try {
const x = fflate.compressSync(new Uint8Array(arrow));
const zipped = fflate.strFromU8(x, true);
localStorage.setItem(URL, zipped);
} catch (e) {
console.error("Can't cache data from data.sfgov.org", e);
}
})();
return table;
}
async function load() {
const el = document.getElementsByTagName("perspective-viewer")[0];
const evictions = get_evictions();
const layout_json = await fetch("layout.json");
const layout = await layout_json.json();
el.load(await evictions);
el.restore(layout);
}
load();
</script>
<style>
perspective-viewer {
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
}
</style>
</head>
<body>
<perspective-viewer editable> </perspective-viewer>
</body>
</html>
+23
View File
@@ -0,0 +1,23 @@
{
"plugin": "Map Scatter",
"plugin_config": {},
"settings": true,
"theme": "Pro Dark",
"group_by": ["bucket lon", "bucket lat"],
"split_by": ["neighborhood"],
"columns": ["lon", "lat", null, null, null],
"filter": [["lon", "is not null", null]],
"sort": [],
"expressions": {
"lon": "var x[2];\nindexof(\"shape\", ' .+?( )', x);\nvar y := substring(\"shape\", 7, x[1] - 7);\nfloat(y)",
"lat": "var x[2];\nindexof(\"shape\", ' .+?( )', x);\nvar y := substring(\"shape\", x[0], length(\"shape\") - x[1]);\nfloat(y)",
"bucket lon": "var x[2];\nindexof(\"shape\", ' .+?( )', x);\nvar y := substring(\"shape\", 7, x[1] - 7);\nbucket(float(y), 0.0025) + 0.00125",
"bucket lat": "var x[2];\nindexof(\"shape\", ' .+?( )', x);\nvar y := substring(\"shape\", x[0], length(\"shape\") - x[1]);\nbucket(float(y), 0.0025) + 0.00125"
},
"aggregates": {
"lon": "avg",
"lat": "avg",
"bucket lon": "avg",
"bucket lat": "avg"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

+1
View File
@@ -0,0 +1 @@
license: apache-2.0
+13
View File
@@ -0,0 +1,13 @@
A simple file loader demo of
[Perspective](https://github.com/perspective-dev/perspective). The given
CSV/Feather must be in a form Perspective's `Table()` constructor supports:
- First row must be column names.
- Subsequent rows should be mono-typed (but if not you will just get `string`
columns and you'll need to use ExprTK expressions to cast).
- Well-formed (Consistent # of `,` per line, strings should be quoted,
especially if they contain `,` themselves).
This example does not have a server component, all
calculation/loading/manipulation of the data is done in the browser, and the
data "uploaded" never leaves your browser/computer.
+108
View File
@@ -0,0 +1,108 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 "/node_modules/@perspective-dev/viewer/dist/cdn/perspective-viewer.js";
import "/node_modules/@perspective-dev/viewer-datagrid/dist/cdn/perspective-viewer-datagrid.js";
import "/node_modules/@perspective-dev/viewer-charts/dist/cdn/perspective-viewer-charts.js";
import perspective from "/node_modules/@perspective-dev/client/dist/cdn/perspective.js";
const worker = await perspective.worker();
// Get `dropArea` element from the DOM.
var dropArea = document.getElementById("drop-area");
// Get `input` element from the DOM.
var input = document.getElementById("fileElem");
// Add event listeners to `dropArea`.
dropArea.addEventListener("dragenter", () => {}, false);
dropArea.addEventListener("dragleave", () => {}, false);
dropArea.addEventListener("dragover", () => {}, false);
dropArea.addEventListener("drop", (x) => console.log(x), false);
// Prevent defaults for drag / drop events.
["dragenter", "dragover", "dragleave", "drop"].forEach((eventName) => {
dropArea.addEventListener(eventName, preventDefaults, false);
});
function preventDefaults(e) {
e.preventDefault();
e.stopPropagation();
}
// Highlight `dropArea` on drag enter and over.
["dragenter", "dragover"].forEach(function (eventName) {
dropArea.addEventListener(eventName, highlight, false);
});
// Remove highlight `dropArea` on drag leave and drop.
["dragleave", "drop"].forEach(function (eventName) {
dropArea.addEventListener(eventName, unhighlight, false);
});
// Add class for highlighting drop area.
function highlight() {
dropArea.classList.add("highlight");
}
// Remove class for highlighting drop area.
function unhighlight() {
dropArea.classList.remove("highlight");
}
// Add event listener for drop.
dropArea.addEventListener("drop", handleDrop, false);
// Add event listener for file change on `input`.
input.addEventListener("change", function () {
handleFiles(this.files);
});
// Handle files attached to the drop.
function handleDrop(e) {
let dt = e.dataTransfer;
let files = dt.files;
handleFiles(files);
}
// Iterate over files and call upload on each.
function handleFiles(files) {
[...files].forEach(uploadFile);
}
// On file load, remove the `dropArea` and replace it with a `<perspective-viewer>`.
function uploadFile(file) {
let reader = new FileReader();
reader.onload = function (fileLoadedEvent) {
let data = fileLoadedEvent.target.result;
// Remove the `dropArea` from the DOM.
const parent = dropArea.parentElement;
parent.removeChild(dropArea);
// Create a `<perspective-viewer>` and append it to the DOM.
const psp = document.createElement("perspective-viewer");
parent.appendChild(psp);
// Load the file data into `<perspective-viewer>`.
if (file.name.endsWith(".feather") || file.name.endsWith(".arrow")) {
psp.load(worker.table(data));
} else {
psp.load(worker.table(data, { format: "csv" }));
}
};
// Read the contents of the file - triggering the onload when finished.
reader.readAsArrayBuffer(file);
}
+71
View File
@@ -0,0 +1,71 @@
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
* ┃ 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). ┃
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
*/
#drop-area {
border: 5px dashed #ccc;
border-radius: 15px;
width: 480px;
font-family: sans-serif;
margin: 100px auto;
padding: 48px;
color: #666;
}
#drop-area.highlight {
border-color: cornflowerblue;
color: #000;
}
p {
margin-top: 0;
}
.my-form {
margin-bottom: 10px;
}
#gallery {
margin-top: 10px;
}
#gallery img {
width: 150px;
margin-bottom: 10px;
margin-right: 10px;
vertical-align: middle;
}
.button {
display: inline-block;
padding: 10px;
background: #ccc;
cursor: pointer;
border-radius: 5px;
}
.button:hover {
background: cornflowerblue;
color: white;
}
#fileElem {
display: none;
}
perspective-viewer {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
}
+21
View File
@@ -0,0 +1,21 @@
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,minimum-scale=1,user-scalable=no" />
<link rel="preload" href="/node_modules/@perspective-dev/viewer/dist/wasm/perspective-viewer.wasm" as="fetch" type="application/wasm" crossorigin="anonymous" />
<link rel="preload" href="/node_modules/@perspective-dev/server/dist/wasm/perspective-server.wasm" as="fetch" type="application/wasm" crossorigin="anonymous" />
<link rel="stylesheet" href="index.css" />
<link rel="stylesheet" crossorigin="anonymous" href="/node_modules/@perspective-dev/viewer/dist/css/themes.css" />
<script type="module" src="file.js"></script>
</head>
<body>
<div id="drop-area">
<form class="my-form">
<p>Upload a CSV/Arrow file by dragging from your desktop and dropping onto the dashed region.</p>
<p>(Data is processed in browser, and never sent to any server).</p>
<input type="file" id="fileElem" multiple accept=".feather,.arrow,.csv" />
<label class="button" for="fileElem">Select a file</label>
</form>
</div>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

+2
View File
@@ -0,0 +1,2 @@
license: apache-2.0
height: 800
+12
View File
@@ -0,0 +1,12 @@
A classic [fractal](https://en.wikipedia.org/wiki/Mandelbrot_set) implemented
entirely in
ExprTK/[Perspective](https://github.com/perspective-dev/perspective). This
example's dataset is just a single column, `"index"`, with 40,000 integers [0,
39999]. From this column, the columns `x` and `y` are derived forming a 200x200
grid, and finally the column `color` is calculated from the standard Mandelbrot
formula, using the complex point at `x + yi`. The controls at the top of the
page allow the programmatic redefinition of this column to e.g. modify the
Mandelbrot function's max iterations, window, resolution, etc.
This examples has no server component and the data is generated in-memory on
load. All calculation is done in the Perspective WebAssembly engine itself.
+224
View File
@@ -0,0 +1,224 @@
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
* ┃ 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). ┃
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
*/
perspective-viewer {
flex: 1;
overflow: visible;
margin-left: 60px;
}
perspective-viewer[theme="Pro Light"],
perspective-viewer[theme="Pro Dark"] {
--psp-charts--gradient--background: linear-gradient(
#000 0%,
#94d0ff 50%,
#8795e8,
#966bff,
#ad8cff,
#c774e8,
#c774a9,
#ff6ad5,
#ff6a8b,
#ff8b8b,
#ffa58b,
#ffde8b,
#cdde8b,
#8bde8b,
#20de8b
);
}
#app {
display: flex;
flex-direction: column;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #f2f4f6;
}
#controls {
display: flex;
/* padding: 12px; */
border-bottom: 1px solid #ababab;
}
#controls2 {
position: absolute;
left: 0;
width: 60px;
bottom: 0;
top: 40px;
}
.range {
position: relative;
display: inline-flex;
flex-direction: column;
margin-right: 24px;
}
.range.xl {
width: 200px;
}
.range.right {
float: right;
}
span,
input,
button {
font-family:
"ui-monospace", "SFMono-Regular", "SF Mono", "Menlo", "Consolas",
"Liberation Mono", monospace;
font-size: 12px;
background: none;
margin: 0px;
border-color: #ccc;
color: #666;
padding: 6px 12px 6px 0px;
}
span {
font-size: 10px;
}
input {
border-width: 0px;
border-style: solid;
border-bottom-width: 1px;
color: inherit;
outline: none;
padding: 0px;
}
input[type="number"] {
font-family:
"ui-monospace", "SFMono-Regular", "SF Mono", "Menlo", "Consolas",
"Liberation Mono", monospace;
}
input:focus {
border-color: #1a7da1;
}
input::placeholder {
color: #ccc;
}
button {
border: 0px solid #ccc;
border-right-width: 1px;
text-transform: uppercase;
text-align: center;
text-decoration: none;
display: inline-block;
padding-left: 12px;
height: 28px;
outline: none;
}
button:hover {
cursor: pointer;
}
#run {
justify-self: center;
margin-right: 24px;
height: 50px;
width: 60px;
}
#run:disabled {
/* opacity: 0.2; */
color: #ccc;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
pointer-events: all;
width: 6px;
height: 24px;
background-color: #fff;
border-radius: 0px;
box-shadow: 0 0 0 1px #c6c6c6;
cursor: pointer;
}
input[type="range"]::-moz-range-thumb {
-webkit-appearance: none;
appearance: none;
pointer-events: all;
width: 6px;
height: 24px;
background-color: #fff;
border-radius: 0px;
box-shadow: 0 0 0 1px #c6c6c6;
cursor: pointer;
}
input[type="range"]::-webkit-slider-thumb:hover {
background: #f7f7f7;
}
input[type="range"]::-webkit-slider-thumb:active {
box-shadow:
inset 0 0 3px #387bbe,
0 0 9px #387bbe;
-webkit-box-shadow:
inset 0 0 3px #387bbe,
0 0 9px #387bbe;
}
/*
input[type="number"] {
color: #8a8383;
width: 50px;
height: 30px;
font-size: 20px;
border: none;
} */
/*
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
opacity: 1;
} */
input[type="range"] {
-webkit-appearance: none;
appearance: none;
height: 2px;
width: 100%;
position: absolute;
background-color: #c6c6c6;
pointer-events: none;
padding: 0;
top: 25px;
width: 200px;
}
#ymin,
#xmin {
z-index: 1;
}
#ymin,
#ymax {
/* -webkit-appearance: slider-vertical; */
transform: rotate(-90deg);
transform-origin: top left;
top: 220px;
left: 30px;
}
+37
View File
@@ -0,0 +1,37 @@
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,minimum-scale=1,user-scalable=no" />
<link rel="preload" href="/node_modules/@perspective-dev/viewer/dist/wasm/perspective-viewer.wasm" as="fetch" type="application/wasm" crossorigin="anonymous" />
<link rel="preload" href="/node_modules/@perspective-dev/server/dist/wasm/perspective-server.wasm" as="fetch" type="application/wasm" crossorigin="anonymous" />
<link rel="stylesheet" crossorigin="anonymous" href="/node_modules/@perspective-dev/viewer/dist/css/themes.css" />
<link rel='stylesheet' href="index.css">
</head>
<body>
<div id="app">
<div id="controls">
<button id="run" disabled>Run</button>
<div class="range xl">
<input id="xmin" min="-2" max="1.0" step="0.1" value="-0.4" type="range"></input>
<input id="xmax" min="-2" max="1.0" step="0.1" value="-0.3" type="range"></input>
</div>
<div class="range right">
<span>Resolution</span>
<input id="resolution" min="25" max="700" type="number" placeholder="Resolution" value="200"></input>
</div>
<div class="range right">
<span>Iterations</span>
<input id="iterations" min="1" max="1000" type="number" placeholder="Iterations" value="100"></input>
</div>
</div>
<div id="controls2">
<div class="range xl">
<input id="ymin" min="-1" max="1.0" step="0.1" value="0.6" type="range" orient="vertical"></input>
<input id="ymax" min="-1" max="1.0" step="0.1" value="0.7" type="range" orient="vertical"></input>
</div>
</div>
<perspective-viewer id="viewer"></perspective-viewer>
</div>
<script type="module" src="index.js"></script>
</body>
</html>
+163
View File
@@ -0,0 +1,163 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 "/node_modules/@perspective-dev/viewer/dist/cdn/perspective-viewer.js";
import "/node_modules/@perspective-dev/viewer-datagrid/dist/cdn/perspective-viewer-datagrid.js";
import "/node_modules/@perspective-dev/viewer-charts/dist/cdn/perspective-viewer-charts.js";
import perspective from "/node_modules/@perspective-dev/client/dist/cdn/perspective.js";
function generate_mandelbrot(params) {
return `
// color
var resolution := ${params.resolution};
var xmin := ${params.xmin};
var xmax := ${params.xmax};
var ymin := ${params.ymin};
var ymax := ${params.ymax};
var iterations := ${params.iterations};
var x := floor("index" / resolution);
var y := "index" % resolution;
var c := iterations;
var cx := xmin + ((xmax - xmin) * x) / (resolution - 1);
var cy := ymin + ((ymax - ymin) * y) / (resolution - 1);
var vx := 0;
var vy := 0;
var vxx := 0;
var vyy := 0;
var vxy := 0;
for (var ii := 0; ii < iterations; ii += 1) {
if (vxx + vyy <= float(4)) {
vxy := vx * vy;
vxx := vx * vx;
vyy := vy * vy;
vx := vxx - vyy + cx;
vy := vxy + vxy + cy;
c -= 1;
}
};
c`;
}
function generate_layout(params) {
return {
plugin: "Heatmap",
table: "raw_data",
settings: true,
group_by: [`floor("index" / ${params.resolution})`],
split_by: [`"index" % ${params.resolution}`],
columns: ["color"],
expressions: {
color: generate_mandelbrot(params).trim(),
[`floor("index" / ${params.resolution})`]: `floor("index" / ${params.resolution})`,
[`"index" % ${params.resolution}`]: `"index" % ${params.resolution}`,
},
};
}
async function generate_data(table) {
const run = document.getElementById("run");
let json = new Array(Math.pow(resolution, 2));
for (let x = 0; x < resolution; ++x) {
for (let y = 0; y < resolution; ++y) {
const index = x * resolution + y;
json[index] = {
index,
};
}
}
await table.replace(json);
run.innerHTML = `Run`;
}
// GUI
function get_gui_params() {
return ["xmin", "xmax", "ymin", "ymax", "resolution", "iterations"].reduce(
(acc, x) => {
acc[x] = window[x].valueAsNumber;
return acc;
},
{},
);
}
function make_range(x, y, name) {
x.addEventListener("input", () => {
window.run.disabled = false;
x.value = Math.min(x.valueAsNumber, y.valueAsNumber - 0.1);
});
y.addEventListener("input", () => {
window.run.disabled = false;
y.value = Math.max(x.valueAsNumber + 0.1, y.valueAsNumber);
});
}
const make_run_click_callback = (worker, state) => async () => {
if (window.run.innerHTML.trim() !== "Run") {
window.run.innerHTML = "Run";
return;
}
window.run.disabled = true;
if (!state.table) {
state.table = await worker.table(
{
index: "integer",
},
{
name: "raw_data",
},
);
window.viewer.load(worker);
}
const run = document.getElementById("run");
const params = get_gui_params();
const new_size = Math.pow(params.resolution, 2);
if (!state.size || state.size !== new_size) {
let json = { index: new Array(new_size) };
for (let x = 0; x < new_size; ++x) {
json.index[x] = x;
}
state.table.replace(json);
}
state.size = new_size;
run.innerHTML = `Run`;
window.viewer.restore(generate_layout(params));
};
function set_runnable() {
window.run.disabled = false;
}
make_range(xmin, xmax, "X");
make_range(ymin, ymax, "Y");
window.resolution.addEventListener("input", set_runnable);
window.iterations.addEventListener("input", set_runnable);
run.addEventListener(
"click",
make_run_click_callback(await perspective.worker(), {}),
);
run.dispatchEvent(new Event("click"));
Binary file not shown.

After

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

+55
View File
@@ -0,0 +1,55 @@
A simple market simulation implemented in Perspective, designed to be simple
rather than accurate. The simulation proceeds in steps:
1) Insert a batch of random orders in a range around the market's bid/ask
spread. For each side of the order book, we can calculate the best open price
by querying the orders `Table` using the `"max"` or `"min"` aggregates
(respectively).
```json
{
columns: ["price"],
group_by: ["security"],
aggregates: { price: "max" },
filter: [
["side", "==", "buy"],
["status", "==", "open"],
],
}
```
1) Clear any matched orders in the `Table`. To match orders, we fetch all open
orders on both sides which are outside of the best price, then update an
_equal_ number of both `"buy"` and `"sell"` side orders to `"closed"`. The
`sort` field guarantees that orders are closed in best, then oldest, order.
```json
{
columns: ["id"],
filter: [
["side", "==", "buy"],
["status", "==", "open"],
["price", ">", price],
],
sort: [
["price", "desc"],
["timestamp", "asc"],
],
}
```
3) Expire any elapsed orders which are still `"open"` by fetching orders older
than the expiration ID and update thair status' to `"expired"`.
```json
{
columns: ["id"],
filter: [
["status", "==", "open"],
["id", "<", 12345],
],
}
```
4) Sleep for a bit and repeat (1).
+93
View File
@@ -0,0 +1,93 @@
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
* ┃ This file is part of the Perspective library, distributed under the terms ┃
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
*/
body {
background: #242526;
color: white;
font-family: "ui-monospace", "SFMono-Regular", "SF Mono", "Menlo",
"Consolas", "Liberation Mono", monospace;
touch-action: none;
}
* {
box-sizing: border-box;
}
#app {
display: flex;
flex-direction: column;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
#header {
display: flex;
flex-wrap: wrap;
align-items: center;
}
#header a {
display: inline-flex;
}
perspective-viewer {
border-top: 1px solid #666;
flex: 1 1 auto;
}
label {
height: 32px;
font-size: 12px;
padding: 6px 0px;
margin-right: 4px;
margin-left: 14px;
margin-top: 8px;
margin-bottom: 8px;
border: 1px solid transparent;
}
img {
vertical-align: middle;
margin-left: 14px;
}
select,
button {
font-family: "ui-monospace", "SFMono-Regular", "SF Mono", "Menlo",
"Consolas", "Liberation Mono", monospace;
font-size: 12px;
appearance: none;
background-color: transparent;
border: 1px solid #666;
border-radius: 2px;
padding: 6px 10px;
color: #f4f5f6;
cursor: pointer;
margin-right: 4px;
margin-left: 4px;
outline: none;
user-select: none;
height: 32px;
margin-top: 8px;
margin-bottom: 8px;
}
select:hover,
button:hover {
color: #242526;
background-color: #f4f5f6;
border-color: #f4f5f6;
}
+24
View File
@@ -0,0 +1,24 @@
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,minimum-scale=1,user-scalable=no" />
<link rel="preload" href="/node_modules/@perspective-dev/viewer/dist/wasm/perspective-viewer.wasm" as="fetch" type="application/wasm" crossorigin="anonymous" />
<link rel="preload" href="/node_modules/@perspective-dev/server/dist/wasm/perspective-server.wasm" as="fetch" type="application/wasm" crossorigin="anonymous" />
<link rel="stylesheet" href="index.css" />
<link rel="stylesheet" crossorigin="anonymous" href="/node_modules/@perspective-dev/viewer/dist/css/themes.css" />
<script type="module" src="market.js"></script>
</head>
<body>
<div id="app">
<div id="header">
<a href="https://perspective-dev.github.io">
<img height="12" src="https://raw.githubusercontent.com/perspective-dev/perspective/master/docs/static/svg/perspective-logo-dark.svg" />
</a>
<label>Market Simulation Demo</label>
<select></select>
<button>Reset</button>
</div>
<perspective-viewer theme="Pro Dark"></perspective-viewer>
</div>
</body>
</html>
+290
View File
@@ -0,0 +1,290 @@
[
{
"plugin": "Heatmap",
"plugin_config": {},
"title": "Heatmap",
"group_by": ["bucket(\"timestamp\", 'm')"],
"split_by": ["bucket(\"price\", 0.5)"],
"columns": ["if(\"side\"=='buy'){-1}else{1}"],
"filter": [],
"sort": [],
"expressions": {
"bucket(\"timestamp\", 'm')": "bucket(\"timestamp\", 'm')",
"bucket(\"price\", 0.5)": "bucket(\"price\", 0.5)",
"if(\"side\"=='buy'){-1}else{1}": "if(\"side\"=='buy'){-1}else{1}"
},
"aggregates": {}
},
{
"plugin": "Heatmap",
"plugin_config": {},
"title": "Heatmap (Closed)",
"group_by": ["bucket(\"timestamp\", 'm')"],
"split_by": ["bucket(\"price\", 0.25)"],
"columns": ["if(\"side\"=='buy'){-1}else{1}"],
"filter": [["status", "==", "closed"]],
"sort": [],
"expressions": {
"bucket(\"timestamp\", 'm')": "bucket(\"timestamp\", 'm')",
"bucket(\"price\", 0.25)": "bucket(\"price\", 0.25)",
"if(\"side\"=='buy'){-1}else{1}": "if(\"side\"=='buy'){-1}else{1}"
},
"aggregates": {}
},
{
"plugin": "Y Scatter",
"plugin_config": {},
"title": "Closed Orders ((s) bucket)",
"group_by": ["bucket(\"timestamp\", 's')"],
"split_by": ["if(\"status\"!='open'){\"status\"}else{\"side\"}"],
"columns": ["price"],
"filter": [],
"sort": [],
"expressions": {
"if(\"status\"=='closed'){1}else if(\"status\"=='expired'){2}else{0}": "if(\"status\"=='closed'){1}else if(\"status\"=='expired'){2}else{0}",
"if(\"status\"!='open'){\"status\"}else{\"side\"}": "if(\"status\"!='open'){\"status\"}else{\"side\"}",
"bucket(\"timestamp\", 's')": "bucket(\"timestamp\", 's')"
},
"aggregates": { "price": "avg", "timestamp": "dominant" }
},
{
"plugin": "X/Y Scatter",
"plugin_config": {},
"title": "Closed Orders",
"group_by": [],
"split_by": [],
"columns": [
"timestamp",
"price",
"if(\"status\"!='open'){\"status\"}else{\"side\"}"
],
"filter": [],
"sort": [],
"expressions": {
"if(\"status\"!='open'){\"status\"}else{\"side\"}": "if(\"status\"!='open'){\"status\"}else{\"side\"}",
"if(\"status\"=='closed'){1}else if(\"status\"=='expired'){2}else{0}": "if(\"status\"=='closed'){1}else if(\"status\"=='expired'){2}else{0}"
},
"aggregates": {}
},
{
"plugin": "Y Line",
"plugin_config": { "zoom": { "k": 1, "x": 1, "y": -1 } },
"title": "Mid Timeseries",
"group_by": ["bucket(\"timestamp\", 's')"],
"split_by": ["status", "side"],
"columns": ["price"],
"filter": [["status", "==", "closed"]],
"sort": [],
"expressions": {
"bucket(\"timestamp\", 's')": "bucket(\"timestamp\", 's')"
},
"aggregates": { "price": "avg" }
},
{
"plugin": "Y Bar",
"plugin_config": {},
"title": "Depth Timeseries",
"group_by": ["bucket(\"timestamp\", 'm')"],
"split_by": ["side", "status"],
"columns": ["if(\"side\"=='buy'){1}else{-1}"],
"filter": [],
"sort": [],
"expressions": {
"bucket(\"timestamp\", 'm')": "bucket(\"timestamp\", 'm')",
"if(\"side\"=='buy'){1}else{-1}": "if(\"side\"=='buy'){1}else{-1}",
"if(\"side\"=='sell'){-1}else{integer(null)}": "if(\"side\"=='sell'){-1}else{integer(null)}"
},
"aggregates": { "bucket(\"timestamp\", 'm')": "dominant" }
},
{
"version": "4.5.2",
"columns_config": {
"Diff from market open": {
"number_fg_mode": "bar",
"fg_gradient": 12.9
},
"if(\"side\"=='buy'){-1}else{1}": {
"number_fg_mode": "bar",
"fg_gradient": 8,
"aggregate_depth": 1
},
"status": {
"string_color_mode": "series",
"color": "#94aa27"
},
"side": {
"string_color_mode": "series"
}
},
"plugin": "Datagrid",
"plugin_config": {},
"settings": true,
"title": "Blotter",
"group_by": [],
"split_by": [],
"sort": [["timestamp", "desc"]],
"filter": [],
"expressions": {
"bucket(\"price\", 1)": "bucket(\"price\", 1)",
"bucket(\"timestamp\", 'm')": "bucket(\"timestamp\", 'm')",
"Diff from market open": "\"price\" - 20"
},
"columns": [
"timestamp",
"id",
"Diff from market open",
"price",
"status",
"side"
],
"aggregates": {}
},
{
"version": "4.5.2",
"columns_config": {
"if(\"side\"=='buy'){-1}else{1}": {
"aggregate_depth": 1,
"number_fg_mode": "bar",
"fg_gradient": 8
}
},
"plugin": "Datagrid",
"plugin_config": {},
"settings": true,
"title": "Order Book",
"group_by": ["bucket(\"price\", 0.5)"],
"split_by": ["side"],
"sort": [["bucket(\"price\", 0.5)", "desc"]],
"filter": [["status", "==", "open"]],
"expressions": {
"if(\"side\"=='buy'){-1}else{1}": "if(\"side\"=='buy'){-1}else{1}",
"bucket(\"price\", 0.5)": "bucket(\"price\", 0.5)"
},
"columns": ["if(\"side\"=='buy'){-1}else{1}"],
"aggregates": {}
},
{
"plugin": "X Bar",
"plugin_config": {},
"title": "Order Book (Chart)",
"group_by": ["bucket(\"price\", 0.5)"],
"split_by": ["side"],
"columns": ["if(\"side\"=='buy'){-1}else{1}"],
"filter": [["status", "==", "open"]],
"sort": [["bucket(\"price\", 0.5)", "desc"]],
"expressions": {
"if(\"side\"=='buy'){-1}else{1}": "if(\"side\"=='buy'){-1}else{1}",
"bucket(\"price\", 0.5)": "bucket(\"price\", 0.5)"
},
"aggregates": {}
},
{
"plugin": "Candlestick",
"plugin_config": {},
"title": "Candlestick",
"group_by": ["bucket(\"timestamp\", 'm')"],
"split_by": [],
"columns": ["price", null, "price 2", "price 3"],
"filter": [["status", "==", "closed"]],
"sort": [],
"expressions": {
"bucket(\"timestamp\", 'm')": "bucket(\"timestamp\", 'm')",
"price 2": "\"price\"",
"price 3": "\"price\""
},
"aggregates": {
"price": "avg",
"price 2": "high",
"price 3": "low"
}
},
{
"plugin": "Candlestick",
"plugin_config": {},
"title": "Candlestick 2",
"group_by": ["bucket(\"timestamp\", 'm')"],
"split_by": [],
"columns": ["price", null, null, null],
"filter": [["status", "==", "closed"]],
"sort": [],
"expressions": {
"bucket(\"timestamp\", 'm')": "bucket(\"timestamp\", 'm')",
"price 2": "\"price\"",
"price 3": "\"price\""
},
"aggregates": {
"price": "avg",
"price 2": "high",
"price 3": "low"
}
},
{
"plugin": "X Bar",
"plugin_config": {},
"title": "Order Book (All Status)",
"group_by": ["bucket(\"price\", 0.5)"],
"split_by": [],
"columns": [
"if(\"status\"=='open' and \"side\"=='buy'){1}else{float(null)}",
"if(\"status\"=='open' and \"side\"=='sell'){1}else{float(null)}",
"if(\"status\"=='closed'){1}else{0}"
],
"filter": [],
"sort": [["bucket(\"price\", 0.5)", "desc"]],
"expressions": {
"if(\"status\"=='open' and \"side\"=='buy'){1}else{float(null)}": "if(\"status\"=='open' and \"side\"=='buy'){1}else{float(null)}",
"bucket(\"timestamp\", 's')": "bucket(\"timestamp\", 's')",
"bucket(\"price\", 0.5)": "bucket(\"price\", 0.5)",
"if(\"status\"=='open'){\"price\"}else{float(null)}": "if(\"status\"=='open'){\"price\"}else{float(null)}",
"if(\"status\"=='closed'){1}else{0}": "if(\"status\"=='closed'){1}else{0}",
"if(\"status\"=='open' and \"side\"=='sell'){1}else{float(null)}": "if(\"status\"=='open' and \"side\"=='sell'){1}else{float(null)}"
},
"aggregates": {}
},
{
"plugin": "Y Scatter",
"plugin_config": {},
"title": "Order Book (Scatter All Status)",
"group_by": ["bucket(\"price\", 0.5)"],
"split_by": [],
"columns": [
"if(\"status\"=='open' and \"side\"=='buy'){1}else{float(null)}",
"if(\"status\"=='open' and \"side\"=='sell'){1}else{float(null)}",
"if(\"status\"=='closed'){1}else{0}"
],
"filter": [],
"sort": [],
"expressions": {
"if(\"status\"=='open' and \"side\"=='buy'){1}else{float(null)}": "if(\"status\"=='open' and \"side\"=='buy'){1}else{float(null)}",
"bucket(\"timestamp\", 's')": "bucket(\"timestamp\", 's')",
"bucket(\"price\", 0.5)": "bucket(\"price\", 0.5)",
"if(\"status\"=='open'){\"price\"}else{float(null)}": "if(\"status\"=='open'){\"price\"}else{float(null)}",
"if(\"status\"=='closed'){1}else{0}": "if(\"status\"=='closed'){1}else{0}",
"if(\"status\"=='open' and \"side\"=='sell'){1}else{float(null)}": "if(\"status\"=='open' and \"side\"=='sell'){1}else{float(null)}"
},
"aggregates": {}
},
{
"plugin": "X Bar",
"plugin_config": {},
"title": "Order Book (Stack All Status)",
"group_by": ["bucket(\"price\", 0.5)"],
"split_by": ["if(\"status\"=='closed'){'closed'}else{\"side\"}"],
"columns": ["price"],
"filter": [["status", "!=", "expired"]],
"sort": [
["bucket(\"price\", 0.5)", "desc"],
["status", "col asc"]
],
"expressions": {
"if(\"status\"=='open' and \"side\"=='buy'){1}else{float(null)}": "if(\"status\"=='open' and \"side\"=='buy'){1}else{float(null)}",
"bucket(\"timestamp\", 's')": "bucket(\"timestamp\", 's')",
"bucket(\"price\", 0.5)": "bucket(\"price\", 0.5)",
"if(\"status\"=='open'){\"price\"}else{float(null)}": "if(\"status\"=='open'){\"price\"}else{float(null)}",
"if(\"status\"=='closed'){1}else{0}": "if(\"status\"=='closed'){1}else{0}",
"if(\"status\"=='open' and \"side\"=='sell'){1}else{float(null)}": "if(\"status\"=='open' and \"side\"=='sell'){1}else{float(null)}",
"if(\"status\"=='closed'){'closed'}else{\"side\"}": "if(\"status\"=='closed'){'closed'}else{\"side\"}"
},
"aggregates": {}
}
]
+292
View File
@@ -0,0 +1,292 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 "/node_modules/@perspective-dev/viewer/dist/cdn/perspective-viewer.js";
import "/node_modules/@perspective-dev/viewer-datagrid/dist/cdn/perspective-viewer-datagrid.js";
import "/node_modules/@perspective-dev/viewer-charts/dist/cdn/perspective-viewer-charts.js";
import perspective from "/node_modules/@perspective-dev/client/dist/cdn/perspective.js";
const MSG_BATCH_TIMEOUT = 50;
const MSG_PER_BATCH = 10;
const MSG_TIME_DELTA = 20 * (100 / MSG_PER_BATCH);
const MARKET_OPEN_PRICE = 20;
const MARKET_MAX_TRADES = 20000;
const MARKET_MIN_TRADES = 5000;
const MARKET_OPEN = performance.now();
const TRADE_EXPIRATION = 10;
const SCHEMA = {
id: "integer",
side: "string",
security: "string",
price: "float",
timestamp: "datetime",
status: "string",
};
async function query_columns(table, config) {
const view = await table.view(config);
const columns = await view.to_columns();
await view.delete();
return columns;
}
class OrderBook {
constructor(table, side) {
this._memo = undefined;
this._side = side;
this._table = table;
this._price_view = table.view({
columns: ["price"],
group_by: ["security"],
aggregates: { price: side === "buy" ? "max" : "min" },
filter: [
["side", "==", side],
["status", "==", "open"],
],
});
}
async best_open_price() {
if (this._memo === undefined) {
const view = await this._price_view;
const { price } = await view.to_columns({ leaves_only: true });
this._memo = price.length === 0 ? MARKET_OPEN_PRICE : price[0];
}
return this._memo;
}
async matched_orders(price) {
const sort_dir = this._side === "buy" ? "desc" : "asc";
const op = this._side === "buy" ? ">" : "<";
return await query_columns(this._table, {
columns: ["id"],
filter: [
["side", "==", this._side],
["status", "==", "open"],
["price", op, price],
],
sort: [
["price", sort_dir],
["timestamp", "asc"],
],
});
}
reset() {
this._memo = undefined;
}
}
class Channel {
async consume() {
await new Promise((resolve) => {
this._resolve = resolve;
});
}
emit() {
if (this._resolve) {
this._resolve();
this._resolve = undefined;
}
}
}
class Market {
constructor(table, model) {
this._id = 1;
this._table = table;
this._model = model;
this._buy_book = new OrderBook(table, "buy");
this._sell_book = new OrderBook(table, "sell");
this._stop_signal = new Channel();
}
async stop() {
if (this._id < MARKET_MAX_TRADES) {
this._id = MARKET_MAX_TRADES;
await this._stop_signal.consume();
}
this._id = 1;
}
async poll(progress) {
await this._run_market_step();
while (this._id < MARKET_MIN_TRADES) {
await this._run_market_step(progress);
}
progress?.();
this._stop_signal.emit();
if (this._id < MARKET_MAX_TRADES) {
setTimeout(this.poll.bind(this), MSG_BATCH_TIMEOUT);
}
}
async _run_market_step(progress) {
if (await this._generate_trades()) {
await this._clear_trades();
await this._expire_trades();
}
this._buy_book.reset();
this._sell_book.reset();
progress?.(this._id / MARKET_MIN_TRADES);
}
async _generate_trades() {
const trades = [];
const timestamp = new Date(MARKET_OPEN + this._id * MSG_TIME_DELTA);
for (let i = 0; i < MSG_PER_BATCH; i++) {
const { side, discount } = this._model();
const book = side === "buy" ? this._sell_book : this._buy_book;
const best_open_price = await book.best_open_price();
trades.push({
security: "Prospective Co",
status: "open",
id: this._id++,
price: discount + best_open_price,
side,
timestamp,
});
}
if (trades.length > 0) {
await this._table.update(trades);
return true;
}
}
async _clear_trades() {
const sell_price = await this._sell_book.best_open_price();
const buy_price = await this._buy_book.best_open_price();
const { id: buys } = await this._buy_book.matched_orders(sell_price);
const { id: sells } = await this._sell_book.matched_orders(buy_price);
const num_clear = Math.min(buys.length, sells.length);
const status = Array(num_clear * 2).fill("closed");
const id = buys.slice(0, num_clear).concat(sells.slice(0, num_clear));
if (id.length > 0) {
await this._table.update({ status, id });
}
}
async _expire_trades() {
const expired = await query_columns(this._table, {
columns: ["id"],
filter: [
["status", "==", "open"],
["id", "<", this._id - MSG_PER_BATCH * TRADE_EXPIRATION],
],
});
if (expired.id.length > 0) {
expired.status = Array(expired.id.length).fill("expired");
await this._table.update(expired);
}
}
}
const SKEW_MODEL_OFFSET = 2;
const SKEW_MODEL_STDDEV = 2;
const SKEW_MODEL_SKEW = 0;
function random_skew_normal(bias) {
const u3 = Math.random(),
u2 = Math.random();
const R = Math.sqrt(-2.0 * Math.log(u3));
const O = 2.0 * Math.PI * u2;
const u0 = R * Math.cos(O);
const v = R * Math.sin(O);
if (SKEW_MODEL_SKEW === 0) {
return bias * SKEW_MODEL_OFFSET + SKEW_MODEL_STDDEV * u0;
} else {
const n = -bias * SKEW_MODEL_SKEW;
const s = n / Math.sqrt(1 + Math.pow(SKEW_MODEL_SKEW, 2));
const u1 = s * u0 + Math.sqrt(1 - s * s) * v;
const z = u0 >= 0 ? u1 : -u1;
return bias * SKEW_MODEL_OFFSET + SKEW_MODEL_STDDEV * z;
}
}
function skew_model() {
const [side, bias] = Math.random() > 0.5 ? ["buy", -1] : ["sell", 1];
const discount = random_skew_normal(bias);
return { side, discount };
}
function progress(x) {
const button = document.querySelector("button");
if (x !== undefined) {
const y = (x * 10).toFixed(0) * 10;
button.textContent = `Generating trades ${y}%`;
} else {
button.textContent = "Reset";
}
}
async function reset_tables(market, market_table, gui_table) {
await market.stop();
await gui_table.size();
await market_table.clear();
await gui_table.clear();
await market.poll(progress);
}
async function init_tables() {
const market_worker = await perspective.worker();
const gui_worker = await perspective.worker();
const market_table = await market_worker.table(SCHEMA, { index: "id" });
const market_view = await market_table.view();
const gui_table = await gui_worker.table(market_view, {
index: "id",
name: "gui",
});
return { market_table, gui_table, gui_worker };
}
async function init_layouts() {
const req = await fetch("layouts.json");
return await req.json();
}
const INIT_TASK = [init_tables(), init_layouts()];
const [{ market_table, gui_table, gui_worker }, layouts] =
await Promise.all(INIT_TASK);
const market = new Market(market_table, skew_model);
const settings = !/(iPad|iPhone|iPod)/g.test(navigator.userAgent);
const select = document.querySelector("select");
const button = document.querySelector("button");
const viewer = document.querySelector("perspective-viewer");
viewer.load(gui_worker);
viewer.restore({ theme: "Pro Dark", table: "gui", settings, ...layouts[0] });
await market.poll(progress);
for (const layout of layouts) {
const option = document.createElement("option");
option.value = layout.title;
option.textContent = layout.title;
select.appendChild(option);
}
button.addEventListener("click", () => {
reset_tables(market, market_table, gui_table);
});
select.addEventListener("change", async (event) => {
const layout = layouts.find((x) => x.title === event.target.value);
await viewer.restore(layout);
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

+2
View File
@@ -0,0 +1,2 @@
license: apache-2.0
height: 800
+11
View File
@@ -0,0 +1,11 @@
Demo of a [Perspective](https://github.com/perspective-dev/perspective)
visualizing a dataset of 3,201 films. Each row in the dataset is a film, with
dimensional data such as production studio, rating and genre, as well as
observations such as box-office gross.
This dataset is bootstrapped off the [Vega](https://vega.github.io/vega/)
[demo page](https://vega.github.io/editor/data/movies.json"), and has no server
component itself, showing how easy it is to use Perspective's client-side
architecture to get a fully-interactive BI experience, on top of
already-deployed web data artifacts without modification or intermediate
servers.
+77
View File
@@ -0,0 +1,77 @@
<html>
<head>
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,minimum-scale=1,user-scalable=no" />
<link rel="preload" href="/node_modules/@perspective-dev/server/dist/wasm/perspective-server.wasm" as="fetch" type="application/wasm" crossorigin="anonymous" />
<link rel="preload" href="/node_modules/@perspective-dev/viewer/dist/wasm/perspective-viewer.wasm" as="fetch" type="application/wasm" crossorigin="anonymous" />
<link rel="stylesheet" crossorigin="anonymous" href="/node_modules/@perspective-dev/viewer/dist/css/pro-dark.css" />
<link rel="stylesheet" crossorigin="anonymous" href="/node_modules/@perspective-dev/workspace/dist/css/pro-dark.css" />
<style>
perspective-workspace {
left: 0;
position: absolute;
top: 0;
bottom: 0;
right: 0;
}
body {
overflow: hidden;
}
perspective-viewer {
--d3fc-positive--gradient: linear-gradient(#94d0ff, #8795e8, #966bff, #ad8cff, #c774e8, #c774a9, #ff6ad5, #ff6a8b, #ff8b8b, #ffa58b, #ffde8b, #cdde8b, #8bde8b, #20de8b) !important;
--d3fc-negative--gradient: linear-gradient(#94d0ff, #8795e8, #966bff, #ad8cff, #c774e8, #c774a9, #ff6ad5, #ff6a8b, #ff8b8b, #ffa58b, #ffde8b, #cdde8b, #8bde8b, #20de8b) !important;
--d3fc-full--gradient: linear-gradient(#94d0ff, #8795e8, #966bff, #ad8cff, #c774e8, #c774a9, #ff6ad5, #ff6a8b, #ff8b8b, #ffa58b, #ffde8b, #cdde8b, #8bde8b, #20de8b) !important;
}
</style>
</head>
<body>
<perspective-workspace id="workspace"> </perspective-workspace>
<script type="module">
import "/node_modules/@perspective-dev/viewer/dist/cdn/perspective-viewer.js";
import "/node_modules/@perspective-dev/workspace/dist/cdn/perspective-workspace.js";
import "/node_modules/@perspective-dev/viewer-datagrid/dist/cdn/perspective-viewer-datagrid.js";
import "/node_modules/@perspective-dev/viewer-charts/dist/cdn/perspective-viewer-charts.js";
import perspective from "/node_modules/@perspective-dev/client/dist/cdn/perspective.js";
const SCHEMA = {
Title: "string",
"US Gross": "float",
"Worldwide Gross": "float",
"US DVD Sales": "float",
"Production Budget": "float",
"Release Date": "date",
"MPAA Rating": "string",
"Running Time min": "integer",
Distributor: "string",
Source: "string",
"Major Genre": "string",
"Creative Type": "string",
Director: "string",
"Rotten Tomatoes Rating": "integer",
"IMDB Rating": "float",
"IMDB Votes": "integer",
};
const MOVIES_URL = "https://vega.github.io/editor/data/movies.json";
(async () => {
const request = fetch(MOVIES_URL);
const worker = await perspective.worker();
const response = await request;
const json = await response.json();
for (const row of json) {
row["Release Date"] = row["Release Date"] ? new Date(row["Release Date"]) || null : null;
}
const table = await worker.table(SCHEMA, { name: "movies" });
table.update(json);
let req = await fetch("layout.json");
let layout = await req.json();
await window.workspace.load(worker);
await window.workspace.restore(layout);
})();
</script>
</body>
</html>
+133
View File
@@ -0,0 +1,133 @@
{
"sizes": [0.34555410083131466, 0.6544458991686853],
"detail": {
"main": {
"type": "tab-area",
"widgets": [
"PERSPECTIVE_GENERATED_ID_0",
"PERSPECTIVE_GENERATED_ID_1",
"PERSPECTIVE_GENERATED_ID_3"
],
"currentIndex": 0
}
},
"mode": "globalFilters",
"master": {
"widgets": ["PERSPECTIVE_GENERATED_ID_2"],
"sizes": [1]
},
"viewers": {
"PERSPECTIVE_GENERATED_ID_2": {
"plugin": "Datagrid",
"plugin_config": {
"columns": {
"IMDB Rating": {
"gradient": 8.1,
"neg_color": "#FF9485",
"number_color_mode": "gradient",
"pos_color": "#b87ff0"
},
"US Gross": {
"fg_gradient": 19729862602,
"number_fg_mode": "bar"
}
},
"editable": false,
"scroll_lock": true
},
"settings": false,
"theme": "Pro Dark",
"group_by": ["Distributor"],
"split_by": [],
"columns": ["US Gross"],
"filter": [],
"sort": [["US Gross", "desc"]],
"expressions": {},
"aggregates": {},
"master": true,
"name": null,
"table": "movies",
"linked": false,
"selectable": "true"
},
"PERSPECTIVE_GENERATED_ID_0": {
"plugin": "Heatmap",
"plugin_config": {},
"settings": false,
"theme": "Pro Dark",
"group_by": ["bucket(\"Rotten Tomatoes Rating\", 2.5)"],
"split_by": ["bucket(\"IMDB Rating\", 0.25)"],
"columns": ["US Gross"],
"filter": [["bucket(\"IMDB Rating\", 0.25)", "is not null", null]],
"sort": [],
"expressions": {
"bucket(\"IMDB Rating\", 0.25)": "bucket(\"IMDB Rating\", 0.25)",
"bucket(\"Rotten Tomatoes Rating\", 2.5)": "bucket(\"Rotten Tomatoes Rating\", 2.5)"
},
"aggregates": {
"bucket(\"Rotten Tomatoes Rating\", 2.5)": "avg"
},
"master": false,
"name": null,
"table": "movies",
"linked": false
},
"PERSPECTIVE_GENERATED_ID_1": {
"plugin": "Y Line",
"plugin_config": {
"legend": {
"left": "79px",
"top": "174px"
},
"splitMainValues": ["Rotten Tomatoes Rating"]
},
"settings": false,
"theme": "Pro Dark",
"group_by": ["bucket(\"Release Date\", 'Y')"],
"split_by": [],
"columns": [
"US Gross",
"Rotten Tomatoes Rating",
"Production Budget",
"Worldwide Gross",
"US DVD Sales"
],
"filter": [],
"sort": [],
"expressions": {
"bucket(\"Release Date\", 'Y')": "bucket(\"Release Date\", 'Y')"
},
"aggregates": {
"Rotten Tomatoes Rating": "avg"
},
"master": false,
"name": "Ratings vs Sales",
"table": "movies",
"linked": false
},
"PERSPECTIVE_GENERATED_ID_3": {
"plugin": "Y Area",
"plugin_config": {
"legend": {
"left": "91px",
"top": "25px"
}
},
"settings": false,
"theme": "Pro Dark",
"group_by": ["bucket(\"Release Date\", 'Y')"],
"split_by": ["Major Genre"],
"columns": ["US Gross"],
"filter": [],
"sort": [],
"expressions": {
"bucket(\"Release Date\", 'Y')": "bucket(\"Release Date\", 'Y')"
},
"aggregates": {},
"master": false,
"name": "US Gross by Genre",
"table": "movies",
"linked": false
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

+3
View File
@@ -0,0 +1,3 @@
Visualizations that utilize
[NYPD CCRB Data](https://github.com/new-york-civil-liberties-union/NYPD-Misconduct-Complaint-Database-Updated)
that was obtained via a FOIL Request.
+78
View File
@@ -0,0 +1,78 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
// Gets the updated data from the NYCLU and prepare it for Perspective.
import perspective from "@perspective-dev/client";
import { Uint8ArrayReader, ZipReader, TextWriter } from "@zip.js/zip.js";
import * as fs from "node:fs/promises";
import * as url from "url";
import * as fss from "node:fs";
const __dirname = url.fileURLToPath(new URL(".", import.meta.url));
let DATA_URL =
"https://rawcdn.githack.com/new-york-civil-liberties-union/NYPD-Misconduct-Complaint-Database-Updated/f6cea944b347c96eb26b76323013640dff4b3d00/CCRB%20Complaint%20Database%20Raw%2004.28.2023.zip?min=1";
async function fetch_progress(url) {
const response = await fetch(url);
const reader = response.body.getReader();
const contentLength = +response.headers.get("Content-Length");
let receivedLength = 0;
let chunks = [];
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
chunks.push(value);
receivedLength += value.length;
let pct = ((100 * receivedLength) / contentLength).toFixed(1);
console.log(`Downloading (${pct}%)`);
}
let chunksAll = new Uint8Array(receivedLength);
let position = 0;
for (let chunk of chunks) {
chunksAll.set(chunk, position);
position += chunk.length;
}
const zipFileReader = new Uint8ArrayReader(chunksAll);
const zipReader = new ZipReader(zipFileReader);
const entries = await zipReader.getEntries();
const csv = await entries[0].getData(new TextWriter(), {
onprogress: (p, t) => console.log(`(${p}b / ${t}b)`),
});
zipReader.close();
let t = await perspective.table(csv);
let view = await t.view();
return new Uint8Array(await view.to_arrow());
}
async function main() {
if (!fss.existsSync(`${__dirname}/nypdccrb.arrow`)) {
await fs.writeFile(
`${__dirname}/nypdccrb.arrow`,
await fetch_progress(DATA_URL),
);
console.log("Wrote nypdccrb.arrow");
}
}
main();
+14
View File
@@ -0,0 +1,14 @@
.dark button {
background-color: inherit;
color: white;
border: 1px solid rgba(255, 255, 255, 0.2);
}
.dark #buttons {
background-color: #242526;
}
.dark select {
background-color: #242526;
color: white;
}
+73
View File
@@ -0,0 +1,73 @@
perspective-viewer[theme="Pro Light"],
perspective-viewer[theme="Pro Dark"] {
--d3fc-positive--gradient: linear-gradient(#94d0ff,
#8795e8,
#966bff,
#ad8cff,
#c774e8,
#c774a9,
#ff6ad5,
#ff6a8b,
#ff8b8b,
#ffa58b,
#ffde8b,
#cdde8b,
#8bde8b,
#20de8b);
}
perspective-workspace {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
#buttons>* {
padding: 6px;
margin: 5px;
}
button {
background-color: inherit;
cursor: pointer;
}
button:hover {
border: 1px solid #00f !important;
}
body {
overflow: hidden;
}
#message {
font-family: monospace;
}
#buttons {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 36px;
}
#workspace {
height: auto;
position: absolute;
top: 36px;
right: 0;
bottom: 0;
left: 0;
}
a {
text-transform: uppercase;
font-size: 10px;
}
button, select {
border-radius: 5px;
}
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,minimum-scale=1,user-scalable=no" />
<link rel="preload" href="/node_modules/@perspective-dev/viewer/dist/wasm/perspective-viewer.wasm" as="fetch" type="application/wasm" crossorigin="anonymous" />
<link rel="preload" href="/node_modules/@perspective-dev/server/dist/wasm/perspective-server.wasm" as="fetch" type="application/wasm" crossorigin="anonymous" />
<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/viewer/dist/css/pro-dark.css" />
<link rel="stylesheet" href="index.css" />
<link rel="stylesheet" crossorigin="anonymous" href="./dark.css" />
<link rel="stylesheet" crossorigin="anonymous" href="./light.css" />
<script type="module" src="https://cdn.jsdelivr.net/npm/@zip.js/zip.js@2.6.81/+esm"></script>
<script type="module" src="index.js"></script>
</head>
<body></body>
</html>
+186
View File
@@ -0,0 +1,186 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 "/node_modules/@perspective-dev/viewer/dist/cdn/perspective-viewer.js";
import "/node_modules/@perspective-dev/workspace/dist/cdn/perspective-workspace.js";
import "/node_modules/@perspective-dev/viewer-datagrid/dist/cdn/perspective-viewer-datagrid.js";
import "/node_modules/@perspective-dev/viewer-charts/dist/cdn/perspective-viewer-charts.js";
import perspective from "/node_modules/@perspective-dev/client/dist/cdn/perspective.js";
let DATA_URL = "nypdccrb.arrow";
let LAYOUTS = localStorage.getItem("layouts")
? JSON.parse(localStorage.getItem("layouts"))
: undefined;
const worker = await perspective.worker();
const theme_style_node = document.createElement("style");
document.head.appendChild(theme_style_node);
let DARK_THEME;
let LIGHT_THEME;
function toggle_theme() {
if (theme_style_node.dataset.theme === "Pro Light") {
theme_style_node.textContent = DARK_THEME;
document.body.classList.add("dark");
window.theme.textContent = "Light Theme";
for (const view of document.querySelectorAll("perspective-viewer")) {
view.setAttribute("theme", "Pro Dark");
view.restyleElement();
}
theme_style_node.dataset.theme = "Pro Dark";
} else {
theme_style_node.textContent = LIGHT_THEME;
document.body.classList.remove("dark");
window.theme.textContent = "Dark Theme";
for (const view of document.querySelectorAll("perspective-viewer")) {
view.setAttribute("theme", "Pro Light");
view.restyleElement();
}
theme_style_node.dataset.theme = "Pro Light";
}
}
async function fetch_progress(url) {
window.message.textContent = "Downloading...";
const response = await fetch(url);
const ab = await new Response(await response.blob()).arrayBuffer();
window.message.style.display = "none";
return ab;
}
DARK_THEME = await fetch(
"/node_modules/@perspective-dev/workspace/dist/css/pro-dark.css",
).then((x) => x.text());
LIGHT_THEME = await fetch(
"/node_modules/@perspective-dev/workspace/dist/css/pro.css",
).then((x) => x.text());
document.body.innerHTML = `
<style>
</style>
<div id='buttons'>
<span id="message"></span>
<select id="layouts"></select>
<button id="save_as">Save As</button>
<input id="name_input" style="display: none"></input>
<button id="save" style="display: none">Save</button>
<button id="cancel" style="display: none">Cancel</button>
<button id="theme" style="float: right">Light Theme</button>
<button id="copy" style="float: right">Debug to Clipboard</button>
<button id="reset" style="float: right">Reset LocalStorage</button>
<a href="https://github.com/texodus/nypd-ccrb">NYCLU/CCRB Data</a>
<a href="https://github.com/perspective-dev/perspective">Built With Perspective</a>
</div>
<perspective-workspace id='workspace'></perspective-workspace>
`.trim();
toggle_theme();
window.workspace.addEventListener(
"workspace-new-view",
({ detail: { widget } }) => {
widget.viewer.setAttribute("theme", theme_style_node.dataset.theme);
},
);
window.workspace.addTable(
"ccrb",
(async () => worker.table(await fetch_progress(DATA_URL)))(),
// worker.table(await fetch_progress(DATA_URL))
);
if (LAYOUTS == undefined) {
LAYOUTS = await (await fetch("./layout.json")).json();
}
const layout_names = Object.keys(LAYOUTS);
let selected_layout = LAYOUTS[layout_names[0]];
await window.workspace.restore(selected_layout);
function set_layout_options() {
const layout_names = Object.keys(LAYOUTS);
window.layouts.innerHTML = "";
for (const layout of layout_names) {
window.layouts.innerHTML += `<option${
layout === selected_layout ? " selected='true'" : ""
}>${layout}</option>`;
}
}
set_layout_options();
window.name_input.value = layout_names[0];
window.layouts.addEventListener("change", async () => {
if (window.layouts.value.trim().length === 0) {
return;
}
window.workspace.innerHTML = "";
await window.workspace.restore(LAYOUTS[window.layouts.value]);
window.name_input.value = window.layouts.value;
});
window.save_as.addEventListener("click", async () => {
window.save_as.style.display = "none";
window.save.style.display = "inline-block";
window.cancel.style.display = "inline-block";
window.name_input.style.display = "inline-block";
window.copy.style.display = "none";
window.layouts.style.display = "none";
});
function cancel() {
window.save_as.style.display = "inline-block";
window.save.style.display = "none";
window.cancel.style.display = "none";
window.name_input.style.display = "none";
window.copy.style.display = "inline-block";
window.layouts.style.display = "inline-block";
}
window.cancel.addEventListener("click", cancel);
window.reset.addEventListener("click", () => {
localStorage.clear();
window.reset.innerText = "Reset!";
setTimeout(() => {
window.reset.innerText = "Reset LocalStorage";
}, 1000);
});
window.save.addEventListener("click", async () => {
const token = await window.workspace.save();
const new_name = window.name_input.value;
LAYOUTS[new_name] = token;
set_layout_options();
window.layouts.value = new_name;
window.save_as.innerText = "Saved!";
setTimeout(() => {
window.save_as.innerText = "Save As";
}, 1000);
localStorage.setItem("layouts", JSON.stringify(LAYOUTS));
cancel();
});
window.copy.addEventListener("click", async () => {
await navigator.clipboard.writeText(JSON.stringify(LAYOUTS));
window.copy.innerText = "Copied!";
setTimeout(() => {
window.copy.innerText = "Debug to Clipboard";
}, 1000);
});
window.theme.addEventListener("click", toggle_theme);
+426
View File
@@ -0,0 +1,426 @@
{
"Raw": {
"sizes": [1],
"detail": {
"main": {
"type": "tab-area",
"widgets": ["PERSPECTIVE_GENERATED_ID_3"],
"currentIndex": 0
}
},
"mode": "globalFilters",
"viewers": {
"PERSPECTIVE_GENERATED_ID_3": {
"plugin": "Datagrid",
"plugin_config": {
"columns": {},
"editable": false,
"scroll_lock": false
},
"settings": false,
"title": "Raw",
"group_by": [],
"split_by": [],
"columns": [
"AsOfDate",
"AllegationID",
"LastName",
"FirstName",
"OfficerRace",
"OfficerGender",
"TaxID",
"CurrentRank",
"CurrentRankLong",
"CurrentCommand",
"IncidentRank",
"IncidentRankLong",
"IncidentCommand",
"ShieldNo",
"DaysOnForce",
"Status",
"LastActive",
"ComplaintID",
"IncidentDate",
"FADOType",
"Allegation",
"CCRBDisposition",
"BoardCat",
"PenaltyRec",
"PenaltyCat",
"PenaltyDesc",
"LocationType",
"ContactReason",
"ContactOutcome",
"IncidentPrecinct",
"ImpactedRace",
"ImpactedGender",
"ImpactedAge",
"ReceivedDate",
"CloseDate"
],
"filter": [],
"sort": [],
"expressions": {},
"aggregates": {},
"master": false,
"table": "ccrb",
"linked": false
}
}
},
"Incidents per Month": {
"sizes": [1],
"detail": {
"main": {
"type": "tab-area",
"widgets": ["PERSPECTIVE_GENERATED_ID_3"],
"currentIndex": 0
}
},
"mode": "globalFilters",
"viewers": {
"PERSPECTIVE_GENERATED_ID_3": {
"plugin": "Y Line",
"plugin_config": {},
"settings": false,
"title": "Incidents",
"group_by": ["bucket(\"IncidentDate\", 'M')"],
"split_by": [],
"columns": ["ComplaintID"],
"filter": [
["IncidentDate", ">", "1985-01-01"],
["IncidentDate", "<", "2025-01-01"]
],
"sort": [],
"expressions": {
"bucket(\"IncidentDate\", 'M')": "bucket(\"IncidentDate\", 'M')"
},
"aggregates": { "ComplaintID": "distinct count" },
"master": false,
"table": "ccrb",
"linked": false
}
}
},
"Incidents per Month by FADO": {
"sizes": [1],
"detail": {
"main": {
"type": "split-area",
"orientation": "vertical",
"children": [
{
"type": "tab-area",
"widgets": ["PERSPECTIVE_GENERATED_ID_0"],
"currentIndex": 0
},
{
"type": "tab-area",
"widgets": ["PERSPECTIVE_GENERATED_ID_2"],
"currentIndex": 0
}
],
"sizes": [0.5, 0.5]
}
},
"mode": "globalFilters",
"viewers": {
"PERSPECTIVE_GENERATED_ID_0": {
"plugin": "Y Line",
"plugin_config": { "zoom": { "k": 1, "x": 0, "y": 0 } },
"settings": false,
"title": "Incidents (line)",
"group_by": ["bucket(\"IncidentDate\", 'M')"],
"split_by": ["FADOType"],
"columns": ["AllegationID"],
"filter": [
["IncidentDate", ">", "1985-01-01"],
["IncidentDate", "<", "2025-01-01"]
],
"sort": [["AllegationID", "col asc"]],
"expressions": {
"bucket(\"IncidentDate\", 'M')": "bucket(\"IncidentDate\", 'M')"
},
"aggregates": { "AllegationID": "distinct count" },
"master": false,
"table": "ccrb",
"linked": false
},
"PERSPECTIVE_GENERATED_ID_2": {
"plugin": "Y Area",
"plugin_config": {},
"settings": false,
"title": "Incidents (area)",
"group_by": ["bucket(\"IncidentDate\", 'M')"],
"split_by": ["FADOType"],
"columns": ["AllegationID"],
"filter": [
["IncidentDate", ">", "1985-01-01"],
["IncidentDate", "<", "2025-01-01"]
],
"sort": [["AllegationID", "col asc"]],
"expressions": {
"bucket(\"IncidentDate\", 'M')": "bucket(\"IncidentDate\", 'M')"
},
"aggregates": { "AllegationID": "distinct count" },
"master": false,
"table": "ccrb",
"linked": false
}
}
},
"Total Complaints vs Officer Count by Command and FADO": {
"sizes": [1],
"detail": {
"main": {
"type": "split-area",
"orientation": "horizontal",
"children": [
{
"type": "tab-area",
"widgets": ["PERSPECTIVE_GENERATED_ID_4"],
"currentIndex": 0
},
{
"type": "tab-area",
"widgets": ["PERSPECTIVE_GENERATED_ID_7"],
"currentIndex": 0
}
],
"sizes": [0.5, 0.5]
}
},
"mode": "globalFilters",
"viewers": {
"PERSPECTIVE_GENERATED_ID_4": {
"plugin": "X/Y Scatter",
"plugin_config": {},
"settings": false,
"title": "Active Officer Incidents",
"group_by": ["IncidentCommand"],
"split_by": ["FADOType"],
"columns": ["ShieldNo", "ComplaintID", null, null, null, null],
"filter": [["Status", "==", "Active"]],
"sort": [],
"expressions": {},
"aggregates": {
"ShieldNo": "distinct count",
"ComplaintID": "distinct count"
},
"master": false,
"table": "ccrb",
"linked": false
},
"PERSPECTIVE_GENERATED_ID_7": {
"plugin": "X/Y Scatter",
"plugin_config": { "zoom": { "k": 1, "x": 0, "y": 0 } },
"settings": false,
"title": "Inactive Officer Incidents",
"group_by": ["IncidentCommand"],
"split_by": ["FADOType"],
"columns": ["ShieldNo", "ComplaintID", null, null, null, null],
"filter": [["Status", "==", "Retired"]],
"sort": [],
"expressions": {},
"aggregates": {
"ShieldNo": "distinct count",
"ComplaintID": "distinct count"
},
"master": false,
"table": "ccrb",
"linked": false
}
}
},
"Incidents by Allegation, time series": {
"sizes": [0.3654752553024352, 0.6345247446975648],
"detail": {
"main": {
"type": "tab-area",
"widgets": ["PERSPECTIVE_GENERATED_ID_13"],
"currentIndex": 0
}
},
"mode": "globalFilters",
"master": { "widgets": ["PERSPECTIVE_GENERATED_ID_11"], "sizes": [1] },
"viewers": {
"PERSPECTIVE_GENERATED_ID_11": {
"plugin": "Datagrid",
"plugin_config": {
"columns": {},
"editable": false,
"scroll_lock": false
},
"settings": false,
"title": "Filter",
"group_by": ["FADOType", "Allegation"],
"split_by": [],
"columns": ["ComplaintID"],
"filter": [],
"sort": [["ComplaintID", "desc"]],
"expressions": {},
"aggregates": { "ComplaintID": "distinct count" },
"master": true,
"table": "ccrb",
"linked": false,
"selectable": ""
},
"PERSPECTIVE_GENERATED_ID_13": {
"plugin": "Y Line",
"plugin_config": {},
"settings": false,
"title": "Incidents",
"group_by": ["bucket(\"IncidentDate\", 'M')"],
"split_by": ["FADOType"],
"columns": ["ComplaintID"],
"filter": [
["IncidentDate", ">", "1985-01-01"],
["IncidentDate", "<", "2025-01-01"]
],
"sort": [],
"expressions": {
"bucket(\"IncidentDate\", 'M')": "bucket(\"IncidentDate\", 'M')"
},
"aggregates": { "ComplaintID": "distinct count" },
"master": false,
"table": "ccrb",
"linked": false
}
}
},
"Allegation reporting change, 1995-2000": {
"sizes": [1],
"detail": {
"main": {
"type": "tab-area",
"widgets": ["PERSPECTIVE_GENERATED_ID_10"],
"currentIndex": 0
}
},
"mode": "globalFilters",
"viewers": {
"PERSPECTIVE_GENERATED_ID_10": {
"plugin": "Heatmap",
"plugin_config": {
"zoom": {
"k": 0.959264119325269,
"x": 23.20639207453189,
"y": 10.58613905921925
}
},
"settings": false,
"title": "Incidents",
"group_by": ["bucket(\"IncidentDate\", '3M')"],
"split_by": ["Allegation"],
"columns": ["AllegationID"],
"filter": [
["IncidentDate", ">", "1985-01-01"],
["IncidentDate", "<", "2025-01-01"]
],
"sort": [["IncidentDate", "col asc"]],
"expressions": {
"bucket(\"IncidentDate\", '3M')": "bucket(\"IncidentDate\", '3M')"
},
"aggregates": {
"IncidentDate": "median",
"AllegationID": "distinct count"
},
"master": false,
"table": "ccrb",
"linked": false
}
}
},
"Day-of-Week Incident Heatmap": {
"sizes": [0.3686174391201885, 0.6313825608798115],
"detail": {
"main": {
"type": "split-area",
"orientation": "vertical",
"children": [
{
"type": "tab-area",
"widgets": ["PERSPECTIVE_GENERATED_ID_13"],
"currentIndex": 0
},
{
"type": "tab-area",
"widgets": ["PERSPECTIVE_GENERATED_ID_15"],
"currentIndex": 0
}
],
"sizes": [0.5, 0.5]
}
},
"mode": "globalFilters",
"master": { "widgets": ["PERSPECTIVE_GENERATED_ID_11"], "sizes": [1] },
"viewers": {
"PERSPECTIVE_GENERATED_ID_11": {
"plugin": "Datagrid",
"plugin_config": {
"columns": {},
"editable": false,
"scroll_lock": false
},
"settings": false,
"title": "Filter",
"group_by": ["FADOType", "Allegation"],
"split_by": [],
"columns": ["ComplaintID"],
"filter": [],
"sort": [["ComplaintID", "desc"]],
"expressions": {},
"aggregates": { "ComplaintID": "distinct count" },
"master": true,
"table": "ccrb",
"linked": false,
"selectable": ""
},
"PERSPECTIVE_GENERATED_ID_13": {
"plugin": "Heatmap",
"plugin_config": {},
"settings": false,
"title": "Year vs Month of Incident",
"group_by": ["bucket(\"IncidentDate\", 'Y')"],
"split_by": ["month_of_year(\"IncidentDate\")"],
"columns": ["ComplaintID"],
"filter": [
["IncidentDate", ">", "1985-01-01"],
["IncidentDate", "<", "2025-01-01"]
],
"sort": [],
"expressions": {
"bucket(\"IncidentDate\", 'Y')": "bucket(\"IncidentDate\", 'Y')",
"month_of_year(\"IncidentDate\")": "month_of_year(\"IncidentDate\")"
},
"aggregates": { "ComplaintID": "distinct count" },
"master": false,
"table": "ccrb",
"linked": false
},
"PERSPECTIVE_GENERATED_ID_15": {
"plugin": "Heatmap",
"plugin_config": {},
"settings": false,
"title": "Month vs Weekday of Incident",
"group_by": ["month_of_year(\"IncidentDate\")"],
"split_by": ["day_of_week(\"IncidentDate\")"],
"columns": ["ComplaintID"],
"filter": [
["IncidentDate", ">", "1985-01-01"],
["IncidentDate", "<", "2025-01-01"]
],
"sort": [],
"expressions": {
"bucket(\"IncidentDate\", 'Y')": "bucket(\"IncidentDate\", 'Y')",
"month_of_year(\"IncidentDate\")": "month_of_year(\"IncidentDate\")",
"day_of_week(\"IncidentDate\")": "day_of_week(\"IncidentDate\")"
},
"aggregates": { "ComplaintID": "distinct count" },
"master": false,
"table": "ccrb",
"linked": false
}
}
}
}
+8
View File
@@ -0,0 +1,8 @@
button {
background-color: inherit;
border: 1px solid rgba(0, 0, 0, 0.5);
}
#buttons {
background-color: #e1e6ea;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

+2
View File
@@ -0,0 +1,2 @@
license: apache-2.0
height: 800
+6
View File
@@ -0,0 +1,6 @@
Demo of [Perspective](https://github.com/perspective-dev/perspective) with
~270,000 rows.
This dataset from
[Kaggle](https://www.kaggle.com/datasets/heesoo37/120-years-of-olympic-history-athletes-and-results)
provides a row for every athlete to attend the Olympics since 1896.
+63
View File
@@ -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). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
import { execSync } from "child_process";
import perspective from "@perspective-dev/client";
import {
Uint8ArrayReader,
ZipReader,
TextWriter,
TextReader,
} from "@zip.js/zip.js";
// import * as fs from "node:fs/promises";
import * as url from "url";
import * as fs from "node:fs";
async function main() {
const __dirname = url
.fileURLToPath(new URL(".", import.meta.url))
.slice(0, -1);
if (fs.existsSync(`${__dirname}/olympics.arrow`)) {
return;
}
execSync(
`cd ${__dirname} && kaggle datasets download -d heesoo37/120-years-of-olympic-history-athletes-and-results`,
{ stdio: "inherit" },
);
const zip = fs.readFileSync(
`${__dirname}/120-years-of-olympic-history-athletes-and-results.zip`,
);
const textReader = new TextReader(zip);
const zipReader = new ZipReader(textReader);
const entries = await zipReader.getEntries();
const csv = await entries[0].getData(new TextWriter(), {
onprogress: (p, t) => console.log(`(${p}b / ${t}b)`),
});
zipReader.close();
const table = await perspective.table(csv);
const view = await table.view();
const arrow = await view.to_arrow();
fs.writeFileSync(`${__dirname}/olympics.arrow`, Buffer.from(arrow));
fs.unlinkSync(
`${__dirname}/120-years-of-olympic-history-athletes-and-results.zip`,
);
await view.delete();
}
main();
+90
View File
@@ -0,0 +1,90 @@
<html>
<head>
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,minimum-scale=1,user-scalable=no" />
<link rel="preload" href="/node_modules/@perspective-dev/server/dist/wasm/perspective-server.wasm" as="fetch" type="application/wasm" crossorigin="anonymous" />
<link rel="preload" href="/node_modules/@perspective-dev/viewer/dist/wasm/perspective-viewer.wasm" as="fetch" type="application/wasm" crossorigin="anonymous" />
<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" />
<style>
perspective-workspace {
left: 0;
position: absolute;
top: 0;
bottom: 0;
right: 0;
}
perspective-viewer[theme="Pro Light"] {
--d3fc-positive--gradient: linear-gradient(#94d0ff, #8795e8, #966bff, #ad8cff, #c774e8, #c774a9, #ff6ad5, #ff6a8b, #ff8b8b, #ffa58b, #ffde8b, #cdde8b, #8bde8b, #20de8b);
}
</style>
</head>
<body>
<perspective-workspace id="workspace"></perspective-workspace>
<script type="module">
import "/node_modules/@perspective-dev/viewer/dist/cdn/perspective-viewer.js";
import "/node_modules/@perspective-dev/workspace/dist/cdn/perspective-workspace.js";
import "/node_modules/@perspective-dev/viewer-datagrid/dist/cdn/perspective-viewer-datagrid.js";
import "/node_modules/@perspective-dev/viewer-charts/dist/cdn/perspective-viewer-charts.js";
import perspective from "/node_modules/@perspective-dev/client/dist/cdn/perspective.js";
const datasource = async () => {
const request = fetch("./olympics.arrow");
const worker = await perspective.worker();
const response = await request;
const arrow = await response.arrayBuffer();
const table = await worker.table(arrow);
return table;
};
window.workspace.tables.set("olympics", datasource());
window.workspace.restore({
sizes: [1],
detail: {
main: {
type: "tab-area",
widgets: ["PERSPECTIVE_GENERATED_ID_1", "PERSPECTIVE_GENERATED_ID_0"],
currentIndex: 1,
},
},
mode: "globalFilters",
viewers: {
PERSPECTIVE_GENERATED_ID_1: {
plugin: "X/Y Scatter",
title: "Avg Height vs Weight by Sport",
group_by: ["Sport"],
columns: ["Height", "Weight", null, "City", null, "Sport", null],
filter: [["Height", "==", null]],
sort: [["Name", "desc"]],
expressions: {},
aggregates: {
Name: "distinct count",
Weight: "avg",
Sport: "dominant",
Height: "avg",
},
table: "olympics",
},
PERSPECTIVE_GENERATED_ID_0: {
plugin: "Heatmap",
title: "Age Distribution by Sport",
group_by: ["Age"],
split_by: ["Sport"],
columns: ["Name"],
filter: [
["Medal", "!=", "NA"],
["Age", "is not null", null],
],
sort: [["Name", "col asc"]],
expressions: {},
aggregates: {
Age: "avg",
},
table: "olympics",
},
},
});
</script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

+2
View File
@@ -0,0 +1,2 @@
license: apache-2.0
height: 800
+7
View File
@@ -0,0 +1,7 @@
A ray-casting engine written as an
[ExprTK](https://github.com/ArashPartow/exprtk) Expression within
[Perspective](https://github.com/perspective-dev/perspective). As presented at
[Open Source in Finance 2022 NY](https://www.youtube.com/watch?v=0ut-ynvBpGI).
This example has no server component, and all ray-casting is done entirely
within the Perspective WebAssembly engine.
+41
View File
@@ -0,0 +1,41 @@
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
* ┃ 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). ┃
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
*/
perspective-viewer {
overflow: visible;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
perspective-viewer[theme="Pro Dark"] {
--d3fc-charts--gradient--background: linear-gradient(
#242526 50%,
#071952,
#088395,
#35a29f,
#f2f7a1
);
}
perspective-viewer[theme="Pro Light"] {
--d3fc-charts--gradient--background: linear-gradient(
#ffffff 50%,
#071952,
#088395,
#35a29f,
#f2f7a1
);
}
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,minimum-scale=1,user-scalable=no" />
<link rel="stylesheet" crossorigin="anonymous" href="/node_modules/@perspective-dev/viewer/dist/css/themes.css" />
<link rel="preload" href="/node_modules/@perspective-dev/server/dist/wasm/perspective-server.wasm" as="fetch" type="application/wasm" crossorigin="anonymous" />
<link rel="preload" href="/node_modules/@perspective-dev/viewer/dist/wasm/perspective-viewer.wasm" as="fetch" type="application/wasm" crossorigin="anonymous" />
<script type="module" src="index.js"></script>
<link rel="stylesheet" href="index.css" />
</head>
<body>
<perspective-viewer id="viewer"><span id="rendering_label" style="display: inline-block; margin: 24px">Rendering ...</span></perspective-viewer>
</body>
</html>
+145
View File
@@ -0,0 +1,145 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 "/node_modules/@perspective-dev/client/dist/cdn/perspective.js";
import "/node_modules/@perspective-dev/viewer/dist/cdn/perspective-viewer.js";
import "/node_modules/@perspective-dev/viewer-datagrid/dist/cdn/perspective-viewer-datagrid.js";
import "/node_modules/@perspective-dev/viewer-charts/dist/cdn/perspective-viewer-charts.js";
const RESOLUTION = 100;
const EXPRESSION = `
// Scene constants
var resolution := ${RESOLUTION};
var fov := 30 * (pi / 180);
var camera[3] := {0, 0, -600};
var light[3] := {0.5, 1, 0};
// Torus constants
var radius := 1;
var tube := 0.4;
var radialSegments := 12;
var tubularSegments := 16;
var rotation := 40 * (pi / 180);
// Mesh
var arc := pi * 2;
var vs[663];
for (var j := 0; j <= radialSegments; j += 1) {
for (var i := 0; i <= tubularSegments; i += 1) {
// Vertex
var u := (i / tubularSegments) * arc;
var v := (j / radialSegments) * pi * 2;
var i0 := j * 3 * (tubularSegments + 1) + (i * 3);
vs[i0] := (radius + tube * cos(v)) * cos(u) * 100;
vs[i0 + 1] := (radius + tube * cos(v)) * sin(u) * 100;
vs[i0 + 2] := tube * sin(v) * 100;
// Rotate
var b := vs[i0 + 1];
var bcos := cos(rotation);
var bsin := sin(rotation);
vs[i0 + 1] := vs[i0 + 1] * bcos - vs[i0 + 2] * bsin;
vs[i0 + 2] := b * bsin + vs[i0 + 2] * bcos;
b := vs[i0];
vs[i0] := vs[i0] * bcos - vs[i0 + 2] * bsin;
vs[i0 + 2] := b * bsin + vs[i0 + 2] * bcos;
}
};
// Render scene
var scale := resolution / (tan(fov / 2) * 400);
var x := (floor(index() / resolution) - resolution / 2) / scale;
var y := (index() % resolution - resolution / 2) / scale;
var d[3] := {x, y, 200};
var color := 0;
var depth := inf;
var light_norm := norm3(light);
for (var j := 1; j <= radialSegments; j += 1) {
for (var i := 1; i <= tubularSegments; i += 1) {
// Index
var aa := (tubularSegments + 1) * j + i - 1;
var b := (tubularSegments + 1) * (j - 1) + i - 1;
var c := (tubularSegments + 1) * (j - 1) + i;
var dd := (tubularSegments + 1) * j + i;
var face[6] := {aa, b, dd, b, c, dd};
for (var ii:= 0; ii < 2; ii += 1) {
var i0 := face[ii * 3];
var i1 := face[ii * 3 + 1];
var i2 := face[ii * 3 + 2];
var v0[3] := {vs[i0 * 3], vs[i0 * 3 + 1], vs[i0 * 3 + 2]};
var v1[3] := {vs[i1 * 3], vs[i1 * 3 + 1], vs[i1 * 3 + 2]};
var v2[3] := {vs[i2 * 3], vs[i2 * 3 + 1], vs[i2 * 3 + 2]};
// Render triangle
var e1[3] := v1 - v0;
var e2[3] := v2 - v0;
var h[3];
cross_product3(d, e2, h);
var a := dot_product3(e1, h);
if (a != 0) {
var f := 1 / a;
var s[3] := camera - v0;
var u := f * dot_product3(s, h);
if (u > 0 and u < 1) {
var q[3];
cross_product3(s, e1, q);
var v := f * dot_product3(d, q);
if (v > 0 and u + v < 1) {
var t := f * dot_product3(e2, q);
if (t >= 0) {
var t2 := 1 - u - v;
var d1[3] := v0 * t2 + v1 * u + v2 * v;
var dist := norm3(d1 - camera);
if (dist < depth) {
depth := dist;
// Lighting
var n[3];
cross_product3(v0 - v1, v2 - v1, n);
color := acos(dot_product3(light, n) / (light_norm * norm3(n)))
}
}
}
}
}
}
}
};
color;
`.trim();
const LAYOUT = {
title: "Raycasting",
plugin: "Heatmap",
group_by: [`x`],
split_by: [`y`],
columns: ["color"],
table: "raycasting",
expressions: {
color: EXPRESSION,
x: `floor(index() / ${RESOLUTION}) - ${RESOLUTION} / 2`,
y: `index() % ${RESOLUTION} - ${RESOLUTION} / 2`,
},
settings: true,
theme: "Pro Dark",
};
const worker = await perspective.worker();
const index = new Array(Math.pow(RESOLUTION, 2)).fill(0);
worker.table({ index }, { name: "raycasting" });
window.viewer.load(worker);
await window.viewer.restore(LAYOUT);
document.querySelector("#rendering_label").remove();
Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

+1
View File
@@ -0,0 +1 @@
license: apache-2.0
+8
View File
@@ -0,0 +1,8 @@
An example of streaming data into the perspective viewer, simulating a simple market
blotter. The example creates random rows of tick data updating the table every 50ms
and limiting the data set to 500 rows.
This example has no server component, and all market data is randomly created in the
browser. It is a good example of Perspective's optimized real-time windowing
performance using the `limit` parameter, as memory usage will stay consistent, as well
as its interactivity during frequent re-renders.
+20
View File
@@ -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). ┃
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
*/
perspective-viewer {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,minimum-scale=1,user-scalable=no" />
<link rel="preload" href="/node_modules/@perspective-dev/server/dist/wasm/perspective-server.wasm" as="fetch" type="application/wasm" crossorigin="anonymous" />
<link rel="preload" href="/node_modules/@perspective-dev/viewer/dist/wasm/perspective-viewer.wasm" as="fetch" type="application/wasm" crossorigin="anonymous" />
<link rel="stylesheet" href="index.css" />
<link rel="stylesheet" crossorigin="anonymous" href="/node_modules/@perspective-dev/viewer/dist/css/themes.css" />
<script type="module" src="streaming.js"></script>
</head>
<body>
<perspective-viewer></perspective-viewer>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

+109
View File
@@ -0,0 +1,109 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 "/node_modules/@perspective-dev/viewer/dist/cdn/perspective-viewer.js";
import "/node_modules/@perspective-dev/viewer-datagrid/dist/cdn/perspective-viewer-datagrid.js";
import "/node_modules/@perspective-dev/viewer-charts/dist/cdn/perspective-viewer-charts.js";
import perspective from "/node_modules/@perspective-dev/client/dist/cdn/perspective.js";
var SECURITIES = [
"AAPL.N",
"AMZN.N",
"QQQ.N",
"NVDA.N",
"TSLA.N",
"FB.N",
"MSFT.N",
"TLT.N",
"XIV.N",
"YY.N",
"CSCO.N",
"GOOGL.N",
"PCLN.N",
];
var CLIENTS = [
"Homer",
"Marge",
"Bart",
"Lisa",
"Maggie",
"Moe",
"Lenny",
"Carl",
"Krusty",
];
// Create 5 random rows of data.
function newRows() {
var rows = [];
for (var x = 0; x < 50; x++) {
rows.push({
name: SECURITIES[Math.floor(Math.random() * SECURITIES.length)],
client: CLIENTS[Math.floor(Math.random() * CLIENTS.length)],
lastUpdate: new Date(),
chg: Math.random() * 20 - 10,
bid: Math.random() * 10 + 90,
ask: Math.random() * 10 + 100,
vol: Math.random() * 10 + 100,
});
}
return rows;
}
// Get element from the DOM.
var elem = document.getElementsByTagName("perspective-viewer")[0];
// Create a new Perspective WebWorker instance.
var worker = await perspective.worker();
// Create a new Perspective table in our `worker`, and limit it it 500 rows.
const table = await worker.table(newRows(), {
name: "streaming",
limit: 500,
});
// Load the `table` in the `<perspective-viewer>` DOM reference.
await elem.load(worker);
elem.restore({
plugin: "Datagrid",
columns_config: {
"(+)chg": { fg_gradient: 7.93, number_fg_mode: "bar" },
"(-)chg": { fg_gradient: 8.07, number_fg_mode: "bar" },
chg: { bg_gradient: 9.97, number_bg_mode: "gradient" },
},
plugin_config: {
editable: false,
scroll_lock: true,
},
settings: true,
table: "streaming",
theme: "Pro Light",
group_by: ["name"],
split_by: ["client"],
columns: ["(-)chg", "chg", "(+)chg"],
filter: [],
sort: [["chg", "desc"]],
expressions: {
"(-)chg": 'if("chg"<0){"chg"}else{0}',
"(+)chg": 'if("chg">0){"chg"}else{0}',
},
aggregates: { "(-)chg": "avg", chg: "avg", "(+)chg": "avg" },
});
// Add more rows every 50ms using the `update()` method on the `table` directly.
(function postRow() {
table.update(newRows());
setTimeout(postRow, 10);
})();
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

+1
View File
@@ -0,0 +1 @@
license: apache-2.0
+9
View File
@@ -0,0 +1,9 @@
Demo of [Perspective](https://github.com/perspective-dev/perspective).
This example of a `<perspective-workspace>` dashboard is generated from one
[Apache Arrow](https://arrow.apache.org/) source dataset, converted originally
from this
[XLS from a Tableau presentation](https://community.tableau.com/docs/DOC-1236).
This layout is just an example; you can explore your own visualizations in the
Perspective configuration menu, accessed by clicking the dropdown arrow in each
pane's upper left, or advanced options in the pane's right-click context menu.
+50
View File
@@ -0,0 +1,50 @@
<html>
<head>
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,minimum-scale=1,user-scalable=no" />
<link rel="preload" href="/node_modules/@perspective-dev/server/dist/wasm/perspective-server.wasm" as="fetch" type="application/wasm" crossorigin="anonymous" />
<link rel="preload" href="/node_modules/@perspective-dev/viewer/dist/wasm/perspective-viewer.wasm" as="fetch" type="application/wasm" crossorigin="anonymous" />
<link rel="stylesheet" crossorigin="anonymous" href="/node_modules/@perspective-dev/viewer/dist/css/themes.css" />
<link rel="stylesheet" crossorigin="anonymous" href="/node_modules/@perspective-dev/workspace/dist/css/pro.css" />
<style>
perspective-workspace {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
}
body {
overflow: hidden;
}
</style>
</head>
<body>
<perspective-workspace id="workspace"></perspective-workspace>
<script type="module">
import "/node_modules/@perspective-dev/viewer/dist/cdn/perspective-viewer.js";
import "/node_modules/@perspective-dev/workspace/dist/cdn/perspective-workspace.js";
import "/node_modules/@perspective-dev/viewer-datagrid/dist/cdn/perspective-viewer-datagrid.js";
import "/node_modules/@perspective-dev/viewer-charts/dist/cdn/perspective-viewer-charts.js";
import perspective from "/node_modules/@perspective-dev/client/dist/cdn/perspective.js";
const DATA_URL = "/node_modules/superstore-arrow/superstore.lz4.arrow";
async function get_layout() {
const req = await fetch("layout.json");
const json = await req.json();
return json;
}
const request = fetch(DATA_URL);
const worker = await perspective.worker();
const response = await request;
const buffer = await response.arrayBuffer();
await worker.table(buffer, { name: "superstore" });
window.workspace.load(worker);
const layout = await get_layout();
await window.workspace.restore(layout);
</script>
</body>
</html>
+169
View File
@@ -0,0 +1,169 @@
index.html: 48 {
"sizes": [
1
],
"detail": {
"main": {
"type": "split-area",
"orientation": "horizontal",
"children": [
{
"type": "tab-area",
"widgets": [
"PERSPECTIVE_GENERATED_ID_0",
"PERSPECTIVE_GENERATED_ID_1"
],
"currentIndex": 0
},
{
"type": "tab-area",
"widgets": [
"PERSPECTIVE_GENERATED_ID_3"
],
"currentIndex": 0
},
{
"type": "tab-area",
"widgets": [
"PERSPECTIVE_GENERATED_ID_2"
],
"currentIndex": 0
}
],
"sizes": [
0.3765432098765432,
0.24691358024691357,
0.3765432098765432
]
}
},
"viewers": {
"PERSPECTIVE_GENERATED_ID_0": {
"version": "3.5.0",
"plugin": "Datagrid",
"plugin_config": {
"columns": {},
"edit_mode": "READ_ONLY",
"scroll_lock": false
},
"columns_config": {},
"title": "Sales Report 2",
"group_by": [
"Region",
"State"
],
"split_by": [
"Category",
"Sub-Category"
],
"columns": [
"Sales",
"Profit"
],
"filter": [],
"sort": [],
"expressions": {},
"aggregates": {},
"table": "superstore",
"settings": false
},
"PERSPECTIVE_GENERATED_ID_1": {
"version": "3.5.0",
"plugin": "Datagrid",
"plugin_config": {
"columns": {},
"edit_mode": "READ_ONLY",
"scroll_lock": false
},
"columns_config": {},
"title": null,
"group_by": [],
"split_by": [],
"columns": [
"Row ID",
"Order ID",
"Ship Mode",
"Customer ID",
"Customer Name",
"Segment",
"Country",
"City",
"State",
"Postal Code",
"Region",
"Product ID",
"Category",
"Sub-Category",
"Product Name",
"Sales",
"Quantity",
"Discount",
"Profit",
"Order Date",
"Ship Date"
],
"filter": [],
"sort": [],
"expressions": {},
"aggregates": {},
"table": "superstore",
"settings": false
},
"PERSPECTIVE_GENERATED_ID_3": {
"version": "3.5.0",
"plugin": "Datagrid",
"plugin_config": {
"columns": {},
"edit_mode": "READ_ONLY",
"scroll_lock": false
},
"columns_config": {},
"title": "Sales Report 2 (*)",
"group_by": [
"Region",
"State"
],
"split_by": [
"Category",
"Sub-Category"
],
"columns": [
"Sales",
"Profit"
],
"filter": [],
"sort": [],
"expressions": {},
"aggregates": {},
"table": "superstore",
"settings": false
},
"PERSPECTIVE_GENERATED_ID_2": {
"version": "3.5.0",
"plugin": "Treemap",
"plugin_config": {},
"columns_config": {},
"title": "Sales Report (by State)",
"group_by": [
"State"
],
"split_by": [],
"columns": [
"Sales",
"Profit",
null
],
"filter": [],
"sort": [
[
"Profit",
"desc"
]
],
"expressions": {},
"aggregates": {},
"table": "superstore",
"settings": false
}
}
}
@@ -0,0 +1,88 @@
{
"sizes": [1],
"detail": {
"main": {
"type": "split-area",
"orientation": "horizontal",
"children": [
{
"type": "tab-area",
"widgets": [
"PERSPECTIVE_GENERATED_ID_0",
"PERSPECTIVE_GENERATED_ID_1"
],
"currentIndex": 0
},
{
"type": "tab-area",
"widgets": ["PERSPECTIVE_GENERATED_ID_2"],
"currentIndex": 0
}
],
"sizes": [0.5, 0.5]
}
},
"mode": "globalFilters",
"viewers": {
"PERSPECTIVE_GENERATED_ID_0": {
"plugin": "datagrid",
"group_by": ["Region", "State"],
"split_by": ["Category", "Sub-Category"],
"columns": ["Sales", "Profit"],
"master": false,
"name": "Sales Report",
"table": "superstore",
"linked": false,
"title": "Sales Report 2"
},
"PERSPECTIVE_GENERATED_ID_1": {
"plugin": "datagrid",
"columns": [
"Row ID",
"Order ID",
"Ship Mode",
"Customer ID",
"Customer Name",
"Segment",
"Country",
"City",
"State",
"Postal Code",
"Region",
"Product ID",
"Category",
"Sub-Category",
"Product Name",
"Sales",
"Quantity",
"Discount",
"Profit",
"Order Date",
"Ship Date"
],
"master": false,
"name": "superstore.csv",
"table": "superstore",
"linked": false
},
"PERSPECTIVE_GENERATED_ID_2": {
"plugin": "Treemap",
"group_by": ["State"],
"columns": ["Sales", "Profit", null],
"sort": [["Profit", "desc"]],
"plugin_config": {
"realValues": ["Sales", "Profit", null],
"treemaps": {
"": {
"treemapLevel": 0,
"treemapRoute": [""]
}
}
},
"master": false,
"title": "Sales Report (by State)",
"table": "superstore",
"linked": false
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

+2
View File
@@ -0,0 +1,2 @@
A Perspective example which uses your computer's webcam as a data source.
+93
View File
@@ -0,0 +1,93 @@
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
* ┃ This file is part of the Perspective library, distributed under the terms ┃
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
*/
body {
background: #242526;
color: white;
font-family: "ui-monospace", "SFMono-Regular", "SF Mono", "Menlo",
"Consolas", "Liberation Mono", monospace;
touch-action: none;
}
* {
box-sizing: border-box;
}
#app {
display: flex;
flex-direction: column;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
#header {
display: flex;
flex-wrap: wrap;
align-items: center;
}
#header a {
display: inline-flex;
}
perspective-viewer {
border-top: 1px solid #666;
flex: 1 1 auto;
}
label {
height: 32px;
font-size: 12px;
padding: 6px 0px;
margin-right: 4px;
margin-left: 14px;
margin-top: 8px;
margin-bottom: 8px;
border: 1px solid transparent;
}
img {
vertical-align: middle;
margin-left: 14px;
}
select,
button {
font-family: "ui-monospace", "SFMono-Regular", "SF Mono", "Menlo",
"Consolas", "Liberation Mono", monospace;
font-size: 12px;
appearance: none;
background-color: transparent;
border: 1px solid #666;
border-radius: 2px;
padding: 6px 10px;
color: #f4f5f6;
cursor: pointer;
margin-right: 4px;
margin-left: 4px;
outline: none;
user-select: none;
height: 32px;
margin-top: 8px;
margin-bottom: 8px;
}
select:hover,
button:hover {
color: #242526;
background-color: #f4f5f6;
border-color: #f4f5f6;
}
+38
View File
@@ -0,0 +1,38 @@
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,minimum-scale=1,user-scalable=no" />
<link rel="preload" href="/node_modules/@perspective-dev/viewer/dist/wasm/perspective-viewer.wasm" as="fetch" type="application/wasm" crossorigin="anonymous" />
<link rel="preload" href="/node_modules/@perspective-dev/server/dist/wasm/perspective-server.wasm" as="fetch" type="application/wasm" crossorigin="anonymous" />
<link rel="stylesheet" href="index.css" />
<link rel="stylesheet" crossorigin="anonymous" href="/node_modules/@perspective-dev/viewer/dist/css/themes.css" />
<script type="module" src="webcam.js"></script>
<style>
perspective-workspace {
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
}
video,
canvas {
display: none;
}
</style>
</head>
<body>
<video id="video" width="80" height="60" autoplay muted></video>
<canvas id="canvas" width="80" height="60"></canvas>
<div id="app">
<div id="header">
<a href="https://perspective-dev.github.io">
<img height="12" src="https://raw.githubusercontent.com/perspective-dev/perspective/master/docs/static/svg/perspective-logo-dark.svg" />
</a>
<label>Webcam Demo</label>
<select></select>
</div>
<perspective-viewer></perspective-viewer>
</div>
</body>
</html>
+152
View File
@@ -0,0 +1,152 @@
[
{
"plugin": "Datagrid",
"columns_config": {
"color": {
"bg_gradient": 251.04,
"neg_bg_color": "#ffa38f",
"number_bg_mode": "gradient",
"number_fg_mode": "disabled",
"pos_bg_color": "#346ead"
}
},
"plugin_config": {
"editable": false,
"scroll_lock": false
},
"title": "Spreadsheet Cam",
"group_by": ["y"],
"split_by": ["x"],
"columns": ["color"],
"filter": [],
"sort": [],
"expressions": {
"New Column 1": "bucket(\"color\", 5)",
"y": "floor(\"index\" / 80)",
"x": "-\"index\" % 80"
},
"aggregates": {}
},
{
"plugin": "Heatmap",
"title": "Heatmap Cam",
"group_by": ["x"],
"split_by": ["y"],
"columns": ["color"],
"expressions": {
"y": "-floor(\"index\" / 80)",
"x": "-\"index\" % 80"
},
"aggregates": {
"New Column 1": "any"
}
},
{
"plugin": "Heatmap",
"plugin_config": {},
"settings": true,
"theme": "Pro Light",
"title": "Downsampled Heatmap Cam",
"group_by": ["x"],
"split_by": ["y"],
"columns": ["color"],
"filter": [],
"sort": [],
"expressions": {
"y": "bucket(-floor(\"index\" / 80), 3)",
"x": "bucket(-\"index\" % 80, 3)"
},
"aggregates": {}
},
{
"plugin": "Y Bar",
"plugin_config": {},
"title": "Luminosity Histogram",
"group_by": ["bucket(\"color\", 5)"],
"split_by": [],
"columns": ["color"],
"filter": [],
"sort": [],
"expressions": {
"bucket(\"color\", 5)": "bucket(\"color\", 5)",
"y": "-floor(\"index\" / 80)",
"x": "-\"index\" % 80"
},
"aggregates": {}
},
{
"plugin": "Datagrid",
"columns_config": {
"color": {
"bg_gradient": 2463.68,
"neg_bg_color": "#ffa38f",
"number_bg_mode": "gradient",
"number_fg_mode": "disabled",
"pos_bg_color": "#307bb0"
}
},
"plugin_config": {
"editable": false,
"scroll_lock": false
},
"title": "Small Spreadsheet Cam",
"group_by": ["bucket(y, 5)"],
"split_by": ["bucket(x, 5)"],
"columns": ["color"],
"filter": [["bucket(x, 5)", "<", 0.0]],
"sort": [],
"expressions": {
"bucket(y, 5)": "bucket(floor(\"index\" / 80), 2)",
"New Column 1": "bucket(\"color\", 5)",
"bucket(x, 5)": "bucket(-\"index\" % 80, 5)"
},
"aggregates": {}
},
{
"plugin": "Y Line",
"plugin_config": {},
"title": "Max Headroom",
"group_by": ["x"],
"split_by": ["y"],
"columns": ["New Column 2"],
"filter": [["x", "<", 0.0]],
"sort": [],
"expressions": {
"x": "-\"index\" % 80",
"y": "floor(\"index\" / 80)",
"New Column 2": "-floor(\"index\" / 80) * 20 - \"color\""
},
"aggregates": { "New Column 2": "avg" }
},
{
"plugin": "X/Y Scatter",
"plugin_config": {},
"title": "Scatter Cam",
"group_by": ["x", "y"],
"split_by": [],
"columns": ["x", "New Column 2", "color", null, null, null, null],
"filter": [["x", "<", 0.0]],
"sort": [],
"expressions": {
"New Column 2": "-floor(\"index\" / 80) * 50 - \"color\"",
"x": "-\"index\" % 80",
"y": "floor(\"index\" / 80)"
},
"aggregates": { "x": "avg", "New Column 2": "avg" }
},
{
"plugin": "Datagrid",
"plugin_config": {
"editable": false,
"scroll_lock": false
},
"title": "Raw Stream",
"group_by": [],
"split_by": [],
"columns": ["index", "color"],
"filter": [],
"sort": [],
"expressions": {},
"aggregates": {}
}
]

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