summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/AddNodeMenu.tsx21
-rw-r--r--src/NodeEditor.module.css5
-rw-r--r--src/NodeEditor.tsx245
-rw-r--r--src/assets/down-arrow.svg3
-rw-r--r--src/assets/right-arrow.svg3
-rw-r--r--src/cssmodules.d.ts4
-rw-r--r--src/dataflow.ts19
-rw-r--r--src/index.css28
-rw-r--r--src/index.tsx13
-rw-r--r--src/node.module.css145
-rw-r--r--src/node.tsx208
-rw-r--r--src/nodes/CombineXYZ.tsx30
-rw-r--r--src/nodes/Fourier.tsx25
-rw-r--r--src/nodes/Intersperse.tsx27
-rw-r--r--src/nodes/Linspace.tsx29
-rw-r--r--src/nodes/Math.tsx157
-rw-r--r--src/nodes/Plot.tsx35
-rw-r--r--src/nodes/SeparateXYZ.tsx30
-rw-r--r--src/nodes/Viewer.tsx28
-rw-r--r--src/nodes/index.ts21
-rw-r--r--src/util.ts2
-rw-r--r--src/wasm.ts9
22 files changed, 1087 insertions, 0 deletions
diff --git a/src/AddNodeMenu.tsx b/src/AddNodeMenu.tsx
new file mode 100644
index 0000000..197f56d
--- /dev/null
+++ b/src/AddNodeMenu.tsx
@@ -0,0 +1,21 @@
+import { useId } from 'preact/hooks';
+import { NodeInfo } from './node.tsx';
+
+export interface AddNodeMenuProps {
+ nodes: Record<string, NodeInfo>;
+ onClick?: (NodeInfo) => void;
+}
+
+export const AddNodeMenu = ({ nodes, onClick = _ => {} }: AddNodeMenuProps) => {
+ const id = useId();
+ return (
+ <>
+ <menu id={id} popover>
+ {Object.entries(nodes).map(([name, node]) => (
+ <li><button onClick={() => onClick(node)}>{name}</button></li>
+ ))}
+ </menu>
+ <button popoverTarget={id}>Add</button>
+ </>
+ );
+}; \ No newline at end of file
diff --git a/src/NodeEditor.module.css b/src/NodeEditor.module.css
new file mode 100644
index 0000000..caa549e
--- /dev/null
+++ b/src/NodeEditor.module.css
@@ -0,0 +1,5 @@
+.link {
+ fill: none;
+ stroke: var(--data-float);
+ stroke-width: 2;
+} \ No newline at end of file
diff --git a/src/NodeEditor.tsx b/src/NodeEditor.tsx
new file mode 100644
index 0000000..83d81bd
--- /dev/null
+++ b/src/NodeEditor.tsx
@@ -0,0 +1,245 @@
+import { useMemo, useRef } from 'preact/hooks';
+import { signal, computed, batch, useSignal, useComputed, Signal } from '@preact/signals';
+import { nodeRegistry } from './nodes';
+import { SocketHandlers, SocketHandler, NodeInfo } from './node.tsx';
+import { InputSocket } from './dataflow.ts';
+import { AddNodeMenu } from './AddNodeMenu.tsx';
+import styles from './NodeEditor.module.css';
+
+export const nodeFactory = () => {
+ let nextNodeId = 0;
+ return (x: number, y: number, { component, func, inputs }: NodeInfo<any, any>) => {
+ const mapEntries = (obj: {}, f: (x: [string, any]) => [string, any]) => (
+ Object.fromEntries(Object.entries(obj).map(f))
+ );
+ const instanceInputs = mapEntries(inputs, ([k, v]) => [k, new InputSocket(v)]);
+ const output = computed(() => func(mapEntries(instanceInputs, ([k, v]) => [k, v.value])));
+ return {
+ id: nextNodeId++,
+ component,
+ x: signal(x),
+ y: signal(y),
+ inputs: instanceInputs,
+ outputs: mapEntries(output.value, ([k, _]) => [k, computed(() => output.value[k])]),
+ };
+ };
+};
+
+interface LinkProps {
+ fromX: Signal<number>;
+ fromY: Signal<number>;
+ toX: Signal<number>;
+ toY: Signal<number>;
+}
+
+const Link = ({ fromX, fromY, toX, toY }: LinkProps) => {
+ const c1x = fromX.value + Math.abs(toX.value - fromX.value) / 3;
+ const c2x = toX.value - Math.abs(toX.value - fromX.value) / 3;
+ return (
+ <path
+ class={styles.link}
+ d={`M ${fromX} ${fromY} C ${c1x} ${fromY} ${c2x} ${toY} ${toX} ${toY}`}
+ />
+ );
+};
+
+const getPos = (elem: Element) => {
+ const rect = elem.getBoundingClientRect();
+ const x = rect.x + (rect.right - rect.x) / 2;
+ const y = rect.y + (rect.bottom - rect.y) / 2;
+ return {x, y};
+};
+
+interface LinkData extends LinkProps {
+ from: { nodeId: number, socket: string };
+ to: { nodeId: number, socket: string };
+}
+
+export const NodeEditor = () => {
+ const offsetX = useSignal(0);
+ const offsetY = useSignal(0);
+ const scale = useSignal(1);
+
+ const instantiateNode = useMemo(nodeFactory, []);
+ const svgRef = useRef(null);
+
+ const nodes = useSignal([
+ instantiateNode(100, 100, nodeRegistry['Linspace']),
+ instantiateNode(350, 200, nodeRegistry['Math']),
+ instantiateNode(350, 50, nodeRegistry['Intersperse']),
+ instantiateNode(600, 100, nodeRegistry['Fourier Transform']),
+ instantiateNode(900, 100, nodeRegistry['Viewer']),
+ instantiateNode(900, 250, nodeRegistry['Plot']),
+ ]);
+
+ const currentLink = useSignal<null | Omit<LinkData, 'to'>>(null);
+ const links = useSignal<LinkData[]>([]);
+ const allLinks = useComputed(() => (links.value as LinkProps[]).concat(currentLink.value as LinkProps ?? []));
+
+ const onOutMouseDown: SocketHandler = (nodeId, socket, event) => {
+ event.stopPropagation();
+ const svgRect = svgRef.current?.getBoundingClientRect();
+ const svgX = svgRect?.x ?? 0;
+ const svgY = svgRect?.y ?? 0;
+ const pos = getPos(event.target as Element);
+ pos.x -= svgX;
+ pos.y -= svgY;
+ const node = nodes.value.find(x => x.id === nodeId);
+ if (!node) throw new Error();
+
+ const xOffs = (pos.x - offsetX.value) / scale.value - node.x.value;
+ const yOffs = (pos.y - offsetY.value) / scale.value - node.y.value;
+
+ const fromX = computed(() => node.x.value + xOffs);
+ const fromY = computed(() => node.y.value + yOffs);
+
+ const mouseX = signal(event.clientX);
+ const mouseY = signal(event.clientY);
+ const toX = computed(() => (mouseX.value - svgX - offsetX.value) / scale.value);
+ const toY = computed(() => (mouseY.value - svgY - offsetY.value) / scale.value);
+
+ const onMouseMove = (event: MouseEvent) => batch(() => {
+ mouseX.value += event.movementX;
+ mouseY.value += event.movementY;
+ });
+
+ const onMouseUp = () => {
+ window.removeEventListener('mousemove', onMouseMove);
+ window.removeEventListener('mouseup', onMouseUp);
+ currentLink.value = null;
+ };
+
+ window.addEventListener('mousemove', onMouseMove);
+ window.addEventListener('mouseup', onMouseUp);
+
+ currentLink.value = {from: {nodeId, socket}, fromX, fromY, toX, toY};
+ };
+
+ const onInMouseDown: SocketHandler = (nodeId, socket, event) => {
+ event.stopPropagation();
+ const i = links.value.findIndex(l => l.to.nodeId === nodeId && l.to.socket === socket);
+ if (i == -1) return;
+ const node = nodes.value.find(x => x.id === nodeId);
+ if (!node) throw new Error();
+
+ const svgRect = svgRef.current?.getBoundingClientRect();
+ const svgX = svgRect?.x ?? 0;
+ const svgY = svgRect?.y ?? 0;
+
+ const mouseX = signal(event.clientX);
+ const mouseY = signal(event.clientY);
+ const toX = computed(() => (mouseX.value - svgX - offsetX.value) / scale.value);
+ const toY = computed(() => (mouseY.value - svgY - offsetY.value) / scale.value);
+
+ batch(() => {
+ node.inputs[socket].link.value = null;
+ currentLink.value = {...links.value[i], toX, toY};
+ links.value = links.value.toSpliced(i, 1);
+ });
+
+ const onMouseMove = (event: MouseEvent) => batch(() => {
+ mouseX.value += event.movementX;
+ mouseY.value += event.movementY;
+ });
+
+ const onMouseUp = () => {
+ window.removeEventListener('mousemove', onMouseMove);
+ window.removeEventListener('mouseup', onMouseUp);
+ currentLink.value = null;
+ };
+
+ window.addEventListener('mousemove', onMouseMove);
+ window.addEventListener('mouseup', onMouseUp);
+ };
+
+ const onInMouseUp: SocketHandler = (nodeId, socket, event) => {
+ if (!currentLink.value) return;
+ event.stopPropagation();
+ const fromNode = nodes.value.find(x => x.id === currentLink.value!.from.nodeId);
+ const node = nodes.value.find(x => x.id === nodeId);
+ if (!node || !fromNode) throw new Error();
+
+ const svgRect = svgRef.current?.getBoundingClientRect();
+ const svgX = svgRect?.x ?? 0;
+ const svgY = svgRect?.y ?? 0;
+ const pos = getPos(event.target as Element);
+ pos.x -= svgX;
+ pos.y -= svgY;
+
+ const xOffs = (pos.x - offsetX.value) / scale.value - node.x.value;
+ const yOffs = (pos.y - offsetY.value) / scale.value - node.y.value;
+
+ const toX = computed(() => node.x.value + xOffs);
+ const toY = computed(() => node.y.value + yOffs);
+
+ batch(() => {
+ node.inputs[socket].link.value = fromNode.outputs[currentLink.value!.from.socket];
+ links.value = [
+ ...links.value.filter(l => l.to.nodeId !== nodeId || l.to.socket !== socket),
+ {...currentLink.value!, to: {nodeId, socket}, toX, toY},
+ ];
+ currentLink.value = null;
+ });
+ };
+
+ const socketHandlers = {
+ onOutMouseDown,
+ onInMouseDown,
+ onInMouseUp,
+ };
+
+ const onBgMouseDown = () => {
+ const onMouseMove = (event: MouseEvent) => batch(() => {
+ offsetX.value += event.movementX;
+ offsetY.value += event.movementY;
+ });
+
+ const onMouseUp = () => {
+ window.removeEventListener('mousemove', onMouseMove);
+ window.removeEventListener('mouseup', onMouseUp);
+ };
+
+ window.addEventListener('mousemove', onMouseMove);
+ window.addEventListener('mouseup', onMouseUp);
+ };
+
+ const onBgWheel = (event: WheelEvent) => batch(() => {
+ const delta = event.deltaY * 0.001;
+ offsetX.value -= (event.clientX - offsetX.value) * delta;
+ offsetY.value -= (event.clientY - offsetY.value) * delta;
+ scale.value *= 1 + delta;
+ });
+
+ return (
+ <>
+ <AddNodeMenu nodes={nodeRegistry} onClick={node => nodes.value = nodes.value.concat(instantiateNode(100, 100, node))} />
+ <svg width="100vw" height="100vh" ref={svgRef} onMouseDown={onBgMouseDown} onWheel={onBgWheel}>
+ <pattern
+ id="bg-grid-major"
+ patternUnits="userSpaceOnUse"
+ x={offsetX} y={offsetY}
+ width={120 * scale.value} height={120 * scale.value}
+ >
+ <pattern id="bg-grid-minor" patternUnits="userSpaceOnUse" x="0" y="0" width="24" height="24">
+ <circle cx="2" cy="2" r="1" fill="#242424" />
+ </pattern>
+ <g transform={`scale(${scale})`}>
+ <rect fill="url(#bg-grid-minor)" x="0" y="0" width="100%" height="100%" />
+ <circle cx="2" cy="2" r="2" fill="#242424" />
+ </g>
+ </pattern>
+ <rect fill="url(#bg-grid-major)" x="0" y="0" width="100%" height="100%" />
+ <g transform={`translate(${offsetX},${offsetY}) scale(${scale})`}>
+ {allLinks.value.map(({fromX, fromY, toX, toY}) => (
+ <Link fromX={fromX} fromY={fromY} toX={toX} toY={toY} />
+ ))}
+ <SocketHandlers.Provider value={socketHandlers}>
+ {nodes.value.map(node => (
+ <node.component id={node.id} x={node.x} y={node.y} inputs={node.inputs} />
+ ))}
+ </SocketHandlers.Provider>
+ </g>
+ </svg>
+ </>
+ );
+}; \ No newline at end of file
diff --git a/src/assets/down-arrow.svg b/src/assets/down-arrow.svg
new file mode 100644
index 0000000..eefe5ef
--- /dev/null
+++ b/src/assets/down-arrow.svg
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" version="2.0" width="12" height="12" viewBox="0 0 16 16">
+ <path fill="none" stroke="white" stroke-width="2" stroke-linecap="square" d="M 2 6 l 6 6 l 6 -6" />
+</svg> \ No newline at end of file
diff --git a/src/assets/right-arrow.svg b/src/assets/right-arrow.svg
new file mode 100644
index 0000000..7ac9997
--- /dev/null
+++ b/src/assets/right-arrow.svg
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" version="2.0" width="12" height="12" viewBox="0 0 16 16">
+ <path fill="none" stroke="white" stroke-width="2" stroke-linecap="square" d="M 6 2 l 6 6 l -6 6" />
+</svg> \ No newline at end of file
diff --git a/src/cssmodules.d.ts b/src/cssmodules.d.ts
new file mode 100644
index 0000000..b6dc193
--- /dev/null
+++ b/src/cssmodules.d.ts
@@ -0,0 +1,4 @@
+declare module '*.css' {
+ const content: Record<string, string>;
+ export default content;
+} \ No newline at end of file
diff --git a/src/dataflow.ts b/src/dataflow.ts
new file mode 100644
index 0000000..ffbab5d
--- /dev/null
+++ b/src/dataflow.ts
@@ -0,0 +1,19 @@
+import { signal, Signal } from '@preact/signals';
+
+export class InputSocket<T> {
+ link: Signal<null | Signal<T>>;
+ manual: Signal<T>;
+
+ constructor(initialValue: T) {
+ this.link = signal(null);
+ this.manual = signal(initialValue);
+ }
+
+ get value() {
+ return this.link.value ? this.link.value.value : this.manual.value;
+ }
+
+ set value(x: T) {
+ this.manual.value = x;
+ }
+} \ No newline at end of file
diff --git a/src/index.css b/src/index.css
new file mode 100644
index 0000000..aac86b5
--- /dev/null
+++ b/src/index.css
@@ -0,0 +1,28 @@
+/*@font-face {
+ font-family: 'Inter';
+ src: url('Inter.ttf');
+}*/
+
+:root {
+ --base: #121212;
+ --surface: #242424;
+ --overlay: #484848;
+ --text: #ffffff;
+ --primary: #1c4c87;
+
+ --data-any: #36c965;
+ --data-float: #a0a0a0;
+ --data-vector: #5e29d9;
+
+ font-family: 'Inter', Helvetica, sans-serif;
+ box-sizing: border-box;
+}
+
+body {
+ background-color: var(--base);
+ color: var(--text);
+ margin: 0;
+ width: 100vw;
+ height: 100vh;
+ overflow: hidden;
+} \ No newline at end of file
diff --git a/src/index.tsx b/src/index.tsx
new file mode 100644
index 0000000..ea83e21
--- /dev/null
+++ b/src/index.tsx
@@ -0,0 +1,13 @@
+import { render } from 'preact';
+import { useErrorBoundary } from 'preact/hooks';
+import { NodeEditor } from './NodeEditor.tsx';
+import './index.css';
+
+export const App = () => {
+ const [error] = useErrorBoundary(error => alert(error));
+ return (
+ <NodeEditor />
+ );
+};
+
+render(<App />, document.body);
diff --git a/src/node.module.css b/src/node.module.css
new file mode 100644
index 0000000..bfd5fb1
--- /dev/null
+++ b/src/node.module.css
@@ -0,0 +1,145 @@
+.foreignObject {
+ width: 0;
+ height: 0;
+ overflow: visible;
+}
+
+.node {
+ background-color: var(--surface);
+ min-width: 180px;
+ font-size: 0.9rem;
+ margin: 4px;
+ padding-bottom: 8px;
+ display: flex;
+ flex-direction: column;
+ border-radius: 6px;
+ user-select: none;
+ --shadow-color: 0deg 0% 0%;
+ box-shadow:
+ 0.1px 0.4px 0.6px hsl(var(--shadow-color) / 0.04),
+ 0.5px 3.3px 4.8px -0.2px hsl(var(--shadow-color) / 0.15),
+ 1.4px 10.2px 14.7px -0.5px hsl(var(--shadow-color) / 0.27);
+
+ &:focus-within {
+ outline: 1px white solid;
+ }
+
+ input[type="number"] {
+ background-color: var(--overlay);
+ border: none;
+ border-radius: 4px;
+ outline: none;
+ color: var(--text);
+ font-size: 1em;
+ text-align: right;
+ width: 100%;
+ margin-right: 12px;
+ padding-top: 3px;
+ padding-bottom: 2px;
+ }
+
+ summary {
+ background-color: var(--primary);
+ padding: 3px;
+ padding-left: 6px;
+ margin-bottom: 4px;
+ border-radius: 6px 6px 0 0;
+ list-style-image: url('assets/down-arrow.svg');
+ }
+ &:not([open]) summary {
+ list-style-image: url('assets/right-arrow.svg');
+ }
+
+ ul {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ }
+
+ ul li {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ padding: 4px 0;
+
+ circle {
+ fill: var(--data-float);
+ .number & { fill: var(--data-float) }
+ .vector & { fill: var(--data-vector) }
+ .select & { fill: var(--data-float) }
+ }
+
+ &.out + &.in {
+ padding-top: 8px;
+ }
+
+ &.out {
+ justify-content: flex-end;
+
+ svg {
+ position: relative;
+ left: 4px;
+ }
+ }
+
+ &.in {
+ justify-content: flex-start;
+
+ svg {
+ position: relative;
+ right: 4px;
+ }
+
+ &.number {
+ + .number { padding-top: 0 }
+ &.linked input { display: none }
+
+ span {
+ position: relative;
+ left: 8px;
+ width: 0;
+ padding-bottom: 2px;
+ }
+ }
+
+ &.vector {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 1px;
+
+ & :first-child {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: 2px;
+ margin-bottom: 4px;
+ .linked & { margin-bottom: 0 }
+ }
+
+ input {
+ width: 89%;
+ margin-left: 8px;
+ margin-right: 16px;
+
+ &:nth-child(2) { border-radius: 4px 4px 0 0 }
+ &:nth-child(3) { border-radius: 0 }
+ &:nth-child(4) { border-radius: 0 0 4px 4px }
+
+ .linked & { display: none }
+ }
+ }
+
+ &.select select {
+ background-color: color-mix(in srgb, var(--base) 50%, var(--surface));
+ color: var(--text);
+ border: 1px solid color-mix(in srgb, var(--overlay) 50%, var(--surface));
+ border-radius: 4px;
+ width: 100%;
+ margin-right: 12px;
+ padding: 3px 1px;
+
+ .linked & { display: none }
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/src/node.tsx b/src/node.tsx
new file mode 100644
index 0000000..211398e
--- /dev/null
+++ b/src/node.tsx
@@ -0,0 +1,208 @@
+import { createContext, FunctionComponent, ComponentChildren } from 'preact';
+import { useContext } from 'preact/hooks';
+import { batch, Signal } from '@preact/signals';
+import { InputSocket } from './dataflow.ts';
+import { cls } from './util.ts';
+import styles from './node.module.css';
+
+export type SocketHandler = (nodeId: number, socket: string, event: MouseEvent) => void;
+export interface SocketHandlers {
+ onOutMouseDown?: SocketHandler;
+ onOutMouseUp?: SocketHandler;
+ onInMouseDown?: SocketHandler;
+ onInMouseUp?: SocketHandler;
+}
+
+export const SocketHandlers = createContext<SocketHandlers>({});
+export const NodeId = createContext<number>(0);
+
+export interface NodeComponentProps<I extends {}> {
+ id: number;
+ x: Signal<number>;
+ y: Signal<number>;
+ inputs: { [Property in keyof I]: InputSocket<I[Property]> };
+}
+export type NodeComponent<I extends {}> = FunctionComponent<NodeComponentProps<I>>;
+
+export interface NodeInfo<I extends {}, O extends {}> {
+ component: NodeComponent<I>;
+ func: (inputs: I) => O;
+ inputs: I;
+}
+
+interface SocketProps {
+ name: string;
+ onMouseDown?: SocketHandler;
+ onMouseUp?: SocketHandler;
+}
+
+const Socket = ({ name, onMouseDown, onMouseUp }: SocketProps) => {
+ const nodeId = useContext(NodeId);
+ const wrap = (func?: SocketHandler) => (event: MouseEvent) => func && func(nodeId, name, event);
+ return (
+ <svg
+ width="10"
+ height="10"
+ viewBox="0 0 16 16"
+ onMouseDown={wrap(onMouseDown)}
+ onMouseUp={wrap(onMouseUp)}
+ >
+ <circle cx="8" cy="8" r="7" stroke="black" stroke-width="1" />
+ </svg>
+ );
+};
+
+const InSocket = ({ name }: { name: string }) => {
+ const handlers = useContext(SocketHandlers);
+ return (
+ <Socket name={name} onMouseDown={handlers.onInMouseDown} onMouseUp={handlers.onInMouseUp} />
+ );
+};
+
+const OutSocket = ({ name }: { name: string }) => {
+ const handlers = useContext(SocketHandlers);
+ return (
+ <Socket name={name} onMouseDown={handlers.onOutMouseDown} onMouseUp={handlers.onOutMouseUp} />
+ );
+};
+
+export interface InputProps<T> {
+ name: string;
+ label: string;
+ value: InputSocket<T>;
+}
+
+export const InputAny = ({ name, label }: Omit<InputProps<any>, 'value'>) => {
+ return (
+ <li class={cls(styles.in)}>
+ <InSocket name={name} />
+ {label}
+ </li>
+ );
+};
+
+export const InputArray = ({ name, label }: Omit<InputProps<any>, 'value'>) => {
+ return (
+ <li class={cls(styles.in, styles.number)}>
+ <InSocket name={name} />
+ {label}
+ </li>
+ );
+};
+
+const InputNum = (parseFunc: (string) => number) => ({ name, label, value }: InputProps<number>) => {
+ const onInput = (event: InputEvent) => {
+ value.value = parseFunc((event.target as HTMLInputElement).value);
+ }
+ return (
+ <li class={cls(styles.in, styles.number, value.link.value && styles.linked)}>
+ <InSocket name={name} />
+ <span>{label}</span>
+ <input type="number" value={value.manual.value} onInput={onInput} />
+ </li>
+ );
+};
+
+export const InputNumber = InputNum(parseFloat);
+export const InputInteger = InputNum(parseInt);
+
+export const InputVector = ({ name, label, value }: InputProps<[number, number, number]>) => {
+ const onInput = (i: 0 | 1 | 2) => (event: InputEvent) => {
+ const newValue: [number, number, number] = [...value.value];
+ newValue[i] = parseFloat((event.target as HTMLInputElement).value);
+ value.value = newValue;
+ };
+ return (
+ <li class={cls(styles.in, styles.vector, value.link.value && styles.linked)}>
+ <div>
+ <InSocket name={name} />
+ {label}
+ </div>
+ <input type="number" value={value.value[0]} onInput={onInput(0)} />
+ <input type="number" value={value.value[1]} onInput={onInput(1)} />
+ <input type="number" value={value.value[2]} onInput={onInput(2)} />
+ </li>
+ );
+};
+
+export interface InputSelectProps extends InputProps<string> {
+ options: string[] | Record<string, string[]>;
+}
+
+export const InputSelect = ({ name, label, value, options }: InputSelectProps) => {
+ const onChange = (event: InputEvent) => {
+ value.value = (event.target as HTMLSelectElement).value;
+ }
+ return (
+ <li class={cls(styles.in, styles.select, value.link.value && styles.linked)}>
+ <InSocket name={name} />
+ <select aria-label={label} onChange={onChange}>
+ {Array.isArray(options)
+ ? options.map(opt => <option value={opt}>{opt}</option>)
+ : Object.entries(options).map(([label, group]) => (
+ <optgroup label={label}>
+ {group.map(opt => <option value={opt}>{opt}</option>)}
+ </optgroup>
+ ))
+ }
+ </select>
+ </li>
+ );
+};
+
+export interface OutputProps {
+ name: string;
+ label: string;
+}
+
+const Output = ({ name, label, type }: OutputProps & { type: string }) => {
+ return (
+ <li class={cls(styles.out, styles[type])}>
+ {label}
+ <OutSocket name={name} />
+ </li>
+ );
+};
+
+export const OutputNumber = (props: OutputProps) => <Output {...props} type="number" />;
+export const OutputVector = (props: OutputProps) => <Output {...props} type="vector" />;
+
+export interface NodeShellProps {
+ id: number;
+ name: string;
+ x: Signal<number>;
+ y: Signal<number>;
+ children: ComponentChildren;
+}
+
+export const NodeShell = ({ id, name, x, y, children }: NodeShellProps) => {
+ const onMouseDown = (event: MouseEvent) => {
+ event.stopPropagation();
+
+ const onMouseMove = (event: MouseEvent) => batch(() => {
+ x.value += event.movementX;
+ y.value += event.movementY;
+ });
+
+ const onMouseUp = () => {
+ window.removeEventListener('mousemove', onMouseMove);
+ window.removeEventListener('mouseup', onMouseUp);
+ };
+
+ window.addEventListener('mousemove', onMouseMove);
+ window.addEventListener('mouseup', onMouseUp);
+ };
+
+ return (
+ <foreignObject x={x} y={y} width="0" height="0" class={styles.foreignObject}>
+ <details open tabindex={0} class={styles.node} onMouseDown={onMouseDown}>
+ <summary><span onClick={e => e.stopPropagation()}>{name}</span></summary>
+ <ul>
+ <NodeId.Provider value={id}>
+ {children}
+ </NodeId.Provider>
+ </ul>
+ </details>
+ </foreignObject>
+ );
+}; \ No newline at end of file
diff --git a/src/nodes/CombineXYZ.tsx b/src/nodes/CombineXYZ.tsx
new file mode 100644
index 0000000..99823fd
--- /dev/null
+++ b/src/nodes/CombineXYZ.tsx
@@ -0,0 +1,30 @@
+import { NodeShell, InputNumber, OutputVector, NodeComponentProps, NodeInfo } from '../node.tsx';
+
+export interface CombineXYZInputs {
+ x: number;
+ y: number;
+ z: number;
+}
+
+export interface CombineXYZOutputs {
+ vector: [number, number, number];
+}
+
+export type CombineXYZProps = NodeComponentProps<CombineXYZInputs>;
+
+export const CombineXYZ = ({ id, x, y, inputs }: CombineXYZProps) => {
+ return (
+ <NodeShell name="Combine XYZ" id={id} x={x} y={y}>
+ <OutputVector name="vector" label="Vector" />
+ <InputNumber name="x" label="X" value={inputs.x} />
+ <InputNumber name="y" label="Y" value={inputs.y} />
+ <InputNumber name="z" label="Z" value={inputs.z} />
+ </NodeShell>
+ );
+};
+
+export const CombineXYZNode: NodeInfo<CombineXYZInputs, CombineXYZOutputs> = {
+ component: CombineXYZ,
+ func: ({ x, y, z }) => ({ vector: [x, y, z] }),
+ inputs: { x: 0, y: 0, z: 0 },
+}; \ No newline at end of file
diff --git a/src/nodes/Fourier.tsx b/src/nodes/Fourier.tsx
new file mode 100644
index 0000000..3763402
--- /dev/null
+++ b/src/nodes/Fourier.tsx
@@ -0,0 +1,25 @@
+import { NodeShell, InputArray, OutputNumber, NodeComponentProps, NodeInfo } from '../node.tsx';
+import { fft } from '../wasm.ts';
+
+export interface FourierInputs {
+ data: Float64Array,
+}
+
+export interface FourierOutputs {
+ data: Float64Array,
+}
+
+export const Fourier = ({ id, x, y, inputs }: NodeComponentProps<FourierInputs>) => {
+ return (
+ <NodeShell name="Fourier Transform" id={id} x={x} y={y}>
+ <OutputNumber name="data" label="Frequency" />
+ <InputArray name="data" label="Time" />
+ </NodeShell>
+ );
+};
+
+export const FourierNode: NodeInfo<FourierInputs, FourierOutputs> = {
+ component: Fourier,
+ func: ({ data }) => ({ data: fft(data) }),
+ inputs: { data: new Float64Array(0) },
+}; \ No newline at end of file
diff --git a/src/nodes/Intersperse.tsx b/src/nodes/Intersperse.tsx
new file mode 100644
index 0000000..7487a84
--- /dev/null
+++ b/src/nodes/Intersperse.tsx
@@ -0,0 +1,27 @@
+import { NodeShell, InputArray, OutputNumber, NodeComponentProps, NodeInfo } from '../node.tsx';
+import { intersperse } from '../wasm.ts';
+
+export interface IntersperseInputs {
+ a: Float64Array,
+ b: Float64Array,
+}
+
+export interface IntersperseOutputs {
+ out: Float64Array,
+}
+
+export const Intersperse = ({ id, x, y, inputs }: NodeComponentProps<IntersperseInputs>) => {
+ return (
+ <NodeShell name="Intersperse" id={id} x={x} y={y}>
+ <OutputNumber name="out" label="Data" />
+ <InputArray name="a" label="Even" />
+ <InputArray name="b" label="Odd" />
+ </NodeShell>
+ );
+};
+
+export const IntersperseNode: NodeInfo<IntersperseInputs, IntersperseOutputs> = {
+ component: Intersperse,
+ func: ({ a, b }) => ({ out: a && b ? intersperse(a, b) : null }),
+ inputs: { a: null, b: null },
+}; \ No newline at end of file
diff --git a/src/nodes/Linspace.tsx b/src/nodes/Linspace.tsx
new file mode 100644
index 0000000..16d8ba6
--- /dev/null
+++ b/src/nodes/Linspace.tsx
@@ -0,0 +1,29 @@
+import { NodeShell, InputNumber, InputInteger, OutputNumber, NodeComponentProps, NodeInfo } from '../node.tsx';
+import { linspace } from '../wasm.ts';
+
+export interface LinspaceInputs {
+ start: number,
+ stop: number,
+ n: number,
+}
+
+export interface LinspaceOutputs {
+ data: Float64Array,
+}
+
+export const Linspace = ({ id, x, y, inputs }: NodeComponentProps<LinspaceInputs>) => {
+ return (
+ <NodeShell name="Linspace" id={id} x={x} y={y}>
+ <OutputNumber name="data" label="Data" />
+ <InputNumber name="start" label="Start" value={inputs.start} />
+ <InputNumber name="stop" label="Stop" value={inputs.stop}/>
+ <InputInteger name="n" label="n" value={inputs.n} />
+ </NodeShell>
+ );
+};
+
+export const LinspaceNode: NodeInfo<LinspaceInputs, LinspaceOutputs> = {
+ component: Linspace,
+ func: ({ start, stop, n }) => ({ data: linspace(start, stop, Math.floor(n)) }),
+ inputs: { start: 0, stop: 1, n: 10 },
+}; \ No newline at end of file
diff --git a/src/nodes/Math.tsx b/src/nodes/Math.tsx
new file mode 100644
index 0000000..e513538
--- /dev/null
+++ b/src/nodes/Math.tsx
@@ -0,0 +1,157 @@
+import { NodeShell, InputNumber, InputSelect, OutputNumber, NodeComponentProps, NodeInfo } from '../node.tsx';
+
+export enum MathOpFunc {
+ Add = 'Add',
+ Sub = 'Subtract',
+ Mul = 'Multiply',
+ Div = 'Divide',
+ Power = 'Power',
+ Log = 'Logarithm',
+ Sqrt = 'Square Root',
+ Exp = 'Exponent',
+}
+export enum MathOpCmp {
+ Min = 'Minimum',
+ Max = 'Maximum',
+ Less = 'Less Than',
+ Greater = 'Greater Than',
+ Sign = 'Sign',
+}
+export enum MathOpRound {
+ Round = 'Round',
+ Floor = 'Floor',
+ Ceil = 'Ceiling',
+ Trunc = 'Truncate',
+ Frac = 'Fraction',
+ Mod = 'Modulo',
+ Snap = 'Snap',
+ Clamp = 'Clamp',
+}
+export enum MathOpTrig {
+ Sin = 'Sine',
+ Cos = 'Cosine',
+ Tan = 'Tangent',
+ Asin = 'Arcsine',
+ Acos = 'Arccosine',
+ Atan = 'Arctangent',
+ Atan2 = 'Arctan2',
+
+ Sinh = 'Hyperbolic Sine',
+ Cosh = 'Hyperbolic Cosine',
+ Tanh = 'Hyperbolic Tangent',
+}
+export enum MathOpConv {
+ ToRad = 'To Radians',
+ ToDeg = 'To Degrees',
+}
+
+export const MathOp = { ...MathOpFunc, ...MathOpCmp, ...MathOpRound, ...MathOpTrig, ...MathOpConv };
+export type MathOp = typeof MathOp;
+
+export interface MathInputs {
+ op: MathOp,
+ a: number | TypedArray,
+ b: number | TypedArray,
+}
+
+export interface MathOutputs {
+ out: boolean | number | TypedArray,
+}
+
+export const MathC = ({ id, x, y, inputs }: NodeComponentProps<MathInputs>) => {
+ const options = {
+ 'Functions': Object.values(MathOpFunc),
+ 'Comparison': Object.values(MathOpCmp),
+ 'Rounding': Object.values(MathOpRound),
+ 'Trigonometric': Object.values(MathOpTrig),
+ 'Conversion': Object.values(MathOpConv),
+ };
+ return (
+ <NodeShell name="Math" id={id} x={x} y={y}>
+ <OutputNumber name="out" label="Value" />
+ <InputSelect name="op" label="Operation" value={inputs.op} options={options} />
+ <InputNumber name="a" label="a" value={inputs.a} />
+ <InputNumber name="b" label="b" value={inputs.b}/>
+ </NodeShell>
+ );
+};
+
+const doMathOp = (op: MathOp, a: number, b: number): number => {
+ switch (op) {
+ case MathOp.Add: return a + b;
+ case MathOp.Sub: return a - b;
+ case MathOp.Mul: return a * b;
+ case MathOp.Div: return a / b;
+ case MathOp.Power: return a ** b;
+ case MathOp.Log: return Math.log(a) / Math.log(b);
+ case MathOp.Sqrt: return Math.sqrt(a);
+ case MathOp.Exp: return Math.exp(a);
+
+ case MathOp.Min: return Math.min(a, b);
+ case MathOp.Max: return Math.max(a, b);
+ case MathOp.Less: return a < b;
+ case MathOp.Greater: return a > b;
+ case MathOp.Sign: return Math.sign(a);
+
+ case MathOp.Round: return Math.round(a);
+ case MathOp.Floor: return Math.floor(a);
+ case MathOp.Ceil: return Math.ceil(a);
+ case MathOp.Trunc: return Math.trunc(a);
+ case MathOp.Frac: return a - Math.trunc(a);
+ case MathOp.Mod: return a % b;
+ case MathOp.Snap: return Math.round(a * b) / b;
+ case MathOp.Clamp: return Math.max(0, Math.min(a, 1));
+
+ case MathOp.Sin: return Math.sin(a);
+ case MathOp.Cos: return Math.cos(a);
+ case MathOp.Tan: return Math.tan(a);
+ case MathOp.Asin: return Math.asin(a);
+ case MathOp.Acos: return Math.acos(a);
+ case MathOp.Atan: return Math.atan(a);
+ case MathOp.Atan2: return Math.atan2(a, b);
+
+ case MathOp.Sinh: return Math.sinh(a);
+ case MathOp.Cosh: return Math.cosh(a);
+ case MathOp.Tanh: return Math.tanh(a);
+
+ case MathOp.ToRad: return a / 180 * Math.PI;
+ case MathOp.ToDeg: return a * 180 / Math.PI;
+
+ default: throw new TypeError();
+ }
+};
+
+const mathFunc = ({ op, a, b }: MathInputs): MathOutputs => {
+ const countScalar = (typeof a === 'number' ? 1 : 0) + (typeof b === 'number' ? 1 : 0);
+ if (typeof a === 'number') {
+ if (typeof b === 'number') {
+ return { out: doMathOp(op, a, b) };
+ } else {
+ const out = new Float64Array(b.length);
+ for (let i = 0; i < out.length; i++) {
+ out[i] = doMathOp(op, a, b[i]);
+ }
+ return { out };
+ }
+ } else {
+ if (typeof b === 'number') {
+ const out = new Float64Array(a.length);
+ for (let i = 0; i < out.length; i++) {
+ out[i] = doMathOp(op, a[i], b);
+ }
+ return { out };
+ } else {
+ const out = new Float64Array(Math.min(a.length, b.length));
+ for (let i = 0; i < out.length; i++) {
+ out[i] = doMathOp(op, a[i], b[i]);
+ }
+ return { out };
+ }
+ }
+};
+
+export const MathNode: NodeInfo<MathInputs, MathOutputs> = {
+ component: MathC,
+ func: mathFunc,
+ inputs: { op: MathOp.Add, a: 0, b: 0 },
+};
diff --git a/src/nodes/Plot.tsx b/src/nodes/Plot.tsx
new file mode 100644
index 0000000..dca3219
--- /dev/null
+++ b/src/nodes/Plot.tsx
@@ -0,0 +1,35 @@
+import { NodeShell, InputAny, NodeComponentProps, NodeInfo } from '../node.tsx';
+
+export interface PlotInputs {
+ data: any;
+}
+
+export interface PlotOutputs {}
+
+export const Plot = ({ id, x, y, inputs }: NodeComponentProps<PlotInputs>) => {
+ const data = inputs.data.value;
+ const scale = 100;
+ const dx = 0;
+ const dy = 0;
+ let path = '';
+ if (data !== null && data.length > 3) {
+ for (let i = 0; i < data.length - data.length % 2; i += 2) {
+ path += (i ? 'L' : 'M') + (data[i] * scale + dx) + ' ' + (data[i+1] * scale + dy);
+ }
+ }
+ //alert(path);
+ return (
+ <NodeShell name="Plot" id={id} x={x} y={y}>
+ <InputAny name="data" label="Data" />
+ <svg width="200" height="200">
+ <path fill="none" stroke="blue" stroke-width="2" d={path} />
+ </svg>
+ </NodeShell>
+ );
+};
+
+export const PlotNode: NodeInfo<PlotInputs, PlotOutputs> = {
+ component: Plot,
+ func: () => ({}),
+ inputs: { data: null },
+}; \ No newline at end of file
diff --git a/src/nodes/SeparateXYZ.tsx b/src/nodes/SeparateXYZ.tsx
new file mode 100644
index 0000000..bc14be1
--- /dev/null
+++ b/src/nodes/SeparateXYZ.tsx
@@ -0,0 +1,30 @@
+import { NodeShell, InputVector, OutputNumber, NodeComponentProps, NodeInfo } from '../node.tsx';
+
+export interface SeparateXYZInputs {
+ vector: [number, number, number];
+}
+
+export interface SeparateXYZOutputs {
+ x: number;
+ y: number;
+ z: number;
+}
+
+export type SeparateXYZProps = NodeComponentProps<SeparateXYZInputs>;
+
+export const SeparateXYZ = ({ id, x, y, inputs }: SeparateXYZProps) => {
+ return (
+ <NodeShell name="Separate XYZ" id={id} x={x} y={y}>
+ <OutputNumber name="x" label="X" />
+ <OutputNumber name="y" label="Y" />
+ <OutputNumber name="z" label="Z" />
+ <InputVector name="vector" label="Vector" value={inputs.vector} />
+ </NodeShell>
+ );
+};
+
+export const SeparateXYZNode: NodeInfo<SeparateXYZInputs, SeparateXYZOutputs> = {
+ component: SeparateXYZ,
+ func: ({ vector }) => ({ x: vector[0], y: vector[1], z: vector[2] }),
+ inputs: { vector: [0, 0, 0] },
+}; \ No newline at end of file
diff --git a/src/nodes/Viewer.tsx b/src/nodes/Viewer.tsx
new file mode 100644
index 0000000..addd0ab
--- /dev/null
+++ b/src/nodes/Viewer.tsx
@@ -0,0 +1,28 @@
+import { NodeShell, InputAny, NodeComponentProps, NodeInfo } from '../node.tsx';
+
+export interface ViewerInputs {
+ value: any;
+}
+
+export interface ViewerOutputs {}
+
+export const Viewer = ({ id, x, y, inputs }: NodeComponentProps<ViewerInputs>) => {
+ let data = inputs.value.value;
+ if (ArrayBuffer.isView(data)) {
+ data = Array.from(data);
+ }
+ return (
+ <NodeShell name="Viewer" id={id} x={x} y={y}>
+ <InputAny name="value" label="Value" />
+ <pre style="padding-left: 8px; white-space: pre-wrap; overflow-wrap: anywhere;">
+ {JSON.stringify(data)}
+ </pre>
+ </NodeShell>
+ );
+};
+
+export const ViewerNode: NodeInfo<ViewerInputs, ViewerOutputs> = {
+ component: Viewer,
+ func: () => ({}),
+ inputs: { value: null },
+}; \ No newline at end of file
diff --git a/src/nodes/index.ts b/src/nodes/index.ts
new file mode 100644
index 0000000..0e63d3a
--- /dev/null
+++ b/src/nodes/index.ts
@@ -0,0 +1,21 @@
+import { CombineXYZNode } from './CombineXYZ.tsx';
+import { SeparateXYZNode } from './SeparateXYZ.tsx';
+import { ViewerNode } from './Viewer.tsx';
+import { FourierNode } from './Fourier.tsx';
+import { LinspaceNode } from './Linspace.tsx';
+import { IntersperseNode } from './Intersperse.tsx';
+import { MathNode } from './Math.tsx';
+import { PlotNode } from './Plot.tsx';
+
+const nodeRegistry: Record<string, NodeInfo<any>> = {
+ 'Combine XYZ': CombineXYZNode,
+ 'Separate XYZ': SeparateXYZNode,
+ 'Viewer': ViewerNode,
+ 'Fourier Transform': FourierNode,
+ 'Linspace': LinspaceNode,
+ 'Intersperse': IntersperseNode,
+ 'Math': MathNode,
+ 'Plot': PlotNode,
+}
+
+export { nodeRegistry }; \ No newline at end of file
diff --git a/src/util.ts b/src/util.ts
new file mode 100644
index 0000000..9f84ffe
--- /dev/null
+++ b/src/util.ts
@@ -0,0 +1,2 @@
+export const cls = (...classes: Array<string | false | null | undefined>) =>
+ classes.filter(c => c).join(' '); \ No newline at end of file
diff --git a/src/wasm.ts b/src/wasm.ts
new file mode 100644
index 0000000..3a308b2
--- /dev/null
+++ b/src/wasm.ts
@@ -0,0 +1,9 @@
+import { instantiate } from '../build/out.js';
+import url from '../build/out.wasm';
+export const {
+ memory,
+ linspace,
+ intersperse,
+ dft,
+ fft,
+} = await instantiate(await WebAssembly.compileStreaming(fetch(url))); \ No newline at end of file