Files
Aethera/webapp_gallery/src/app.tsx
T
2026-08-21 15:12:22 +08:00

290 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>;
}