web_server 初步改
This commit is contained in:
+118
-12
@@ -1,14 +1,120 @@
|
||||
import {memo, useEffect, useMemo, useRef, useState} from "react";
|
||||
type Graph = {id: string; title: string; category: string; description: string; dimension: "2D" | "3D"; websocket: string; state: string; descriptor: string};
|
||||
type Field = {name: string; value_type: string; editable: boolean; children?: Field[]; presentation: {label: string; description?: string; control: string; options?: Array<{value: string; label: string}>}};
|
||||
type Descriptor = {state: {fields: Field[]}; prop: {fields: Field[]}; state_api: string; prop_api: string};
|
||||
|
||||
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; type: "boolean" | "integer" | "number" | "select" | "array" | "object"; value: unknown; options?: Option[]};
|
||||
type Schema = {prop: Field[]; state: Field[]};
|
||||
|
||||
const protocolHeaderSize = 24;
|
||||
function socketUrl(path: string) { return `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}${path}`; }
|
||||
function drawPixels(canvas: HTMLCanvasElement, bytes: ArrayBuffer) { if (bytes.byteLength < protocolHeaderSize) 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, protocolHeaderSize); 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 useGraphStream(graph: Graph, canvasRef: React.RefObject<HTMLCanvasElement | null>) { const statusRef = useRef<HTMLSpanElement>(null); useEffect(() => { let stopped = false; let animation = 0; const socket = new WebSocket(socketUrl(graph.websocket)); socket.binaryType = "arraybuffer"; socket.onopen = () => { if (statusRef.current) statusRef.current.textContent = "LIVE"; }; socket.onclose = () => { if (statusRef.current) statusRef.current.textContent = "OFFLINE"; }; socket.onmessage = event => { if (event.data instanceof ArrayBuffer && canvasRef.current) drawPixels(canvasRef.current, event.data); }; const tick = (time: number) => { const canvas = canvasRef.current; if (!stopped && socket.readyState === WebSocket.OPEN && canvas) socket.send(JSON.stringify({time, width: Math.max(320, canvas.clientWidth * devicePixelRatio), height: Math.max(220, canvas.clientHeight * devicePixelRatio)})); if (!stopped) animation = requestAnimationFrame(tick); }; animation = requestAnimationFrame(tick); return () => { stopped = true; cancelAnimationFrame(animation); socket.close(); }; }, [graph.websocket, canvasRef]); return statusRef; }
|
||||
function JsonControl({field, value, onChange}: {field: Field; value: unknown; onChange: (value: unknown) => void}) { const [draft, setDraft] = useState(() => JSON.stringify(value, null, 2)); const [invalid, setInvalid] = useState(false); useEffect(() => setDraft(JSON.stringify(value, null, 2)), [value]); const apply = () => { try { onChange(JSON.parse(draft)); setInvalid(false); } catch { setInvalid(true); } }; return <label className="control controlJson"><span>{field.presentation.label}</span><textarea value={draft} onChange={event => setDraft(event.target.value)} onBlur={apply}/><small className={invalid ? "error" : ""}>{invalid ? "Invalid JSON" : field.presentation.description ?? "Edit as JSON; changes apply when focus leaves the field."}</small></label>; }
|
||||
function FieldControl({field, value, onChange}: {field: Field; value: unknown; onChange: (value: unknown) => void}) { if (field.value_type === "object" || field.value_type === "array") return <JsonControl field={field} value={value} onChange={onChange}/>; const control = field.presentation.control === "automatic" ? field.value_type === "boolean" ? "boolean" : field.value_type === "number" || field.value_type === "integer" ? "number" : field.presentation.options ? "select" : "text" : field.presentation.control; if (control === "boolean") return <label className="control controlBoolean"><span>{field.presentation.label}</span><input type="checkbox" checked={Boolean(value)} onChange={event => onChange(event.target.checked)}/></label>; if (control === "select" && field.presentation.options) return <label className="control"><span>{field.presentation.label}</span><select value={String(value ?? "")} onChange={event => onChange(event.target.value)}>{field.presentation.options.map(option => <option key={option.value} value={option.value}>{option.label}</option>)}</select></label>; return <label className="control"><span>{field.presentation.label}</span><input type={control === "number" ? "number" : "text"} value={String(value ?? "")} onChange={event => onChange(control === "number" ? Number(event.target.value) : event.target.value)}/><small>{field.presentation.description}</small></label>; }
|
||||
function StateValue({value}: {value: unknown}) { if (value !== null && typeof value === "object") return <pre>{JSON.stringify(value, null, 2)}</pre>; return <code>{String(value ?? "—")}</code>; }
|
||||
function InspectorSidebar({graph, onClose}: {graph: Graph; onClose: () => void}) { const [descriptor, setDescriptor] = useState<Descriptor | null>(null); const [tab, setTab] = useState<"prop" | "state">("prop"); const [state, setState] = useState<Record<string, unknown>>({}); const [prop, setProp] = useState<Record<string, unknown>>({}); const [busy, setBusy] = useState(true); useEffect(() => { let active = true; setBusy(true); void fetch(graph.descriptor).then(response => response.json()).then((value: Descriptor) => { if (!active) return; setDescriptor(value); return fetch(value.prop_api); }).then(response => response?.json()).then(value => { if (active && value) setProp(value); }).finally(() => { if (active) setBusy(false); }); return () => { active = false; }; }, [graph]); const loadState = async () => { if (!descriptor) return; setBusy(true); try { setState(await fetch(descriptor.state_api).then(response => response.json())); } finally { setBusy(false); } }; useEffect(() => { if (tab === "state") void loadState(); }, [tab, descriptor]); const update = async (name: string, value: unknown) => { const previous = prop; setProp(current => ({...current, [name]: value})); const result = await fetch(descriptor!.prop_api, {method: "PATCH", headers: {"Content-Type": "application/json"}, body: JSON.stringify({[name]: value})}).then(response => response.json()); if (result.success && result.prop) setProp(result.prop); else setProp(previous); }; const fields = descriptor?.prop.fields ?? []; return <><button className="backdrop" aria-label="Close inspector" onClick={onClose}/><aside className="sidebar" aria-label={`${graph.title} inspector`}><header><div><span className="eyebrow">{graph.category} · {graph.dimension}</span><h2>{graph.title}</h2></div><button className="close" onClick={onClose} aria-label="Close">×</button></header><div className="tabs"><button className={tab === "prop" ? "active" : ""} onClick={() => setTab("prop")}>Editable Prop <span>{fields.length}</span></button><button className={tab === "state" ? "active" : ""} onClick={() => setTab("state")}>Published State <span>{descriptor?.state.fields.length ?? 0}</span></button></div><div className="sidebarBody">{busy && !descriptor ? <p className="muted">Loading descriptor…</p> : tab === "prop" ? <section className="propGrid">{fields.map(field => <FieldControl key={field.name} field={field} value={prop[field.name]} onChange={value => void update(field.name, value)}/>)}</section> : <section><div className="stateHeading"><p>Complete published State</p><button onClick={() => void loadState()}>Refresh</button></div><dl className="stateList">{descriptor?.state.fields.map(field => <div key={field.name}><dt>{field.presentation.label}</dt><dd><StateValue value={state[field.name]}/></dd></div>)}</dl></section>}</div></aside></>; }
|
||||
const GraphCard = memo(function GraphCard({graph, onInspect}: {graph: Graph; onInspect: (graph: Graph) => void}) { const canvasRef = useRef<HTMLCanvasElement>(null); const statusRef = useGraphStream(graph, canvasRef); return <article className="card"><header><div><span className="eyebrow">{graph.category} · {graph.dimension}</span><h2>{graph.title}</h2></div><span ref={statusRef} className="status">CONNECTING</span></header><p>{graph.description}</p><canvas ref={canvasRef}/><button className="inspect" onClick={() => onInspect(graph)}>Inspect Prop & State</button></article>; });
|
||||
export function App() { const [graphs, setGraphs] = useState<Graph[]>([]); const [category, setCategory] = useState("All"); const [selected, setSelected] = useState<Graph | null>(null); useEffect(() => { void fetch("/api/graphs").then(response => response.json()).then(setGraphs); }, []); useEffect(() => { document.body.classList.toggle("sidebarOpen", selected !== null); return () => document.body.classList.remove("sidebarOpen"); }, [selected]); const categories = useMemo(() => ["All", ...new Set(graphs.map(graph => graph.category))], [graphs]); const visible = category === "All" ? graphs : graphs.filter(graph => graph.category === category); return <><main><section className="hero"><div><span className="eyebrow">AETHERA RENDER LAB</span><h1>Live chart gallery</h1><p>Browser time drives each isolated Scene. Completed pixels return directly from the Scene callback; properties and state are fetched only when inspected.</p></div><div className="heroMetric"><strong>{graphs.length}</strong><span>live components</span></div></section><nav>{categories.map(value => <button key={value} className={category === value ? "active" : ""} onClick={() => setCategory(value)}>{value}</button>)}</nav><section className="grid">{visible.map(graph => <GraphCard key={graph.id} graph={graph} onInspect={setSelected}/>)}</section></main>{selected ? <InspectorSidebar graph={selected} onClose={() => setSelected(null)}/> : null}</>; }
|
||||
|
||||
function socketUrl(path: string) {
|
||||
return `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}${path}`;
|
||||
}
|
||||
|
||||
function drawPixels(canvas: HTMLCanvasElement, bytes: ArrayBuffer) {
|
||||
if (bytes.byteLength < protocolHeaderSize) 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, protocolHeaderSize);
|
||||
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 usePlotStream(plot: Plot, canvasRef: React.RefObject<HTMLCanvasElement | null>) {
|
||||
const statusRef = useRef<HTMLSpanElement>(null);
|
||||
useEffect(() => {
|
||||
let stopped = false;
|
||||
let animation = 0;
|
||||
const socket = new WebSocket(socketUrl(plot.websocket));
|
||||
socket.binaryType = "arraybuffer";
|
||||
socket.onopen = () => { if (statusRef.current) statusRef.current.textContent = "LIVE"; };
|
||||
socket.onclose = () => { if (statusRef.current) statusRef.current.textContent = "OFFLINE"; };
|
||||
socket.onmessage = event => { if (event.data instanceof ArrayBuffer && canvasRef.current) drawPixels(canvasRef.current, event.data); };
|
||||
const tick = (time: number) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!stopped && socket.readyState === WebSocket.OPEN && canvas) {
|
||||
socket.send(JSON.stringify({time, width: Math.max(320, canvas.clientWidth * devicePixelRatio), height: Math.max(220, canvas.clientHeight * devicePixelRatio)}));
|
||||
}
|
||||
if (!stopped) animation = requestAnimationFrame(tick);
|
||||
};
|
||||
animation = requestAnimationFrame(tick);
|
||||
return () => { stopped = true; cancelAnimationFrame(animation); socket.close(); };
|
||||
}, [plot.websocket, canvasRef]);
|
||||
return statusRef;
|
||||
}
|
||||
|
||||
function JsonControl({field, onChange}: {field: Field; onChange: (value: unknown) => void}) {
|
||||
const [draft, setDraft] = useState(() => JSON.stringify(field.value, null, 2));
|
||||
const [invalid, setInvalid] = useState(false);
|
||||
useEffect(() => setDraft(JSON.stringify(field.value, null, 2)), [field.value]);
|
||||
const apply = () => {
|
||||
try { onChange(JSON.parse(draft)); setInvalid(false); }
|
||||
catch { setInvalid(true); }
|
||||
};
|
||||
return <label className="control controlJson"><span>{field.key}</span><textarea value={draft} onChange={event => setDraft(event.target.value)} onBlur={apply}/><small className={invalid ? "error" : ""}>{invalid ? "Invalid JSON" : "Changes apply when focus leaves the field."}</small></label>;
|
||||
}
|
||||
|
||||
function FieldControl({field, onChange}: {field: Field; onChange: (value: unknown) => void}) {
|
||||
if (field.type === "object" || field.type === "array") return <JsonControl field={field} onChange={onChange}/>;
|
||||
if (field.type === "boolean") return <label className="control controlBoolean"><span>{field.key}</span><input type="checkbox" checked={Boolean(field.value)} onChange={event => onChange(event.target.checked)}/></label>;
|
||||
if (field.type === "select") return <label className="control"><span>{field.key}</span><select value={String(field.value ?? "")} onChange={event => onChange(event.target.value)}>{field.options?.map(option => <option key={option.value} value={option.value}>{option.label}</option>)}</select></label>;
|
||||
return <label className="control"><span>{field.key}</span><input type="number" value={String(field.value ?? "")} onChange={event => onChange(Number(event.target.value))}/></label>;
|
||||
}
|
||||
|
||||
function StateValue({value}: {value: unknown}) {
|
||||
if (value !== null && typeof value === "object") return <pre>{JSON.stringify(value, null, 2)}</pre>;
|
||||
return <code>{String(value ?? "—")}</code>;
|
||||
}
|
||||
|
||||
function InspectorSidebar({plot, onClose}: {plot: Plot; onClose: () => void}) {
|
||||
const [schema, setSchema] = useState<Schema | null>(null);
|
||||
const [tab, setTab] = useState<"prop" | "state">("prop");
|
||||
const [busy, setBusy] = useState(true);
|
||||
const loadSchema = async () => {
|
||||
setBusy(true);
|
||||
try { setSchema(await fetch(plot.schema).then(response => response.json())); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
useEffect(() => { void loadSchema(); }, [plot.schema]);
|
||||
useEffect(() => { if (tab === "state") void loadSchema(); }, [tab]);
|
||||
const update = async (field: Field, value: unknown) => {
|
||||
const result = await fetch(`/plot/${encodeURIComponent(plot.id)}/prop/${encodeURIComponent(field.key)}`, {
|
||||
method: "PUT",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify(value)
|
||||
}).then(response => response.json());
|
||||
if (result.success) setSchema(current => current ? {...current, prop: current.prop.map(item => item.key === field.key ? {...item, value: result.value} : item)} : current);
|
||||
};
|
||||
return <><button className="backdrop" aria-label="Close inspector" onClick={onClose}/><aside className="sidebar" aria-label={`${plot.title} inspector`}><header><div><span className="eyebrow">{plot.category} · {plot.dimension}</span><h2>{plot.title}</h2></div><button className="close" onClick={onClose} aria-label="Close">×</button></header><div className="tabs"><button className={tab === "prop" ? "active" : ""} onClick={() => setTab("prop")}>Editable Prop <span>{schema?.prop.length ?? 0}</span></button><button className={tab === "state" ? "active" : ""} onClick={() => setTab("state")}>Published State <span>{schema?.state.length ?? 0}</span></button></div><div className="sidebarBody">{busy && !schema ? <p className="muted">Loading schema…</p> : tab === "prop" ? <section className="propGrid">{schema?.prop.map(field => <FieldControl key={field.key} field={field} onChange={value => void update(field, value)}/>)}</section> : <section><div className="stateHeading"><p>Current published State</p><button onClick={() => void loadSchema()}>Refresh</button></div><dl className="stateList">{schema?.state.map(field => <div key={field.key}><dt>{field.key}</dt><dd><StateValue value={field.value}/></dd></div>)}</dl></section>}</div></aside></>;
|
||||
}
|
||||
|
||||
const PlotCard = memo(function PlotCard({plot, onInspect}: {plot: Plot; onInspect: (plot: Plot) => void}) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const statusRef = usePlotStream(plot, canvasRef);
|
||||
return <article className="card"><header><div><span className="eyebrow">{plot.category} · {plot.dimension}</span><h2>{plot.title}</h2></div><span ref={statusRef} className="status">CONNECTING</span></header><p>{plot.description}</p><canvas ref={canvasRef}/><button className="inspect" onClick={() => onInspect(plot)}>Inspect Prop & State</button></article>;
|
||||
});
|
||||
|
||||
export function App() {
|
||||
const [plots, setPlots] = useState<Plot[]>([]);
|
||||
const [category, setCategory] = useState("All");
|
||||
const [selected, setSelected] = useState<Plot | null>(null);
|
||||
useEffect(() => { void fetch("/plot").then(response => response.json()).then(setPlots); }, []);
|
||||
useEffect(() => { document.body.classList.toggle("sidebarOpen", selected !== null); return () => document.body.classList.remove("sidebarOpen"); }, [selected]);
|
||||
const categories = useMemo(() => ["All", ...new Set(plots.map(plot => plot.category))], [plots]);
|
||||
const visible = category === "All" ? plots : plots.filter(plot => plot.category === category);
|
||||
return <><main><section className="hero"><div><span className="eyebrow">AETHERA RENDER LAB</span><h1>Live chart gallery</h1><p>Each Plot binds a real engine Scene to its WebSocket stream and Structive-generated property schema.</p></div><div className="heroMetric"><strong>{plots.length}</strong><span>live components</span></div></section><nav>{categories.map(value => <button key={value} className={category === value ? "active" : ""} onClick={() => setCategory(value)}>{value}</button>)}</nav><section className="grid">{visible.map(plot => <PlotCard key={plot.id} plot={plot} onInspect={setSelected}/>)}</section></main>{selected ? <InspectorSidebar plot={selected} onClose={() => setSelected(null)}/> : null}</>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user