diff options
| author | Sam Nystrom <sam@samnystrom.dev> | 2024-03-06 20:52:45 +0000 |
|---|---|---|
| committer | Sam Nystrom <15555332-SamNystrom1@users.noreply.replit.com> | 2024-03-06 20:52:45 +0000 |
| commit | ac83dd4a08bdbab6df270d6dae8d9e2d6d619342 (patch) | |
| tree | d928f552d7fb89a96b757b00899d4dbc0639cacd | |
init
| -rw-r--r-- | .gitignore | 4 | ||||
| -rw-r--r-- | LICENSE | 13 | ||||
| -rw-r--r-- | asconfig.json | 22 | ||||
| -rw-r--r-- | assembly/index.ts | 41 | ||||
| -rw-r--r-- | assembly/tsconfig.json | 6 | ||||
| -rw-r--r-- | package.json | 20 | ||||
| -rw-r--r-- | public/index.html | 11 | ||||
| -rw-r--r-- | src/AddNodeMenu.tsx | 21 | ||||
| -rw-r--r-- | src/NodeEditor.module.css | 5 | ||||
| -rw-r--r-- | src/NodeEditor.tsx | 245 | ||||
| -rw-r--r-- | src/assets/down-arrow.svg | 3 | ||||
| -rw-r--r-- | src/assets/right-arrow.svg | 3 | ||||
| -rw-r--r-- | src/cssmodules.d.ts | 4 | ||||
| -rw-r--r-- | src/dataflow.ts | 19 | ||||
| -rw-r--r-- | src/index.css | 28 | ||||
| -rw-r--r-- | src/index.tsx | 13 | ||||
| -rw-r--r-- | src/node.module.css | 145 | ||||
| -rw-r--r-- | src/node.tsx | 208 | ||||
| -rw-r--r-- | src/nodes/CombineXYZ.tsx | 30 | ||||
| -rw-r--r-- | src/nodes/Fourier.tsx | 25 | ||||
| -rw-r--r-- | src/nodes/Intersperse.tsx | 27 | ||||
| -rw-r--r-- | src/nodes/Linspace.tsx | 29 | ||||
| -rw-r--r-- | src/nodes/Math.tsx | 157 | ||||
| -rw-r--r-- | src/nodes/Plot.tsx | 35 | ||||
| -rw-r--r-- | src/nodes/SeparateXYZ.tsx | 30 | ||||
| -rw-r--r-- | src/nodes/Viewer.tsx | 28 | ||||
| -rw-r--r-- | src/nodes/index.ts | 21 | ||||
| -rw-r--r-- | src/util.ts | 2 | ||||
| -rw-r--r-- | src/wasm.ts | 9 | ||||
| -rw-r--r-- | tsconfig.json | 22 |
30 files changed, 1226 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c8304db --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +dist/ +node_modules/ +bun.lockb +build/ @@ -0,0 +1,13 @@ +Copyright (c) 2024 Sam Nystrom <sam@samnystrom.dev> + +Permission to use, copy, modify, and distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
\ No newline at end of file diff --git a/asconfig.json b/asconfig.json new file mode 100644 index 0000000..d530acf --- /dev/null +++ b/asconfig.json @@ -0,0 +1,22 @@ +{ + "targets": { + "debug": { + "outFile": "build/out.wasm", + "textFile": "build/out.wat", + "sourceMap": true, + "debug": true + }, + "release": { + "outFile": "build/out.wasm", + "textFile": "build/out.wat", + "sourceMap": true, + "optimizeLevel": 3, + "shrinkLevel": 0, + "converge": false, + "noAssert": false + } + }, + "options": { + "bindings": "raw" + } +}
\ No newline at end of file diff --git a/assembly/index.ts b/assembly/index.ts new file mode 100644 index 0000000..cba8926 --- /dev/null +++ b/assembly/index.ts @@ -0,0 +1,41 @@ + +export function linspace(start: f64, stop: f64, n: i32): Float64Array { + const out = new Float64Array(n); + for (let i = 0; i < n; i++) { + out[i] = start + (stop - start) * <f64>i / <f64>(n-1); + } + return out; +} + +export function intersperse(a: Float64Array, b: Float64Array): Float64Array { + const len = a.length < b.length ? a.length : b.length; + const out = new Float64Array(len * 2); + for (let i = 0; i < out.length / 2; i++) { + out[i*2] = a[i]; + out[i*2+1] = b[i]; + } + return out; +} + +export function dft(x: Float64Array): Float64Array { + const out = new Float64Array(x.length); + for (let k = 0; k < out.length - out.length % 2; k += 2) { + for (let n = 0; n < x.length - x.length % 2; n += 2) { + const y = -2.0 * Math.PI * <f64>k / <f64>x.length * <f64>n; + const u = Math.cos(y); + const v = Math.sin(y); + out[k] = x[n] * u - x[n+1] * v; + out[k+1] = x[n] * v + x[n+1] * u; + } + } + return out; +} + +export function fft(x: Float64Array): Float64Array { + //const out = new Float64Array(x.length); + return dft(x); +} + +export function add(a: i32, b: i32): i32 { + return a + b; +} diff --git a/assembly/tsconfig.json b/assembly/tsconfig.json new file mode 100644 index 0000000..e28fcf2 --- /dev/null +++ b/assembly/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "assemblyscript/std/assembly.json", + "include": [ + "./**/*.ts" + ] +}
\ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..f7ec3c5 --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "webnodes", + "type": "module", + "scripts": { + "check": "tsc", + "dev": "esbuild src/index.tsx --loader:.svg=dataurl --loader:.wasm=file --bundle --sourcemap --format=esm --outdir=public --watch --servedir=public", + "asbuild:debug": "asc assembly/index.ts --target debug", + "asbuild:release": "asc assembly/index.ts --target release", + "build": "rm -rf dist && cp -r public dist && bun run asbuild:release && esbuild src/index.tsx --loader:.svg=dataurl --loader:.wasm=file --bundle --minify --format=esm --outdir=dist" + }, + "dependencies": { + "@preact/signals": "^1.2.2", + "preact": "^10.19.6" + }, + "devDependencies": { + "assemblyscript": "^0.27.24", + "esbuild": "^0.20.1", + "typescript": "^5.3.3" + } +}
\ No newline at end of file diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..6f4971a --- /dev/null +++ b/public/index.html @@ -0,0 +1,11 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width,initial-scale=1" /> + <title>Node Editor</title> + <link rel="stylesheet" href="/index.css" /> + <script type="module" src="/index.js"></script> + </head> + <body></body> +</html>
\ No newline at end of file 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 diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2a94c31 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ESNext", "DOM"], + "module": "ESNext", + "skipLibCheck": true, + + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "jsxImportSource": "preact", + "allowJs": false, + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true + }, + "include": ["src"] +} |
