diff --git a/frontend/src/lib/components/Camera.svelte b/frontend/src/lib/components/Camera.svelte index 04793e8..3bac531 100644 --- a/frontend/src/lib/components/Camera.svelte +++ b/frontend/src/lib/components/Camera.svelte @@ -1,74 +1,18 @@ - - - + diff --git a/frontend/src/lib/components/Node.svelte b/frontend/src/lib/components/Node.svelte index a52ada0..40d63ce 100644 --- a/frontend/src/lib/components/Node.svelte +++ b/frontend/src/lib/components/Node.svelte @@ -1,9 +1,10 @@
+ {#if input.type === "float"} {:else if input.type === "integer"} diff --git a/frontend/src/lib/components/NodeParameter.svelte b/frontend/src/lib/components/NodeParameter.svelte index 2fa53d9..2a2ef24 100644 --- a/frontend/src/lib/components/NodeParameter.svelte +++ b/frontend/src/lib/components/NodeParameter.svelte @@ -68,8 +68,6 @@ class:disabled={$possibleSocketIds && !$possibleSocketIds.has(socketId)} >
- -
@@ -152,16 +150,6 @@ display: none; } - .input { - width: 100%; - box-sizing: border-box; - border-radius: 3px; - font-size: 1em; - padding: 10px; - background: #111; - background: var(--background-color-lighter); - } - svg { position: absolute; box-sizing: border-box; diff --git a/frontend/src/lib/components/background/Background.svelte b/frontend/src/lib/components/background/Background.svelte index 334d37d..0c854fc 100644 --- a/frontend/src/lib/components/background/Background.svelte +++ b/frontend/src/lib/components/background/Background.svelte @@ -3,7 +3,6 @@ import BackgroundVert from "./Background.vert"; import BackgroundFrag from "./Background.frag"; - import { Color } from "three"; import { colors } from "../graph/stores"; export let minZoom = 4; diff --git a/frontend/src/lib/components/debug/Debug.svelte b/frontend/src/lib/components/debug/Debug.svelte index d53a791..94686a6 100644 --- a/frontend/src/lib/components/debug/Debug.svelte +++ b/frontend/src/lib/components/debug/Debug.svelte @@ -22,12 +22,3 @@ {/each} - - diff --git a/frontend/src/lib/components/debug/index.ts b/frontend/src/lib/components/debug/index.ts index 6db9502..fc131cb 100644 --- a/frontend/src/lib/components/debug/index.ts +++ b/frontend/src/lib/components/debug/index.ts @@ -1,4 +1,4 @@ -import { Vector3 } from "three"; +import { Vector3 } from "three/src/math/Vector3.js"; import { lines, points } from "./store"; export function debugPosition(x: number, y: number) { diff --git a/frontend/src/lib/components/debug/store.ts b/frontend/src/lib/components/debug/store.ts index 4dbe8fd..59d869f 100644 --- a/frontend/src/lib/components/debug/store.ts +++ b/frontend/src/lib/components/debug/store.ts @@ -1,5 +1,5 @@ import { writable } from "svelte/store"; -import type { Vector3 } from "three"; +import { Vector3 } from "three/src/math/Vector3.js"; export const points = writable([]); diff --git a/frontend/src/lib/components/edges/Edge.svelte b/frontend/src/lib/components/edges/Edge.svelte index a68b182..e6f3807 100644 --- a/frontend/src/lib/components/edges/Edge.svelte +++ b/frontend/src/lib/components/edges/Edge.svelte @@ -1,13 +1,21 @@ - - - - - + + {#if boxSelection && mouseDown} diff --git a/frontend/src/lib/components/graph/stores.ts b/frontend/src/lib/components/graph/stores.ts index 2d3efa1..82bc014 100644 --- a/frontend/src/lib/components/graph/stores.ts +++ b/frontend/src/lib/components/graph/stores.ts @@ -1,7 +1,7 @@ import { browser } from "$app/environment"; import type { Socket } from "$lib/types"; import { writable, type Writable } from "svelte/store"; -import { Color } from "three"; +import { Color } from "three/src/math/Color.js"; export const activeNodeId: Writable = writable(-1); export const selectedNodes: Writable | null> = writable(null); diff --git a/frontend/src/lib/graph-manager.ts b/frontend/src/lib/graph-manager.ts index 1fa5ed8..4d34ea3 100644 --- a/frontend/src/lib/graph-manager.ts +++ b/frontend/src/lib/graph-manager.ts @@ -1,44 +1,14 @@ import { writable, type Writable } from "svelte/store"; -import type { Graph, NodeRegistry as INodeRegistry, NodeType, Node, Edge, Socket } from "./types"; +import { type Graph, type Node, type Edge, type Socket, type NodeRegistry, type RuntimeExecutor } from "./types"; import { HistoryManager } from "./history-manager"; - -const nodeTypes: NodeType[] = [ - { - id: "input/float", - inputs: { - "value": { type: "float", value: 0.1 }, - }, - outputs: ["float"], - }, - { - id: "math", - inputs: { - "type": { type: "select", options: ["add", "subtract", "multiply", "divide"], internal: true }, - "a": { type: "float", value: 0.2 }, - "b": { type: "float", value: 0.2 }, - }, - outputs: ["float"], - }, - { - id: "output", - inputs: { - "input": { type: "float" }, - }, - outputs: [], - } -] - -export class NodeRegistry implements INodeRegistry { - getNode(id: string): NodeType | undefined { - return nodeTypes.find((nodeType) => nodeType.id === id); - } -} - +import * as templates from "./graphs"; export class GraphManager { status: Writable<"loading" | "idle" | "error"> = writable("loading"); + graph: Graph = { nodes: [], edges: [] }; + private _nodes: Map = new Map(); nodes: Writable> = writable(new Map()); private _edges: Edge[] = []; @@ -48,7 +18,7 @@ export class GraphManager { history: HistoryManager = new HistoryManager(this); - private constructor(private graph: Graph, private nodeRegistry: NodeRegistry = new NodeRegistry()) { + constructor(private nodeRegistry: NodeRegistry, private runtime: RuntimeExecutor) { this.nodes.subscribe((nodes) => { this._nodes = nodes; }); @@ -73,6 +43,15 @@ export class GraphManager { return { nodes, edges }; } + execute() { + if (!this.runtime["loaded"]) return; + const start = performance.now(); + const result = this.runtime.execute(this.serialize()); + const end = performance.now(); + console.log(`Execution took ${end - start}ms -> ${result}`); + } + + private _init(graph: Graph) { const nodes = new Map(graph.nodes.map(node => { const nodeType = this.nodeRegistry.getNode(node.type); @@ -105,7 +84,9 @@ export class GraphManager { } - async load() { + async load(graph: Graph) { + this.graph = graph; + this.status.set("loading"); for (const node of this.graph.nodes) { const nodeType = this.nodeRegistry.getNode(node.type); @@ -120,8 +101,10 @@ export class GraphManager { this._init(this.graph); - this.status.set("idle"); - this.history.save(); + setTimeout(() => { + this.status.set("idle"); + this.history.save(); + }, 100) } @@ -164,9 +147,6 @@ export class GraphManager { } } - private updateNodeParents(node: Node) { - } - removeNode(node: Node) { const edges = this._edges.filter((edge) => edge[0].id !== node.id && edge[2].id !== node.id); this.edges.set(edges); @@ -307,49 +287,17 @@ export class GraphManager { .filter(Boolean) as unknown as [Node, number, Node, string][]; } - static createEmptyGraph({ width = 20, height = 20 } = {}): GraphManager { - - const graph: Graph = { - edges: [], - nodes: [], - }; - - const amount = width * height; - - for (let i = 0; i < amount; i++) { - const x = i % width; - const y = Math.floor(i / height); - - graph.nodes.push({ - id: i, - tmp: { - visible: false, - }, - position: { - x: x * 30, - y: y * 40, - }, - props: i == 0 ? { value: 0 } : {}, - type: i == 0 ? "input/float" : "math", - }); - - graph.edges.push([i, 0, i + 1, i === amount - 1 ? "input" : "a",]); + createTemplate(template: T, ...args: Parameters) { + switch (template) { + case "grid": + return templates.grid(args?.[0] || 5, args?.[1] || 5); + case "tree": + return templates.tree(args?.[0] || 4); + default: + throw new Error(`Template not found: ${template}`); } - graph.nodes.push({ - id: amount, - tmp: { - visible: false, - }, - position: { - x: width * 30, - y: (height - 1) * 40, - }, - type: "output", - props: {}, - }); - return new GraphManager(graph); } } diff --git a/frontend/src/lib/graphs/grid.ts b/frontend/src/lib/graphs/grid.ts new file mode 100644 index 0000000..48c6536 --- /dev/null +++ b/frontend/src/lib/graphs/grid.ts @@ -0,0 +1,47 @@ +import type { Graph } from "$lib/types"; + +export function grid(width: number, height: number) { + + const graph: Graph = { + edges: [], + nodes: [], + }; + + const amount = width * height; + + for (let i = 0; i < amount; i++) { + const x = i % width; + const y = Math.floor(i / height); + + graph.nodes.push({ + id: i, + tmp: { + visible: false, + }, + position: { + x: x * 30, + y: y * 40, + }, + props: i == 0 ? { value: 0 } : {}, + type: i == 0 ? "input/float" : "math", + }); + + graph.edges.push([i, 0, i + 1, i === amount - 1 ? "input" : "a",]); + } + + graph.nodes.push({ + id: amount, + tmp: { + visible: false, + }, + position: { + x: width * 30, + y: (height - 1) * 40, + }, + type: "output", + props: {}, + }); + + return graph; + +} diff --git a/frontend/src/lib/graphs/index.ts b/frontend/src/lib/graphs/index.ts new file mode 100644 index 0000000..9785018 --- /dev/null +++ b/frontend/src/lib/graphs/index.ts @@ -0,0 +1,2 @@ +export { grid } from "./grid"; +export { tree } from "./tree"; diff --git a/frontend/src/lib/graphs/tree.ts b/frontend/src/lib/graphs/tree.ts new file mode 100644 index 0000000..9261eef --- /dev/null +++ b/frontend/src/lib/graphs/tree.ts @@ -0,0 +1,52 @@ +import type { Graph, Node } from "$lib/types"; + +export function tree(depth: number): Graph { + + const nodes: Node[] = [ + { + id: 0, + type: "output", + position: { x: 0, y: 0 } + }, + { + id: 1, + type: "math", + position: { x: -40, y: -10 } + } + ] + + const edges: [number, number, number, string][] = [ + [1, 0, 0, "input"] + ]; + + for (let d = 0; d < depth; d++) { + const amount = Math.pow(2, d); + for (let i = 0; i < amount; i++) { + + const id0 = amount * 2 + i * 2; + const id1 = amount * 2 + i * 2 + 1; + + const parent = Math.floor(id0 / 2); + + const x = -(d + 1) * 50 - 40; + const y = i * 80 - amount * 35; + + nodes.push({ + id: id0, + type: "math", + position: { x, y: y }, + }); + edges.push([id0, 0, parent, "a"]); + nodes.push({ + id: id1, + type: "math", + position: { x, y: y + 35 }, + }); + edges.push([id1, 0, parent, "b"]); + } + } + + + return { nodes, edges }; + +} diff --git a/frontend/src/lib/node-registry.ts b/frontend/src/lib/node-registry.ts new file mode 100644 index 0000000..babc5c5 --- /dev/null +++ b/frontend/src/lib/node-registry.ts @@ -0,0 +1,45 @@ +import type { NodeRegistry, NodeType } from "./types"; + +const nodeTypes: NodeType[] = [ + { + id: "input/float", + inputs: { + "value": { type: "float", value: 0.1, internal: true }, + }, + outputs: ["float"], + execute: ({ value }) => { return value } + }, + { + id: "math", + inputs: { + "type": { type: "select", options: ["add", "subtract", "multiply", "divide"], internal: true, value: "multiply" }, + "a": { type: "float", value: 2 }, + "b": { type: "float", value: 2 }, + }, + outputs: ["float"], + execute: (inputs) => { + const a = inputs.a as number; + const b = inputs.b as number; + switch (inputs.type) { + case "add": return a + b; + case "subtract": return a - b; + case "multiply": return a * b; + case "divide": return a / b; + } + } + }, + { + id: "output", + inputs: { + "input": { type: "float" }, + }, + outputs: [], + } +] + +export class MemoryNodeRegistry implements NodeRegistry { + getNode(id: string): NodeType | undefined { + return nodeTypes.find((nodeType) => nodeType.id === id); + } +} + diff --git a/frontend/src/lib/panzoom/domController.ts b/frontend/src/lib/panzoom/domController.ts new file mode 100644 index 0000000..06fb70f --- /dev/null +++ b/frontend/src/lib/panzoom/domController.ts @@ -0,0 +1,52 @@ +export default function makeDomController(domElement: HTMLElement) { + const elementValid = isDomElement(domElement); + if (!elementValid) { + throw new Error( + 'panzoom requires DOM element to be attached to the DOM tree', + ); + } + + const owner = domElement.parentElement; + domElement.scrollTop = 0; + + const api = { + getBBox: getBBox, + getOwner: getOwner, + applyTransform: applyTransform, + }; + + return api; + + function getOwner() { + return owner; + } + + function getBBox() { + // TODO: We should probably cache this? + return { + left: 0, + top: 0, + width: domElement.clientWidth, + height: domElement.clientHeight, + }; + } + + function applyTransform(transform: { scale: number; x: number; y: number }) { + // TODO: Should we cache this? + domElement.style.transformOrigin = '0 0 0'; + domElement.style.transform = + 'matrix(' + + transform.scale + + ', 0, 0, ' + + transform.scale + + ', ' + + transform.x + + ', ' + + transform.y + + ')'; + } +} + +export function isDomElement(element: HTMLElement) { + return element && element.parentElement && element.style; +} diff --git a/frontend/src/lib/panzoom/index.ts b/frontend/src/lib/panzoom/index.ts new file mode 100644 index 0000000..e5c2f37 --- /dev/null +++ b/frontend/src/lib/panzoom/index.ts @@ -0,0 +1,773 @@ +import type NodeSystemView from '../../view/NodeSystemView'; +import makeDomController from './domController'; +import kinetic from './kinetic'; + +interface Bounds { + left: number; + top: number; + right: number; + bottom: number; +} +export interface Transform { + x: number; + y: number; + scale: number; +} + +export interface TransformOrigin { + x: number; + y: number; +} + +export interface PanZoomController { + getOwner: () => Element; + applyTransform: (transform: Transform) => void; +} + +interface PanZoomOptions { + filterKey?: () => boolean; + bounds?: boolean | Bounds; + maxZoom?: number; + minZoom?: number; + boundsPadding?: number; + zoomDoubleClickSpeed?: number; + zoomSpeed?: number; + initialX?: number; + initialY?: number; + initialZoom?: number; + pinchSpeed?: number; + beforeWheel?: (e: WheelEvent) => void; + beforeMouseDown?: (e: MouseEvent) => void; + autocenter?: boolean; + onTouch?: (e: TouchEvent) => void; + onTransform?: (t: Transform) => void; + onDoubleClick?: (e: Event) => void; + smoothScroll?: Record; + controller?: PanZoomController; + enableTextSelection?: boolean; + disableKeyboardInteraction?: boolean; + transformOrigin?: TransformOrigin; + view?: NodeSystemView; +} + +const defaultZoomSpeed = 0.2; + +/** + * Creates a new instance of panzoom, so that an object can be panned and zoomed + * + * @param {DOMElement} domElement where panzoom should be attached. + * @param {Object} options that configure behavior. + */ +export function createPanZoom( + domElement: HTMLElement, + options: PanZoomOptions, +) { + const panController = makeDomController(domElement); + + const owner = panController.getOwner(); + // just to avoid GC pressure, every time we do intermediate transform + // we return this object. For internal use only. Never give it back to the consumer of this library + const storedCTMResult = { x: 0, y: 0 }; + + let isDirty = false; + const transform = { + x: 0, + y: 0, + scale: 1, + }; + + // TODO: likely need to unite pinchSpeed with zoomSpeed + const pinchSpeed = + typeof options.pinchSpeed === 'number' ? options.pinchSpeed : 1; + const bounds = options.bounds; + const maxZoom = + typeof options.maxZoom === 'number' + ? options.maxZoom + : Number.POSITIVE_INFINITY; + const minZoom = typeof options.minZoom === 'number' ? options.minZoom : 0; + + const boundsPadding = + typeof options.boundsPadding === 'number' ? options.boundsPadding : 0.05; + + const speed = + typeof options.zoomSpeed === 'number' + ? options.zoomSpeed + : defaultZoomSpeed; + let transformOrigin = parseTransformOrigin(options.transformOrigin); + + validateBounds(bounds); + + let frameAnimation: number; + let touchInProgress = false; + + // We only need to fire panstart when actual move happens + let panstartFired = false; + + // cache mouse coordinates here + let mouseX: number; + let mouseY: number; + + let pinchZoomLength: number; + + const smoothScroll = kinetic(getPoint, scroll, options.smoothScroll); + + let zoomToAnimation: { cancel: () => void }; + + let multiTouch: boolean; + let paused = false; + + listenForEvents(); + + const api = { + dispose, + moveBy, + moveTo, + smoothMoveTo, + centerOn, + zoomTo: publicZoomTo, + zoomAbs, + + pause, + resume, + isPaused, + + getTransform: getTransformModel, + + setTransform, + + getTransformOrigin, + setTransformOrigin, + }; + + const initialX = + typeof options.initialX === 'number' ? options.initialX : transform.x; + const initialY = + typeof options.initialY === 'number' ? options.initialY : transform.y; + const initialZoom = + typeof options.initialZoom === 'number' + ? options.initialZoom + : transform.scale; + + if ( + initialX != transform.x || + initialY != transform.y || + initialZoom != transform.scale + ) { + zoomAbs(initialX, initialY, initialZoom); + } + + return api; + + function pause() { + releaseEvents(); + paused = true; + } + + function resume() { + if (paused) { + listenForEvents(); + paused = false; + } + } + + function isPaused() { + return paused; + } + + function transformToScreen(x: number, y: number) { + storedCTMResult.x = x; + storedCTMResult.y = y; + + return storedCTMResult; + } + + function setTransform(x: number, y: number, s: number) { + transform.x = x; + transform.y = y; + transform.scale = s; + makeDirty(); + } + + function getTransformModel() { + // TODO: should this be read only? + return transform; + } + + function getTransformOrigin() { + return transformOrigin; + } + + function setTransformOrigin(newTransformOrigin: TransformOrigin) { + transformOrigin = parseTransformOrigin(newTransformOrigin); + } + + function getPoint() { + return { + x: transform.x, + y: transform.y, + }; + } + + function moveTo(x: number, y: number) { + transform.x = x; + transform.y = y; + + keepTransformInsideBounds(); + + makeDirty(); + } + + function moveBy(dx: number, dy: number) { + moveTo(transform.x + dx, transform.y + dy); + } + + function keepTransformInsideBounds() { + const boundingBox = getBoundingBox(); + if (!boundingBox) return; + + let adjusted = false; + const clientRect = getClientRect(); + + let diff = boundingBox.left - clientRect.right; + if (diff > 0) { + transform.x += diff; + adjusted = true; + } + // check the other side: + diff = boundingBox.right - clientRect.left; + if (diff < 0) { + transform.x += diff; + adjusted = true; + } + + // y axis: + diff = boundingBox.top - clientRect.bottom; + if (diff > 0) { + // we adjust transform, so that it matches exactly our bounding box: + // transform.y = boundingBox.top - (boundingBox.height + boundingBox.y) * transform.scale => + // transform.y = boundingBox.top - (clientRect.bottom - transform.y) => + // transform.y = diff + transform.y => + transform.y += diff; + adjusted = true; + } + + diff = boundingBox.bottom - clientRect.top; + if (diff < 0) { + transform.y += diff; + adjusted = true; + } + return adjusted; + } + + /** + * Returns bounding box that should be used to restrict scene movement. + */ + function getBoundingBox() { + if (!bounds) return; // client does not want to restrict movement + + if (typeof bounds === 'boolean') { + // for boolean type we use parent container bounds + const ownerRect = owner.getBoundingClientRect(); + const sceneWidth = ownerRect.width; + const sceneHeight = ownerRect.height; + + return { + left: sceneWidth * boundsPadding, + top: sceneHeight * boundsPadding, + right: sceneWidth * (1 - boundsPadding), + bottom: sceneHeight * (1 - boundsPadding), + }; + } + + return bounds; + } + + function getClientRect() { + const bbox = panController.getBBox(); + const leftTop = client(bbox.left, bbox.top); + + return { + left: leftTop.x, + top: leftTop.y, + right: bbox.width * transform.scale + leftTop.x, + bottom: bbox.height * transform.scale + leftTop.y, + }; + } + + function client(x: number, y: number) { + return { + x: x * transform.scale + transform.x, + y: y * transform.scale + transform.y, + }; + } + + function makeDirty() { + isDirty = true; + + frameAnimation = window.requestAnimationFrame(frame); + } + + function zoomByRatio(clientX: number, clientY: number, ratio: number) { + if (isNaN(clientX) || isNaN(clientY) || isNaN(ratio)) { + throw new Error('zoom requires valid numbers'); + } + + const newScale = transform.scale * ratio; + + if (newScale < minZoom) { + if (transform.scale === minZoom) return; + + ratio = minZoom / transform.scale; + } + if (newScale > maxZoom) { + if (transform.scale === maxZoom) return; + + ratio = maxZoom / transform.scale; + } + + const size = transformToScreen(clientX, clientY); + + transform.x = size.x - ratio * (size.x - transform.x); + transform.y = size.y - ratio * (size.y - transform.y); + + // TODO: https://github.com/anvaka/panzoom/issues/112 + if (bounds && boundsPadding === 1 && minZoom === 1) { + transform.scale *= ratio; + keepTransformInsideBounds(); + } else { + const transformAdjusted = keepTransformInsideBounds(); + if (!transformAdjusted) transform.scale *= ratio; + } + + makeDirty(); + } + + function zoomAbs(clientX: number, clientY: number, zoomLevel: number) { + const ratio = zoomLevel / transform.scale; + zoomByRatio(clientX, clientY, ratio); + } + + function centerOn(ui: SVGElement) { + const parent = ui.ownerSVGElement; + if (!parent) + throw new Error('ui element is required to be within the scene'); + + // TODO: should i use controller's screen CTM? + const clientRect = ui.getBoundingClientRect(); + const cx = clientRect.left + clientRect.width / 2; + const cy = clientRect.top + clientRect.height / 2; + + const container = parent.getBoundingClientRect(); + const dx = container.width / 2 - cx; + const dy = container.height / 2 - cy; + + internalMoveBy(dx, dy); + } + + function smoothMoveTo(x: number, y: number) { + internalMoveBy(x - transform.x, y - transform.y); + } + + function internalMoveBy(dx: number, dy: number) { + return moveBy(dx, dy); + } + + function scroll(x: number, y: number) { + cancelZoomAnimation(); + moveTo(x, y); + } + + function dispose() { + releaseEvents(); + } + + function listenForEvents() { + owner.addEventListener('mousedown', onMouseDown, { passive: true }); + owner.addEventListener('dblclick', onDoubleClick, { passive: false }); + owner.addEventListener('touchstart', onTouch, { passive: true }); + owner.addEventListener('keydown', onKeyDown); + + // Need to listen on the owner container, so that we are not limited + // by the size of the scrollable domElement + owner.addEventListener('wheel', onMouseWheel, { passive: true }); + + makeDirty(); + } + + function releaseEvents() { + owner.removeEventListener('wheel', onMouseWheel); + owner.removeEventListener('mousedown', onMouseDown); + owner.removeEventListener('keydown', onKeyDown); + owner.removeEventListener('dblclick', onDoubleClick); + owner.removeEventListener('touchstart', onTouch); + + if (frameAnimation) { + window.cancelAnimationFrame(frameAnimation); + frameAnimation = 0; + } + + smoothScroll.cancel(); + + releaseDocumentMouse(); + releaseTouches(); + + triggerPanEnd(); + } + + function frame() { + if (isDirty) applyTransform(); + } + + function applyTransform() { + isDirty = false; + + // TODO: Should I allow to cancel this? + panController.applyTransform(transform); + + frameAnimation = 0; + + if (options.onTransform) { + options.onTransform(transform); + } + } + + function onKeyDown(e: KeyboardEvent) { + // let x = 0, + // y = 0, + let z = 0; + if (e.key === 'ArrowUp') { + // y = 1; // up + } else if (e.key === 'ArrowDown') { + // y = -1; // down + } else if (e.key === 'ArrowLeft') { + // x = 1; // left + } else if (e.key === 'ArrowRigh') { + // x = -1; // right + } else if (e.key === '-') { + // DASH or SUBTRACT + z = 1; // `-` - zoom out + } else if (e.key === '=' || e.key === '+') { + // EQUAL SIGN or ADD + z = -1; // `=` - zoom in (equal sign on US layout is under `+`) + } + if (z) { + const scaleMultiplier = getScaleMultiplier(z * 100); + const offset = transformOrigin ? getTransformOriginOffset() : midPoint(); + publicZoomTo(offset.x, offset.y, scaleMultiplier); + } + } + + function midPoint() { + const ownerRect = owner.getBoundingClientRect(); + return { + x: ownerRect.width / 2, + y: ownerRect.height / 2, + }; + } + + function onTouch(e: TouchEvent) { + // let the override the touch behavior + beforeTouch(e); + + if (e.touches.length === 1) { + return handleSingleFingerTouch(e); + } else if (e.touches.length === 2) { + // handleTouchMove() will care about pinch zoom. + pinchZoomLength = getPinchZoomLength(e.touches[0], e.touches[1]); + multiTouch = true; + startTouchListenerIfNeeded(); + } + } + + function beforeTouch(e: TouchEvent) { + e.stopPropagation(); + e.preventDefault(); + } + + function beforeDoubleClick(e: MouseEvent) { + e.preventDefault(); + e.stopPropagation(); + } + + function handleSingleFingerTouch(e: TouchEvent) { + const touch = e.touches[0]; + const offset = getOffsetXY(touch); + const point = transformToScreen(offset.x, offset.y); + mouseX = point.x; + mouseY = point.y; + + smoothScroll.cancel(); + startTouchListenerIfNeeded(); + } + + function startTouchListenerIfNeeded() { + if (touchInProgress) { + // no need to do anything, as we already listen to events; + return; + } + + touchInProgress = true; + document.addEventListener('touchmove', handleTouchMove); + document.addEventListener('touchend', handleTouchEnd); + document.addEventListener('touchcancel', handleTouchEnd); + } + + function handleTouchMove(e: TouchEvent) { + if (e.touches.length === 1) { + e.stopPropagation(); + const touch = e.touches[0]; + + const offset = getOffsetXY(touch); + const point = transformToScreen(offset.x, offset.y); + + const dx = point.x - mouseX; + const dy = point.y - mouseY; + + if (dx !== 0 && dy !== 0) { + triggerPanStart(); + } + mouseX = point.x; + mouseY = point.y; + internalMoveBy(dx, dy); + } else if (e.touches.length === 2) { + // it's a zoom, let's find direction + multiTouch = true; + const t1 = e.touches[0]; + const t2 = e.touches[1]; + const currentPinchLength = getPinchZoomLength(t1, t2); + + // since the zoom speed is always based on distance from 1, we need to apply + // pinch speed only on that distance from 1: + const scaleMultiplier = + 1 + (currentPinchLength / pinchZoomLength - 1) * pinchSpeed; + + const firstTouchPoint = getOffsetXY(t1); + const secondTouchPoint = getOffsetXY(t2); + mouseX = (firstTouchPoint.x + secondTouchPoint.x) / 2; + mouseY = (firstTouchPoint.y + secondTouchPoint.y) / 2; + if (transformOrigin) { + const offset = getTransformOriginOffset(); + mouseX = offset.x; + mouseY = offset.y; + } + + publicZoomTo(mouseX, mouseY, scaleMultiplier); + + pinchZoomLength = currentPinchLength; + e.stopPropagation(); + e.preventDefault(); + } + } + + function handleTouchEnd(e: TouchEvent) { + if (e.touches.length > 0) { + const offset = getOffsetXY(e.touches[0]); + const point = transformToScreen(offset.x, offset.y); + mouseX = point.x; + mouseY = point.y; + } + } + + function getPinchZoomLength(finger1: Touch, finger2: Touch) { + const dx = finger1.clientX - finger2.clientX; + const dy = finger1.clientY - finger2.clientY; + return Math.sqrt(dx * dx + dy * dy); + } + + function onDoubleClick(e: MouseEvent) { + beforeDoubleClick(e); + } + + function onMouseDown(e: MouseEvent) { + if (touchInProgress) { + // modern browsers will fire mousedown for touch events too + // we do not want this: touch is handled separately. + e.stopPropagation(); + return false; + } + + if (e.target !== owner && e.target !== domElement) return; + + // for IE, left click == 1 + // for Firefox, left click == 0 + const isLeftButton = + (e.button === 1 && window.event !== null) || e.button === 0; + if (!isLeftButton) return; + + smoothScroll.cancel(); + + const offset = getOffsetXY(e); + const point = transformToScreen(offset.x, offset.y); + mouseX = point.x; + mouseY = point.y; + + // We need to listen on document itself, since mouse can go outside of the + // window, and we will loose it + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + + return false; + } + + function onMouseMove(e: MouseEvent) { + // no need to worry about mouse events when touch is happening + if (touchInProgress) return; + + if (e.ctrlKey) return; + + triggerPanStart(); + + const offset = getOffsetXY(e); + const point = transformToScreen(offset.x, offset.y); + const dx = point.x - mouseX; + const dy = point.y - mouseY; + + mouseX = point.x; + mouseY = point.y; + + internalMoveBy(dx, dy); + } + + function onMouseUp() { + triggerPanEnd(); + releaseDocumentMouse(); + } + + function releaseDocumentMouse() { + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + panstartFired = false; + } + + function releaseTouches() { + document.removeEventListener('touchmove', handleTouchMove); + document.removeEventListener('touchend', handleTouchEnd); + document.removeEventListener('touchcancel', handleTouchEnd); + panstartFired = false; + multiTouch = false; + touchInProgress = false; + } + + function onMouseWheel(e: WheelEvent) { + smoothScroll.cancel(); + + let delta = e.deltaY; + if (e.deltaMode > 0) delta *= 100; + + const scaleMultiplier = getScaleMultiplier(delta); + + if (scaleMultiplier !== 1) { + const offset = transformOrigin + ? getTransformOriginOffset() + : getOffsetXY(e); + publicZoomTo(offset.x, offset.y, scaleMultiplier); + } + } + + function getOffsetXY(e: MouseEvent | Touch) { + // let offsetX, offsetY; + // I tried using e.offsetX, but that gives wrong results for svg, when user clicks on a path. + const ownerRect = owner.getBoundingClientRect(); + const offsetX = e.clientX - ownerRect.left; + const offsetY = e.clientY - ownerRect.top; + + return { x: offsetX, y: offsetY }; + } + + function getTransformOriginOffset() { + const ownerRect = owner.getBoundingClientRect(); + return { + x: ownerRect.width * transformOrigin.x, + y: ownerRect.height * transformOrigin.y, + }; + } + + function publicZoomTo( + clientX: number, + clientY: number, + scaleMultiplier: number, + ) { + smoothScroll.cancel(); + cancelZoomAnimation(); + return zoomByRatio(clientX, clientY, scaleMultiplier); + } + + function cancelZoomAnimation() { + if (zoomToAnimation) { + zoomToAnimation.cancel(); + zoomToAnimation = null; + } + } + + function getScaleMultiplier(delta: number) { + const sign = Math.sign(delta); + const deltaAdjustedSpeed = Math.min(0.25, Math.abs((speed * delta) / 128)); + return 1 - sign * deltaAdjustedSpeed; + } + + function triggerPanStart() { + if (!panstartFired) { + panstartFired = true; + smoothScroll.start(); + } + } + + function triggerPanEnd() { + if (panstartFired) { + // we should never run smooth scrolling if it was multiTouch (pinch zoom animation): + if (!multiTouch) smoothScroll.stop(); + } + } +} + +function parseTransformOrigin(options: TransformOrigin) { + if (!options) return; + if (typeof options === 'object') { + if (!isNumber(options.x) || !isNumber(options.y)) failTransformOrigin(); + return options; + } + + failTransformOrigin(); +} + +function failTransformOrigin() { + throw new Error( + [ + 'Cannot parse transform origin.', + 'Some good examples:', + ' "center center" can be achieved with {x: 0.5, y: 0.5}', + ' "top center" can be achieved with {x: 0.5, y: 0}', + ' "bottom right" can be achieved with {x: 1, y: 1}', + ].join('\n'), + ); +} + +function validateBounds(bounds: boolean | Bounds) { + if (!bounds) return; + if (typeof bounds === 'boolean') return; // this is okay + // otherwise need to be more thorough: + const validBounds = + isNumber(bounds.left) && + isNumber(bounds.top) && + isNumber(bounds.bottom) && + isNumber(bounds.right); + + if (!validBounds) + throw new Error( + 'Bounds object is not valid. It can be: ' + + 'undefined, boolean (true|false) or an object {left, top, right, bottom}', + ); +} + +function isNumber(x: number) { + return Number.isFinite(x); +} + +// IE 11 does not support isNaN: +function isNaN(value: unknown) { + if (Number.isNaN) { + return Number.isNaN(value); + } + + return value !== value; +} diff --git a/frontend/src/lib/panzoom/kinetic.ts b/frontend/src/lib/panzoom/kinetic.ts new file mode 100644 index 0000000..f0a4101 --- /dev/null +++ b/frontend/src/lib/panzoom/kinetic.ts @@ -0,0 +1,146 @@ +/** + * Allows smooth kinetic scrolling of the surface + */ +export default function kinetic( + getPoint: () => { x: number; y: number }, + scroll: (x: number, y: number) => void, + settings: Record, +) { + if (typeof settings !== 'object') { + // setting could come as boolean, we should ignore it, and use an object. + settings = {}; + } + + const minVelocity = + typeof settings.minVelocity === 'number' ? settings.minVelocity : 5; + const amplitude = + typeof settings.amplitude === 'number' ? settings.amplitude : 0.25; + const cancelAnimationFrame = + typeof settings.cancelAnimationFrame === 'function' + ? settings.cancelAnimationFrame + : getCancelAnimationFrame(); + const requestAnimationFrame = + typeof settings.requestAnimationFrame === 'function' + ? settings.requestAnimationFrame + : getRequestAnimationFrame(); + + let lastPoint: { x: number; y: number }; + let timestamp: number; + const timeConstant = 342; + + let ticker: unknown; + let vx: number, targetX: number, ax: number; + let vy: number, targetY: number, ay: number; + + let raf: unknown; + + return { + start: start, + stop: stop, + cancel: dispose, + }; + + function dispose() { + cancelAnimationFrame(ticker); + cancelAnimationFrame(raf); + } + + function start() { + lastPoint = getPoint(); + + ax = ay = vx = vy = 0; + timestamp = Date.now(); + + cancelAnimationFrame(ticker); + cancelAnimationFrame(raf); + + // we start polling the point position to accumulate velocity + // Once we stop(), we will use accumulated velocity to keep scrolling + // an object. + ticker = requestAnimationFrame(track); + } + + function track() { + const now = Date.now(); + const elapsed = now - timestamp; + timestamp = now; + + const currentPoint = getPoint(); + + const dx = currentPoint.x - lastPoint.x; + const dy = currentPoint.y - lastPoint.y; + + lastPoint = currentPoint; + + const dt = 1000 / (1 + elapsed); + + // moving average + vx = 0.8 * dx * dt + 0.2 * vx; + vy = 0.8 * dy * dt + 0.2 * vy; + + ticker = requestAnimationFrame(track); + } + + function stop() { + cancelAnimationFrame(ticker); + cancelAnimationFrame(raf); + + const currentPoint = getPoint(); + + targetX = currentPoint.x; + targetY = currentPoint.y; + timestamp = Date.now(); + + if (vx < -minVelocity || vx > minVelocity) { + ax = amplitude * vx; + targetX += ax; + } + + if (vy < -minVelocity || vy > minVelocity) { + ay = amplitude * vy; + targetY += ay; + } + + raf = requestAnimationFrame(autoScroll); + } + + function autoScroll() { + const elapsed = Date.now() - timestamp; + + let moving = false; + let dx = 0; + let dy = 0; + + if (ax) { + dx = -ax * Math.exp(-elapsed / timeConstant); + + if (dx > 0.5 || dx < -0.5) moving = true; + else dx = ax = 0; + } + + if (ay) { + dy = -ay * Math.exp(-elapsed / timeConstant); + + if (dy > 0.5 || dy < -0.5) moving = true; + else dy = ay = 0; + } + + if (moving) { + scroll(targetX + dx, targetY + dy); + raf = requestAnimationFrame(autoScroll); + } + } +} + +function getCancelAnimationFrame() { + if (typeof cancelAnimationFrame === 'function') return cancelAnimationFrame; + return clearTimeout; +} + +function getRequestAnimationFrame() { + if (typeof requestAnimationFrame === 'function') return requestAnimationFrame; + + return function (handler: () => void) { + return setTimeout(handler, 16); + }; +} diff --git a/frontend/src/lib/runtime-executor.ts b/frontend/src/lib/runtime-executor.ts new file mode 100644 index 0000000..fb19406 --- /dev/null +++ b/frontend/src/lib/runtime-executor.ts @@ -0,0 +1,149 @@ +import type { Graph, Node, NodeRegistry, NodeType, RuntimeExecutor } from "./types"; + +export class MemoryRuntimeExecutor implements RuntimeExecutor { + + loaded = false; + + constructor(private registry: NodeRegistry) { + setTimeout(() => { + this.loaded = true; + }, 500); + } + + private getNodeTypes(graph: Graph) { + + const typeMap = new Map(); + for (const node of graph.nodes) { + if (!typeMap.has(node.type)) { + const type = this.registry.getNode(node.type); + if (type) { + typeMap.set(node.type, type); + } + } + } + return typeMap; + + + } + + addMetaData(graph: Graph) { + + + // First, lets check if all nodes have a type + const typeMap = this.getNodeTypes(graph); + + const outputNode = graph.nodes.find(node => node.type === "output"); + if (!outputNode) { + throw new Error("No output node found"); + } + outputNode.tmp = outputNode.tmp || {}; + outputNode.tmp.depth = 0; + + const nodeMap = new Map(graph.nodes.map(node => [node.id, node])); + + // loop through all edges and assign the parent and child nodes to each node + for (const edge of graph.edges) { + const [parentId, _parentOutput, childId, childInput] = edge; + const parent = nodeMap.get(parentId); + const child = nodeMap.get(childId); + if (parent && child) { + parent.tmp = parent.tmp || {}; + parent.tmp.children = parent.tmp.children || []; + parent.tmp.children.push(child); + child.tmp = child.tmp || {}; + child.tmp.parents = child.tmp.parents || []; + child.tmp.parents.push(parent); + child.tmp.inputNodes = child.tmp.inputNodes || {}; + child.tmp.inputNodes[childInput] = parent; + } + } + + const nodes = [] + + // loop through all the nodes and assign each nodes its depth + const stack = [outputNode]; + while (stack.length) { + const node = stack.pop(); + if (node) { + node.tmp = node.tmp || {}; + + node.tmp.type = typeMap.get(node.type); + + if (node?.tmp?.depth === undefined) { + node.tmp.depth = 0; + } + if (node?.tmp?.parents !== undefined) { + for (const parent of node.tmp.parents) { + parent.tmp = parent.tmp || {}; + if (parent.tmp?.depth === undefined) { + parent.tmp.depth = node.tmp.depth + 1; + stack.push(parent); + } else { + parent.tmp.depth = Math.max(parent.tmp.depth, node.tmp.depth + 1); + } + } + } + + nodes.push(node); + } + } + + return [outputNode, nodes] as const; + } + + execute(graph: Graph) { + if (!this.loaded) return; + + // Then we add some metadata to the graph + const [outputNode, nodes] = this.addMetaData(graph); + + /* + * Here we sort the nodes into buckets, which we then execute one by one + * +-b2-+-b1-+---b0---+ + * | | | | + * | n3 | n2 | Output | + * | n6 | n4 | Level | + * | | n5 | | + * | | | | + * +----+----+--------+ + */ + + // we execute the nodes from the bottom up + const sortedNodes = nodes.sort((a, b) => (b.tmp?.depth || 0) - (a.tmp?.depth || 0)); + + + // here we store the intermediate results of the nodes + const results: Record = {}; + + for (const node of sortedNodes) { + if (node?.tmp && node?.tmp?.type?.execute) { + const inputs: Record = {}; + for (const [key, input] of Object.entries(node.tmp.type.inputs || {})) { + + // check if the input is connected to another node + const inputNode = node.tmp.inputNodes?.[key]; + if (inputNode) { + if (results[inputNode.id] === undefined) { + console.log(inputNode, node) + throw new Error("Input node has no result"); + } + inputs[key] = results[inputNode.id]; + continue; + } + + // if the input is not connected to another node, we use the value from the node itself + inputs[key] = node.props?.[key] ?? input?.value; + } + + // execute the node and store the result + results[node.id] = node.tmp.type.execute(inputs) as number;; + + } + } + + // return the result of the parent of the output node + return results[outputNode.tmp?.parents?.[0].id as number] as string + + } + +} diff --git a/frontend/src/lib/stores/localStore.ts b/frontend/src/lib/stores/localStore.ts new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/lib/types/index.ts b/frontend/src/lib/types/index.ts index f8e2780..9cd14bf 100644 --- a/frontend/src/lib/types/index.ts +++ b/frontend/src/lib/types/index.ts @@ -1,4 +1,4 @@ -import type { NodeInput } from "./inputs"; +import type { NodeInput, NodeInputType } from "./inputs"; export type { NodeInput } from "./inputs"; export type Node = { @@ -6,8 +6,10 @@ export type Node = { type: string; props?: Record, tmp?: { + depth?: number; parents?: Node[], children?: Node[], + inputNodes?: Record type?: NodeType; downX?: number; downY?: number; @@ -29,11 +31,12 @@ export type Node = { export type NodeType = { id: string; - inputs?: Record; + inputs?: Record outputs?: string[]; meta?: { title?: string; - } + }, + execute?: (inputs: Record) => unknown; } export type Socket = { @@ -47,6 +50,10 @@ export interface NodeRegistry { getNode: (id: string) => NodeType | undefined; } +export interface RuntimeExecutor { + execute: (graph: Graph) => void; +} + export type Edge = [Node, number, Node, string]; diff --git a/frontend/src/lib/types/inputs.ts b/frontend/src/lib/types/inputs.ts index c1d80be..abefced 100644 --- a/frontend/src/lib/types/inputs.ts +++ b/frontend/src/lib/types/inputs.ts @@ -29,3 +29,8 @@ type DefaultOptions = { } export type NodeInput = (NodeInputBoolean | NodeInputFloat | NodeInputInteger | NodeInputSelect) & DefaultOptions; + + +export type NodeInputType> = { + [K in keyof T]: T[K]["value"] +}; diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index c1f4ea5..12dbabb 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -1,15 +1,17 @@ -
-
- -
-
+ + + + + -
+
- +
@@ -51,7 +53,7 @@ left: 10px; } - .canvas-wrapper { + #canvas-wrapper { height: 100vh; }