290 lines
15 KiB
TypeScript
290 lines
15 KiB
TypeScript
import {memo, useCallback, useEffect, useMemo, useRef, useState} from "react";
|
||
import {Group, Panel, Separator} from "react-resizable-panels";
|
||
|
||
type Plot = {
|
||
id: string;
|
||
title: string;
|
||
category: string;
|
||
description: string;
|
||
dimension: "2D" | "3D";
|
||
websocket: string;
|
||
schema: string;
|
||
};
|
||
type Option = {value: string; label: string};
|
||
type Field = {key: string; description: string; type: "boolean" | "integer" | "number" | "select" | "array" | "object"; value: unknown; options?: Option[]};
|
||
type Schema = {prop: Field[]; state: Field[]};
|
||
type Stream_Status = "CONNECTING" | "LIVE" | "OFFLINE";
|
||
|
||
const protocol_header_size = 24;
|
||
const frame_interval_ms = 1000 / 30;
|
||
|
||
function socket_url(path: string) {
|
||
return `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}${path}`;
|
||
}
|
||
|
||
function draw_pixels(canvas: HTMLCanvasElement, bytes: ArrayBuffer) {
|
||
if (bytes.byteLength < protocol_header_size) return;
|
||
const view = new DataView(bytes);
|
||
if (view.getUint32(0, true) !== 0x41544852 || view.getUint16(4, true) !== 1) return;
|
||
const width = view.getUint32(8, true);
|
||
const height = view.getUint32(12, true);
|
||
const pixels = new Uint8ClampedArray(bytes, protocol_header_size);
|
||
if (pixels.byteLength !== width * height * 4) return;
|
||
if (canvas.width !== width || canvas.height !== height) {
|
||
canvas.width = width;
|
||
canvas.height = height;
|
||
}
|
||
canvas.getContext("2d", {alpha: false})?.putImageData(new ImageData(pixels, width, height), 0, 0);
|
||
}
|
||
|
||
function use_plot_stream(plot: Plot, canvas_ref: React.RefObject<HTMLCanvasElement | null>) {
|
||
const [status, set_status] = useState<Stream_Status>("CONNECTING");
|
||
const socket_ref = useRef<WebSocket | null>(null);
|
||
|
||
useEffect(() => {
|
||
let stopped = false;
|
||
let animation = 0;
|
||
let previous_frame_time = 0;
|
||
const socket = new WebSocket(socket_url(plot.websocket));
|
||
socket_ref.current = socket;
|
||
socket.binaryType = "arraybuffer";
|
||
socket.onopen = () => set_status("LIVE");
|
||
socket.onclose = () => set_status("OFFLINE");
|
||
socket.onmessage = event => {
|
||
if (event.data instanceof ArrayBuffer && canvas_ref.current) draw_pixels(canvas_ref.current, event.data);
|
||
};
|
||
const tick = (time: number) => {
|
||
const canvas = canvas_ref.current;
|
||
if (!stopped && socket.readyState === WebSocket.OPEN && canvas && time - previous_frame_time >= frame_interval_ms) {
|
||
const bounds = canvas.getBoundingClientRect();
|
||
const visible = bounds.bottom > 0 && bounds.top < innerHeight && bounds.right > 0 && bounds.left < innerWidth;
|
||
if (visible && bounds.width > 0 && bounds.height > 0) {
|
||
const scale = devicePixelRatio;
|
||
socket.send(JSON.stringify({
|
||
time,
|
||
width: Math.round(bounds.width * scale),
|
||
height: Math.round(bounds.height * scale)
|
||
}));
|
||
previous_frame_time = time;
|
||
}
|
||
}
|
||
if (!stopped) animation = requestAnimationFrame(tick);
|
||
};
|
||
animation = requestAnimationFrame(tick);
|
||
return () => {
|
||
stopped = true;
|
||
cancelAnimationFrame(animation);
|
||
socket_ref.current = null;
|
||
socket.close();
|
||
};
|
||
}, [plot.websocket, canvas_ref]);
|
||
|
||
const send_wheel = useCallback((event: WheelEvent) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
const socket = socket_ref.current;
|
||
if (!socket || socket.readyState !== WebSocket.OPEN) return;
|
||
const canvas = canvas_ref.current;
|
||
if (!canvas) return;
|
||
const bounds = canvas.getBoundingClientRect();
|
||
const delta_scale = event.deltaMode === WheelEvent.DOM_DELTA_LINE ? 16 : event.deltaMode === WheelEvent.DOM_DELTA_PAGE ? bounds.height : 1;
|
||
socket.send(JSON.stringify({
|
||
time: performance.now(),
|
||
width: Math.round(bounds.width * devicePixelRatio),
|
||
height: Math.round(bounds.height * devicePixelRatio),
|
||
wheel: {
|
||
x: (event.clientX - bounds.left) * devicePixelRatio,
|
||
y: (event.clientY - bounds.top) * devicePixelRatio,
|
||
delta_y: Math.max(-120, Math.min(120, -event.deltaY * delta_scale))
|
||
}
|
||
}));
|
||
}, [canvas_ref]);
|
||
|
||
useEffect(() => {
|
||
const canvas = canvas_ref.current;
|
||
if (!canvas) return;
|
||
canvas.addEventListener("wheel", send_wheel, {passive: false});
|
||
return () => canvas.removeEventListener("wheel", send_wheel);
|
||
}, [canvas_ref, send_wheel]);
|
||
|
||
return status;
|
||
}
|
||
|
||
function Json_Control({field, on_change}: {field: Field; on_change: (value: unknown) => void}) {
|
||
const [draft, set_draft] = useState(() => JSON.stringify(field.value, null, 2));
|
||
const [invalid, set_invalid] = useState(false);
|
||
useEffect(() => set_draft(JSON.stringify(field.value, null, 2)), [field.value]);
|
||
const apply = () => {
|
||
try {
|
||
on_change(JSON.parse(draft));
|
||
set_invalid(false);
|
||
} catch {
|
||
set_invalid(true);
|
||
}
|
||
};
|
||
return <label className="control controlJson" title={field.description}>
|
||
<span>{field.key}</span>
|
||
<textarea value={draft} onChange={event => set_draft(event.target.value)} onBlur={apply}/>
|
||
<small className={invalid ? "error" : ""}>{invalid ? "Invalid JSON" : "Changes apply when focus leaves the field."}</small>
|
||
</label>;
|
||
}
|
||
|
||
function Field_Control({field, on_change}: {field: Field; on_change: (value: unknown) => void}) {
|
||
if (field.type === "object" || field.type === "array") return <Json_Control field={field} on_change={on_change}/>;
|
||
if (field.type === "boolean") return <label className="control controlBoolean" title={field.description}>
|
||
<span>{field.key}</span><input type="checkbox" checked={Boolean(field.value)} onChange={event => on_change(event.target.checked)}/>
|
||
</label>;
|
||
if (field.type === "select") return <label className="control" title={field.description}>
|
||
<span>{field.key}</span>
|
||
<select value={String(field.value ?? "")} onChange={event => on_change(event.target.value)}>
|
||
{field.options?.map(option => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||
</select>
|
||
</label>;
|
||
return <label className="control" title={field.description}>
|
||
<span>{field.key}</span><input type="number" value={String(field.value ?? "")} onChange={event => on_change(Number(event.target.value))}/>
|
||
</label>;
|
||
}
|
||
|
||
function State_Value({value}: {value: unknown}) {
|
||
if (value !== null && typeof value === "object") return <pre>{JSON.stringify(value, null, 2)}</pre>;
|
||
return <code>{String(value ?? "—")}</code>;
|
||
}
|
||
|
||
function Workspace_Header({plot, label, count, on_hide}: {plot: Plot; label: string; count: number; on_hide: () => void}) {
|
||
return <header className="workspaceHeader">
|
||
<div><span className="eyebrow">{plot.category} · {plot.dimension}</span><h2>{label}</h2><p>{plot.title}</p></div>
|
||
<span className="fieldCount">{count}</span>
|
||
<button className="iconButton" onClick={on_hide} aria-label={`Hide ${label}`}>×</button>
|
||
</header>;
|
||
}
|
||
|
||
function Inspector_Workspace({plot, prop_visible, state_visible, on_hide_prop, on_hide_state}: {
|
||
plot: Plot;
|
||
prop_visible: boolean;
|
||
state_visible: boolean;
|
||
on_hide_prop: () => void;
|
||
on_hide_state: () => void;
|
||
}) {
|
||
const [schema, set_schema] = useState<Schema | null>(null);
|
||
const [busy, set_busy] = useState(true);
|
||
const load_schema = useCallback(async () => {
|
||
set_busy(true);
|
||
try {
|
||
const response = await fetch(plot.schema);
|
||
set_schema(await response.json());
|
||
} finally {
|
||
set_busy(false);
|
||
}
|
||
}, [plot.schema]);
|
||
|
||
useEffect(() => { void load_schema(); }, [load_schema]);
|
||
useEffect(() => {
|
||
if (!state_visible) return;
|
||
const interval = window.setInterval(() => void load_schema(), 1000);
|
||
return () => window.clearInterval(interval);
|
||
}, [state_visible, load_schema]);
|
||
|
||
const update = async (field: Field, value: unknown) => {
|
||
const response = await fetch(`/plot/${encodeURIComponent(plot.id)}/prop/${encodeURIComponent(field.key)}`, {
|
||
method: "PUT",
|
||
headers: {"Content-Type": "application/json"},
|
||
body: JSON.stringify(value)
|
||
});
|
||
const result = await response.json();
|
||
if (result.success) {
|
||
set_schema(current => current ? {
|
||
...current,
|
||
prop: current.prop.map(item => item.key === field.key ? {...item, value: result.value} : item)
|
||
} : current);
|
||
}
|
||
};
|
||
|
||
if (prop_visible && state_visible) return <Group orientation="vertical" id="inspector-sections" className="inspectorGroup">
|
||
<Panel id="prop" defaultSize="58" minSize={220}>
|
||
<section className="workspacePane" aria-label={`${plot.title} editable properties`}>
|
||
<Workspace_Header plot={plot} label="Editable Prop" count={schema?.prop.length ?? 0} on_hide={on_hide_prop}/>
|
||
<div className="workspaceBody">{busy && !schema ? <p className="muted">Loading schema…</p> : <div className="propGrid">
|
||
{schema?.prop.map(field => <Field_Control key={field.key} field={field} on_change={value => void update(field, value)}/>)}
|
||
</div>}</div>
|
||
</section>
|
||
</Panel>
|
||
<Separator className="resizeHandle horizontal"/>
|
||
<Panel id="state" defaultSize="42" minSize={180}>
|
||
<section className="workspacePane" aria-label={`${plot.title} published state`}>
|
||
<Workspace_Header plot={plot} label="Published State" count={schema?.state.length ?? 0} on_hide={on_hide_state}/>
|
||
<div className="workspaceBody"><dl className="stateList">{schema?.state.map(field => <div key={field.key} title={field.description}>
|
||
<dt>{field.key}</dt><dd><State_Value value={field.value}/></dd>
|
||
</div>)}</dl></div>
|
||
</section>
|
||
</Panel>
|
||
</Group>;
|
||
|
||
const show_prop = prop_visible;
|
||
return <section className="workspacePane" aria-label={`${plot.title} ${show_prop ? "editable properties" : "published state"}`}>
|
||
<Workspace_Header plot={plot} label={show_prop ? "Editable Prop" : "Published State"} count={show_prop ? schema?.prop.length ?? 0 : schema?.state.length ?? 0} on_hide={show_prop ? on_hide_prop : on_hide_state}/>
|
||
<div className="workspaceBody">{show_prop ? <div className="propGrid">
|
||
{schema?.prop.map(field => <Field_Control key={field.key} field={field} on_change={value => void update(field, value)}/>)}
|
||
</div> : <dl className="stateList">{schema?.state.map(field => <div key={field.key} title={field.description}>
|
||
<dt>{field.key}</dt><dd><State_Value value={field.value}/></dd>
|
||
</div>)}</dl>}</div>
|
||
</section>;
|
||
}
|
||
|
||
const Plot_Card = memo(function Plot_Card({plot, selected, on_inspect}: {plot: Plot; selected: boolean; on_inspect: (plot: Plot) => void}) {
|
||
const canvas_ref = useRef<HTMLCanvasElement>(null);
|
||
const status = use_plot_stream(plot, canvas_ref);
|
||
return <article className={`card${selected ? " selected" : ""}`} data-plot-id={plot.id}>
|
||
<header><div><span className="eyebrow">{plot.category} · {plot.dimension}</span><h2>{plot.title}</h2></div><span className="status">{status}</span></header>
|
||
{plot.description ? <p>{plot.description}</p> : null}
|
||
<div className="plotViewport"><canvas ref={canvas_ref}/></div>
|
||
<button className="inspect" onClick={() => on_inspect(plot)}>{selected ? "Editing Prop & State" : "Inspect Prop & State"}</button>
|
||
</article>;
|
||
});
|
||
|
||
export function App() {
|
||
const [plots, set_plots] = useState<Plot[]>([]);
|
||
const [category, set_category] = useState("All");
|
||
const [selected, set_selected] = useState<Plot | null>(null);
|
||
const [prop_visible, set_prop_visible] = useState(true);
|
||
const [state_visible, set_state_visible] = useState(true);
|
||
useEffect(() => { void fetch("/plot").then(response => response.json()).then(set_plots); }, []);
|
||
const categories = useMemo(() => ["All", ...new Set(plots.map(plot => plot.category))], [plots]);
|
||
const visible = category === "All" ? plots : plots.filter(plot => plot.category === category);
|
||
const displayed = selected && visible.some(plot => plot.id === selected.id)
|
||
? [selected, ...visible.filter(plot => plot.id !== selected.id)]
|
||
: visible;
|
||
const workspace_visible = selected !== null && (prop_visible || state_visible);
|
||
const inspect = (plot: Plot) => {
|
||
set_selected(plot);
|
||
set_prop_visible(true);
|
||
set_state_visible(true);
|
||
};
|
||
|
||
const gallery = <section className="galleryPanel">
|
||
<header className="topbar">
|
||
<div><span className="eyebrow">AETHERA RENDER LAB</span><h1>Live chart gallery</h1></div>
|
||
<div className="topbarActions">
|
||
{selected ? <>
|
||
<span className="selectionName">Editing <strong>{selected.title}</strong></span>
|
||
<button className={prop_visible ? "active" : ""} onClick={() => set_prop_visible(value => !value)}>Prop</button>
|
||
<button className={state_visible ? "active" : ""} onClick={() => set_state_visible(value => !value)}>State</button>
|
||
<button onClick={() => set_selected(null)}>Close</button>
|
||
</> : <span className="muted">Choose a plot to inspect it beside the live canvas.</span>}
|
||
</div>
|
||
</header>
|
||
<nav>{categories.map(value => <button key={value} className={category === value ? "active" : ""} onClick={() => set_category(value)}>{value}</button>)}</nav>
|
||
<section className="grid">{displayed.map(plot => <Plot_Card key={plot.id} plot={plot} selected={selected?.id === plot.id} on_inspect={inspect}/>)}</section>
|
||
</section>;
|
||
|
||
if (!workspace_visible) return <div className="appShell">{gallery}</div>;
|
||
return <div className="appShell"><Group orientation="horizontal" id="gallery-workspace" className="rootGroup">
|
||
<Panel id="gallery" defaultSize="62" minSize={420}>{gallery}</Panel>
|
||
<Separator className="resizeHandle vertical"/>
|
||
<Panel id="inspector" defaultSize="38" minSize={340} maxSize="65" groupResizeBehavior="preserve-pixel-size">
|
||
<aside className="inspector" aria-label={`${selected.title} inspector`}>
|
||
<Inspector_Workspace plot={selected} prop_visible={prop_visible} state_visible={state_visible} on_hide_prop={() => set_prop_visible(false)} on_hide_state={() => set_state_visible(false)}/>
|
||
</aside>
|
||
</Panel>
|
||
</Group></div>;
|
||
}
|