feat: add initial runtime-executor prototype
This commit is contained in:
parent
19234bdac1
commit
d1d9d7ecae
@ -1,74 +1,18 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { T } from "@threlte/core";
|
import { T } from "@threlte/core";
|
||||||
import { OrbitControls } from "@threlte/extras";
|
import { type OrthographicCamera } from "three";
|
||||||
import { onMount } from "svelte";
|
|
||||||
import { MOUSE, type OrthographicCamera } from "three";
|
|
||||||
import type { OrbitControls as OrbitControlsType } from "three/examples/jsm/Addons.js";
|
|
||||||
|
|
||||||
export let camera: OrthographicCamera | undefined = undefined;
|
export let camera: OrthographicCamera | undefined = undefined;
|
||||||
export let maxZoom = 150;
|
|
||||||
export let minZoom = 4;
|
|
||||||
|
|
||||||
export let controls: OrbitControlsType | undefined = undefined;
|
export let position: [number, number, number] = [0, 0, 4];
|
||||||
|
|
||||||
export const position: [number, number, number] = [0, 1, 0];
|
|
||||||
|
|
||||||
function updateProps() {
|
|
||||||
if (!camera) return;
|
|
||||||
position[0] = camera.position.x;
|
|
||||||
position[1] = camera.position.z;
|
|
||||||
position[2] = camera.zoom;
|
|
||||||
saveControls();
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadControls = () => {
|
|
||||||
if (!controls) return;
|
|
||||||
const stateJSON = localStorage.getItem(`orbitControls`);
|
|
||||||
|
|
||||||
if (stateJSON) {
|
|
||||||
const { target0, position0, zoom0 } = JSON.parse(stateJSON);
|
|
||||||
controls.target0.copy(target0);
|
|
||||||
controls.position0.copy(position0);
|
|
||||||
controls.zoom0 = zoom0;
|
|
||||||
} else {
|
|
||||||
controls.zoom0 = 30;
|
|
||||||
}
|
|
||||||
|
|
||||||
controls.reset();
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveControls = () => {
|
|
||||||
if (!controls) return;
|
|
||||||
controls.saveState();
|
|
||||||
const { target0, position0, zoom0 } = controls;
|
|
||||||
const state = { target0, position0, zoom0 };
|
|
||||||
localStorage.setItem(`orbitControls`, JSON.stringify(state));
|
|
||||||
};
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
loadControls();
|
|
||||||
updateProps();
|
|
||||||
controls?.addEventListener("change", updateProps);
|
|
||||||
return () => {
|
|
||||||
controls?.removeEventListener("change", updateProps);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<T.OrthographicCamera bind:ref={camera} position.y={10} makeDefault>
|
<T.OrthographicCamera
|
||||||
<OrbitControls
|
bind:ref={camera}
|
||||||
args={[camera, document.body]}
|
position.x={0}
|
||||||
mouseButtons={{ LEFT: 0, MIDDLE: 0, RIGHT: MOUSE.PAN }}
|
position.y={10}
|
||||||
bind:ref={controls}
|
position.z={0}
|
||||||
enableZoom={true}
|
rotation.x={-Math.PI / 2}
|
||||||
zoomSpeed={2}
|
zoom={position[2]}
|
||||||
target.y={0}
|
makeDefault
|
||||||
rotateSpeed={0}
|
/>
|
||||||
minPolarAngle={0}
|
|
||||||
maxPolarAngle={0}
|
|
||||||
enablePan={true}
|
|
||||||
zoomToCursor
|
|
||||||
{maxZoom}
|
|
||||||
{minZoom}
|
|
||||||
/>
|
|
||||||
</T.OrthographicCamera>
|
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Node } from "$lib/types";
|
import type { Node } from "$lib/types";
|
||||||
import { getContext } from "svelte";
|
import { getContext, onMount } from "svelte";
|
||||||
import NodeHeader from "./NodeHeader.svelte";
|
import NodeHeader from "./NodeHeader.svelte";
|
||||||
import NodeParameter from "./NodeParameter.svelte";
|
import NodeParameter from "./NodeParameter.svelte";
|
||||||
import { activeNodeId, selectedNodes } from "./graph/stores";
|
import { activeNodeId, selectedNodes } from "./graph/stores";
|
||||||
|
import { getGraphManager } from "./graph/context";
|
||||||
|
|
||||||
export let node: Node;
|
export let node: Node;
|
||||||
export let inView = true;
|
export let inView = true;
|
||||||
@ -17,11 +18,16 @@
|
|||||||
|
|
||||||
let ref: HTMLDivElement;
|
let ref: HTMLDivElement;
|
||||||
|
|
||||||
$: if (node && ref) {
|
$: if (node) {
|
||||||
node.tmp = node.tmp || {};
|
|
||||||
node.tmp.ref = ref;
|
|
||||||
updateNodePosition(node);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (ref) {
|
||||||
|
node.tmp = node.tmp || {};
|
||||||
|
node.tmp.ref = ref;
|
||||||
|
updateNodePosition(node);
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@ -36,7 +42,7 @@
|
|||||||
|
|
||||||
{#each parameters as [key, value], i}
|
{#each parameters as [key, value], i}
|
||||||
<NodeParameter
|
<NodeParameter
|
||||||
{node}
|
bind:node
|
||||||
id={key}
|
id={key}
|
||||||
input={value}
|
input={value}
|
||||||
isLast={i == parameters.length - 1}
|
isLast={i == parameters.length - 1}
|
||||||
|
@ -4,19 +4,23 @@
|
|||||||
import Integer from "$lib/elements/Integer.svelte";
|
import Integer from "$lib/elements/Integer.svelte";
|
||||||
import Select from "$lib/elements/Select.svelte";
|
import Select from "$lib/elements/Select.svelte";
|
||||||
import type { Node, NodeInput } from "$lib/types";
|
import type { Node, NodeInput } from "$lib/types";
|
||||||
|
import { getGraphManager } from "./graph/context";
|
||||||
|
|
||||||
export let node: Node;
|
export let node: Node;
|
||||||
export let input: NodeInput;
|
export let input: NodeInput;
|
||||||
export let id: string;
|
export let id: string;
|
||||||
|
|
||||||
|
const graph = getGraphManager();
|
||||||
|
|
||||||
let value = node?.props?.[id] ?? input.value;
|
let value = node?.props?.[id] ?? input.value;
|
||||||
|
|
||||||
$: if (value) {
|
$: if (node?.props?.[id] !== value) {
|
||||||
node.props = node.props || {};
|
node.props = { ...node.props, [id]: value };
|
||||||
node.props.value = value;
|
graph.execute();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<label for="asd">{id}</label>
|
||||||
{#if input.type === "float"}
|
{#if input.type === "float"}
|
||||||
<Float bind:value />
|
<Float bind:value />
|
||||||
{:else if input.type === "integer"}
|
{:else if input.type === "integer"}
|
||||||
|
@ -68,8 +68,6 @@
|
|||||||
class:disabled={$possibleSocketIds && !$possibleSocketIds.has(socketId)}
|
class:disabled={$possibleSocketIds && !$possibleSocketIds.has(socketId)}
|
||||||
>
|
>
|
||||||
<div class="content" class:disabled={$inputSockets.has(socketId)}>
|
<div class="content" class:disabled={$inputSockets.has(socketId)}>
|
||||||
<label>{id}</label>
|
|
||||||
|
|
||||||
<NodeInput {node} {input} {id} />
|
<NodeInput {node} {input} {id} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -152,16 +150,6 @@
|
|||||||
display: none;
|
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 {
|
svg {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
@ -3,7 +3,6 @@
|
|||||||
|
|
||||||
import BackgroundVert from "./Background.vert";
|
import BackgroundVert from "./Background.vert";
|
||||||
import BackgroundFrag from "./Background.frag";
|
import BackgroundFrag from "./Background.frag";
|
||||||
import { Color } from "three";
|
|
||||||
import { colors } from "../graph/stores";
|
import { colors } from "../graph/stores";
|
||||||
|
|
||||||
export let minZoom = 4;
|
export let minZoom = 4;
|
||||||
|
@ -22,12 +22,3 @@
|
|||||||
<MeshLineMaterial color="red" linewidth={1} attenuate={false} />
|
<MeshLineMaterial color="red" linewidth={1} attenuate={false} />
|
||||||
</T.Mesh>
|
</T.Mesh>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
<style>
|
|
||||||
.wrapper {
|
|
||||||
position: fixed;
|
|
||||||
top: 10px;
|
|
||||||
left: 10px;
|
|
||||||
background: white;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { Vector3 } from "three";
|
import { Vector3 } from "three/src/math/Vector3.js";
|
||||||
import { lines, points } from "./store";
|
import { lines, points } from "./store";
|
||||||
|
|
||||||
export function debugPosition(x: number, y: number) {
|
export function debugPosition(x: number, y: number) {
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { writable } from "svelte/store";
|
import { writable } from "svelte/store";
|
||||||
import type { Vector3 } from "three";
|
import { Vector3 } from "three/src/math/Vector3.js";
|
||||||
|
|
||||||
export const points = writable<Vector3[]>([]);
|
export const points = writable<Vector3[]>([]);
|
||||||
|
|
||||||
|
@ -1,13 +1,21 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { T, extend } from "@threlte/core";
|
import { T, extend } from "@threlte/core";
|
||||||
import { MeshLineGeometry, MeshLineMaterial } from "@threlte/extras";
|
import { MeshLineGeometry, MeshLineMaterial } from "@threlte/extras";
|
||||||
import { CubicBezierCurve, Mesh, Vector2, Vector3 } from "three";
|
import { Color, type Mesh } from "three";
|
||||||
|
import { CubicBezierCurve } from "three/src/extras/curves/CubicBezierCurve.js";
|
||||||
extend({ MeshLineGeometry, MeshLineMaterial });
|
import { Vector3 } from "three/src/math/Vector3.js";
|
||||||
|
import { Vector2 } from "three/src/math/Vector2.js";
|
||||||
|
|
||||||
export let from: { x: number; y: number };
|
export let from: { x: number; y: number };
|
||||||
export let to: { x: number; y: number };
|
export let to: { x: number; y: number };
|
||||||
|
|
||||||
|
const samples = Math.max(
|
||||||
|
5,
|
||||||
|
Math.floor(
|
||||||
|
Math.sqrt(Math.pow(to.x - from.x, 2) + Math.pow(to.y - from.y, 2)) / 2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const curve = new CubicBezierCurve(
|
const curve = new CubicBezierCurve(
|
||||||
new Vector2(from.x, from.y),
|
new Vector2(from.x, from.y),
|
||||||
new Vector2(from.x + 2, from.y),
|
new Vector2(from.x + 2, from.y),
|
||||||
@ -22,9 +30,7 @@
|
|||||||
|
|
||||||
let mesh: Mesh;
|
let mesh: Mesh;
|
||||||
|
|
||||||
import { colors } from "../graph/stores";
|
const color = new Color(32 / 255, 32 / 255, 32 / 255);
|
||||||
|
|
||||||
$: color = $colors.backgroundColorLighter;
|
|
||||||
|
|
||||||
export const update = function (force = false) {
|
export const update = function (force = false) {
|
||||||
if (!force) {
|
if (!force) {
|
||||||
@ -43,9 +49,6 @@
|
|||||||
// Math.pow(to.x - from.x, 2) + Math.pow(to.y - from.y, 2),
|
// Math.pow(to.x - from.x, 2) + Math.pow(to.y - from.y, 2),
|
||||||
// );
|
// );
|
||||||
//
|
//
|
||||||
// let samples = Math.max(5, Math.floor(length));
|
|
||||||
// console.log(samples);
|
|
||||||
const samples = 12;
|
|
||||||
|
|
||||||
curve.v0.set(from.x, from.y);
|
curve.v0.set(from.x, from.y);
|
||||||
curve.v1.set(mid.x, from.y);
|
curve.v1.set(mid.x, from.y);
|
||||||
|
@ -1,10 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { animate, lerp, snapToGrid } from "$lib/helpers";
|
import { animate, lerp, snapToGrid } from "$lib/helpers";
|
||||||
import Debug from "../debug/Debug.svelte";
|
import type { OrthographicCamera } from "three";
|
||||||
import { OrthographicCamera } from "three";
|
|
||||||
import Background from "../background/Background.svelte";
|
import Background from "../background/Background.svelte";
|
||||||
import type { GraphManager } from "$lib/graph-manager";
|
import type { GraphManager } from "$lib/graph-manager";
|
||||||
import { setContext } from "svelte";
|
import { onMount, setContext } from "svelte";
|
||||||
import Camera from "../Camera.svelte";
|
import Camera from "../Camera.svelte";
|
||||||
import GraphView from "./GraphView.svelte";
|
import GraphView from "./GraphView.svelte";
|
||||||
import type { Node as NodeType } from "$lib/types";
|
import type { Node as NodeType } from "$lib/types";
|
||||||
@ -19,7 +18,6 @@
|
|||||||
selectedNodes,
|
selectedNodes,
|
||||||
} from "./stores";
|
} from "./stores";
|
||||||
import BoxSelection from "../BoxSelection.svelte";
|
import BoxSelection from "../BoxSelection.svelte";
|
||||||
import type { OrbitControls } from "three/examples/jsm/Addons.js";
|
|
||||||
|
|
||||||
export let graph: GraphManager;
|
export let graph: GraphManager;
|
||||||
setContext("graphManager", graph);
|
setContext("graphManager", graph);
|
||||||
@ -28,22 +26,36 @@
|
|||||||
const edges = graph.edges;
|
const edges = graph.edges;
|
||||||
|
|
||||||
let camera: OrthographicCamera;
|
let camera: OrthographicCamera;
|
||||||
let controls: OrbitControls;
|
|
||||||
const minZoom = 2;
|
const minZoom = 2;
|
||||||
const maxZoom = 40;
|
const maxZoom = 40;
|
||||||
let mousePosition = [0, 0];
|
let mousePosition = [0, 0];
|
||||||
let mouseDown: null | [number, number] = null;
|
let mouseDown: null | [number, number] = null;
|
||||||
let boxSelection = false;
|
let boxSelection = false;
|
||||||
let cameraPosition: [number, number, number] = [0, 1, 0];
|
let loaded = false;
|
||||||
let width = 100;
|
const cameraDown = [0, 0];
|
||||||
let height = 100;
|
let cameraPosition: [number, number, number] = [0, 0, 4];
|
||||||
|
|
||||||
|
$: if (cameraPosition && loaded) {
|
||||||
|
localStorage.setItem("cameraPosition", JSON.stringify(cameraPosition));
|
||||||
|
}
|
||||||
|
|
||||||
|
let width = globalThis?.innerWidth ?? 100;
|
||||||
|
let height = globalThis?.innerHeight ?? 100;
|
||||||
|
|
||||||
|
let cameraBounds = [-1000, 1000, -1000, 1000];
|
||||||
$: cameraBounds = [
|
$: cameraBounds = [
|
||||||
cameraPosition[0] - width / cameraPosition[2] / 2,
|
cameraPosition[0] - width / cameraPosition[2] / 2,
|
||||||
cameraPosition[0] + width / cameraPosition[2] / 2,
|
cameraPosition[0] + width / cameraPosition[2] / 2,
|
||||||
cameraPosition[1] - height / cameraPosition[2] / 2,
|
cameraPosition[1] - height / cameraPosition[2] / 2,
|
||||||
cameraPosition[1] + height / cameraPosition[2] / 2,
|
cameraPosition[1] + height / cameraPosition[2] / 2,
|
||||||
];
|
];
|
||||||
|
function setCameraTransform(x: number, y: number, z: number) {
|
||||||
|
if (!camera) return;
|
||||||
|
camera.position.x = x;
|
||||||
|
camera.position.z = y;
|
||||||
|
camera.zoom = z;
|
||||||
|
cameraPosition = [x, y, z];
|
||||||
|
}
|
||||||
|
|
||||||
export let debug = {};
|
export let debug = {};
|
||||||
$: debug = {
|
$: debug = {
|
||||||
@ -55,6 +67,7 @@
|
|||||||
? `${$hoveredSocket?.node.id}-${$hoveredSocket?.index}`
|
? `${$hoveredSocket?.node.id}-${$hoveredSocket?.index}`
|
||||||
: null,
|
: null,
|
||||||
selectedNodes: [...($selectedNodes?.values() || [])],
|
selectedNodes: [...($selectedNodes?.values() || [])],
|
||||||
|
cameraPosition,
|
||||||
};
|
};
|
||||||
|
|
||||||
function updateNodePosition(node: NodeType) {
|
function updateNodePosition(node: NodeType) {
|
||||||
@ -93,10 +106,8 @@
|
|||||||
const height = getNodeHeight(node.type);
|
const height = getNodeHeight(node.type);
|
||||||
const width = 20;
|
const width = 20;
|
||||||
return (
|
return (
|
||||||
// check x-axis
|
|
||||||
node.position.x > cameraBounds[0] - width &&
|
node.position.x > cameraBounds[0] - width &&
|
||||||
node.position.x < cameraBounds[1] &&
|
node.position.x < cameraBounds[1] &&
|
||||||
// check y-axis
|
|
||||||
node.position.y > cameraBounds[2] - height &&
|
node.position.y > cameraBounds[2] - height &&
|
||||||
node.position.y < cameraBounds[3]
|
node.position.y < cameraBounds[3]
|
||||||
);
|
);
|
||||||
@ -207,6 +218,7 @@
|
|||||||
} else {
|
} else {
|
||||||
$hoveredSocket = null;
|
$hoveredSocket = null;
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// handle box selection
|
// handle box selection
|
||||||
@ -278,12 +290,53 @@
|
|||||||
updateNodePosition(node);
|
updateNodePosition(node);
|
||||||
|
|
||||||
$edges = $edges;
|
$edges = $edges;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// here we are handling panning of camera
|
||||||
|
let newX =
|
||||||
|
cameraDown[0] - (event.clientX - mouseDown[0]) / cameraPosition[2];
|
||||||
|
let newY =
|
||||||
|
cameraDown[1] - (event.clientY - mouseDown[1]) / cameraPosition[2];
|
||||||
|
|
||||||
|
setCameraTransform(newX, newY, cameraPosition[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const zoomSpeed = 2;
|
||||||
|
function handleMouseScroll(event: WheelEvent) {
|
||||||
|
const bodyIsFocused = document.activeElement === document.body;
|
||||||
|
if (!bodyIsFocused) return;
|
||||||
|
|
||||||
|
// Define zoom speed and clamp it between -1 and 1
|
||||||
|
const isNegative = event.deltaY < 0;
|
||||||
|
const normalizedDelta = Math.abs(event.deltaY * 0.01);
|
||||||
|
const delta = Math.pow(0.95, zoomSpeed * normalizedDelta);
|
||||||
|
|
||||||
|
// Calculate new zoom level and clamp it between minZoom and maxZoom
|
||||||
|
const newZoom = Math.max(
|
||||||
|
minZoom,
|
||||||
|
Math.min(
|
||||||
|
maxZoom,
|
||||||
|
isNegative ? cameraPosition[2] / delta : cameraPosition[2] * delta,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculate the ratio of the new zoom to the original zoom
|
||||||
|
const zoomRatio = newZoom / cameraPosition[2];
|
||||||
|
|
||||||
|
// Update camera position and zoom level
|
||||||
|
setCameraTransform(
|
||||||
|
mousePosition[0] - (mousePosition[0] - cameraPosition[0]) / zoomRatio,
|
||||||
|
mousePosition[1] - (mousePosition[1] - cameraPosition[1]) / zoomRatio,
|
||||||
|
newZoom,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleMouseDown(event: MouseEvent) {
|
function handleMouseDown(event: MouseEvent) {
|
||||||
if (mouseDown) return;
|
if (mouseDown) return;
|
||||||
mouseDown = [event.clientX, event.clientY];
|
mouseDown = [event.clientX, event.clientY];
|
||||||
|
cameraDown[0] = cameraPosition[0];
|
||||||
|
cameraDown[1] = cameraPosition[1];
|
||||||
|
|
||||||
if (event.target instanceof HTMLElement && event.buttons === 1) {
|
if (event.target instanceof HTMLElement && event.buttons === 1) {
|
||||||
const nodeElement = event.target.closest(".node");
|
const nodeElement = event.target.closest(".node");
|
||||||
@ -321,7 +374,6 @@
|
|||||||
}
|
}
|
||||||
} else if (event.ctrlKey) {
|
} else if (event.ctrlKey) {
|
||||||
boxSelection = true;
|
boxSelection = true;
|
||||||
controls.enabled = false;
|
|
||||||
} else {
|
} else {
|
||||||
$activeNodeId = -1;
|
$activeNodeId = -1;
|
||||||
$selectedNodes?.clear();
|
$selectedNodes?.clear();
|
||||||
@ -346,10 +398,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleKeyDown(event: KeyboardEvent) {
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
const bodyIsFocused = document.activeElement === document.body;
|
||||||
|
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
$activeNodeId = -1;
|
$activeNodeId = -1;
|
||||||
$selectedNodes?.clear();
|
$selectedNodes?.clear();
|
||||||
$selectedNodes = $selectedNodes;
|
$selectedNodes = $selectedNodes;
|
||||||
|
document?.activeElement.blur();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.key === "a" && event.ctrlKey) {
|
if (event.key === "a" && event.ctrlKey) {
|
||||||
@ -377,9 +432,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
event.key === "Delete" ||
|
(event.key === "Delete" ||
|
||||||
event.key === "Backspace" ||
|
event.key === "Backspace" ||
|
||||||
event.key === "x"
|
event.key === "x") &&
|
||||||
|
bodyIsFocused
|
||||||
) {
|
) {
|
||||||
if ($activeNodeId !== -1) {
|
if ($activeNodeId !== -1) {
|
||||||
const node = graph.getNode($activeNodeId);
|
const node = graph.getNode($activeNodeId);
|
||||||
@ -495,13 +551,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
mouseDown = null;
|
mouseDown = null;
|
||||||
controls.enabled = true;
|
|
||||||
boxSelection = false;
|
boxSelection = false;
|
||||||
$activeSocket = null;
|
$activeSocket = null;
|
||||||
$possibleSockets = [];
|
$possibleSockets = [];
|
||||||
$possibleSocketIds = null;
|
$possibleSocketIds = null;
|
||||||
$hoveredSocket = null;
|
$hoveredSocket = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (localStorage.getItem("cameraPosition")) {
|
||||||
|
const cPosition = JSON.parse(localStorage.getItem("cameraPosition")!);
|
||||||
|
if (Array.isArray(cPosition)) {
|
||||||
|
setCameraTransform(cPosition[0], cPosition[1], cPosition[2]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loaded = true;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:document
|
<svelte:document
|
||||||
@ -511,18 +576,14 @@
|
|||||||
on:keydown={handleKeyDown}
|
on:keydown={handleKeyDown}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<svelte:window bind:innerWidth={width} bind:innerHeight={height} />
|
<svelte:window
|
||||||
|
on:wheel={handleMouseScroll}
|
||||||
<Debug />
|
bind:innerWidth={width}
|
||||||
|
bind:innerHeight={height}
|
||||||
<Camera
|
|
||||||
bind:controls
|
|
||||||
bind:camera
|
|
||||||
{maxZoom}
|
|
||||||
{minZoom}
|
|
||||||
bind:position={cameraPosition}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Camera bind:camera position={cameraPosition} />
|
||||||
|
|
||||||
<Background {cameraPosition} {maxZoom} {minZoom} {width} {height} />
|
<Background {cameraPosition} {maxZoom} {minZoom} {width} {height} />
|
||||||
|
|
||||||
{#if boxSelection && mouseDown}
|
{#if boxSelection && mouseDown}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { browser } from "$app/environment";
|
import { browser } from "$app/environment";
|
||||||
import type { Socket } from "$lib/types";
|
import type { Socket } from "$lib/types";
|
||||||
import { writable, type Writable } from "svelte/store";
|
import { writable, type Writable } from "svelte/store";
|
||||||
import { Color } from "three";
|
import { Color } from "three/src/math/Color.js";
|
||||||
|
|
||||||
export const activeNodeId: Writable<number> = writable(-1);
|
export const activeNodeId: Writable<number> = writable(-1);
|
||||||
export const selectedNodes: Writable<Set<number> | null> = writable(null);
|
export const selectedNodes: Writable<Set<number> | null> = writable(null);
|
||||||
|
@ -1,44 +1,14 @@
|
|||||||
import { writable, type Writable } from "svelte/store";
|
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";
|
import { HistoryManager } from "./history-manager";
|
||||||
|
import * as templates from "./graphs";
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export class GraphManager {
|
export class GraphManager {
|
||||||
|
|
||||||
status: Writable<"loading" | "idle" | "error"> = writable("loading");
|
status: Writable<"loading" | "idle" | "error"> = writable("loading");
|
||||||
|
|
||||||
|
graph: Graph = { nodes: [], edges: [] };
|
||||||
|
|
||||||
private _nodes: Map<number, Node> = new Map();
|
private _nodes: Map<number, Node> = new Map();
|
||||||
nodes: Writable<Map<number, Node>> = writable(new Map());
|
nodes: Writable<Map<number, Node>> = writable(new Map());
|
||||||
private _edges: Edge[] = [];
|
private _edges: Edge[] = [];
|
||||||
@ -48,7 +18,7 @@ export class GraphManager {
|
|||||||
|
|
||||||
history: HistoryManager = new HistoryManager(this);
|
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.subscribe((nodes) => {
|
||||||
this._nodes = nodes;
|
this._nodes = nodes;
|
||||||
});
|
});
|
||||||
@ -73,6 +43,15 @@ export class GraphManager {
|
|||||||
return { nodes, edges };
|
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) {
|
private _init(graph: Graph) {
|
||||||
const nodes = new Map(graph.nodes.map(node => {
|
const nodes = new Map(graph.nodes.map(node => {
|
||||||
const nodeType = this.nodeRegistry.getNode(node.type);
|
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) {
|
for (const node of this.graph.nodes) {
|
||||||
const nodeType = this.nodeRegistry.getNode(node.type);
|
const nodeType = this.nodeRegistry.getNode(node.type);
|
||||||
@ -120,8 +101,10 @@ export class GraphManager {
|
|||||||
|
|
||||||
this._init(this.graph);
|
this._init(this.graph);
|
||||||
|
|
||||||
this.status.set("idle");
|
setTimeout(() => {
|
||||||
this.history.save();
|
this.status.set("idle");
|
||||||
|
this.history.save();
|
||||||
|
}, 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -164,9 +147,6 @@ export class GraphManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateNodeParents(node: Node) {
|
|
||||||
}
|
|
||||||
|
|
||||||
removeNode(node: Node) {
|
removeNode(node: Node) {
|
||||||
const edges = this._edges.filter((edge) => edge[0].id !== node.id && edge[2].id !== node.id);
|
const edges = this._edges.filter((edge) => edge[0].id !== node.id && edge[2].id !== node.id);
|
||||||
this.edges.set(edges);
|
this.edges.set(edges);
|
||||||
@ -307,49 +287,17 @@ export class GraphManager {
|
|||||||
.filter(Boolean) as unknown as [Node, number, Node, string][];
|
.filter(Boolean) as unknown as [Node, number, Node, string][];
|
||||||
}
|
}
|
||||||
|
|
||||||
static createEmptyGraph({ width = 20, height = 20 } = {}): GraphManager {
|
createTemplate<T extends keyof typeof templates>(template: T, ...args: Parameters<typeof templates[T]>) {
|
||||||
|
switch (template) {
|
||||||
const graph: Graph = {
|
case "grid":
|
||||||
edges: [],
|
return templates.grid(args?.[0] || 5, args?.[1] || 5);
|
||||||
nodes: [],
|
case "tree":
|
||||||
};
|
return templates.tree(args?.[0] || 4);
|
||||||
|
default:
|
||||||
const amount = width * height;
|
throw new Error(`Template not found: ${template}`);
|
||||||
|
|
||||||
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 new GraphManager(graph);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
47
frontend/src/lib/graphs/grid.ts
Normal file
47
frontend/src/lib/graphs/grid.ts
Normal file
@ -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;
|
||||||
|
|
||||||
|
}
|
2
frontend/src/lib/graphs/index.ts
Normal file
2
frontend/src/lib/graphs/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { grid } from "./grid";
|
||||||
|
export { tree } from "./tree";
|
52
frontend/src/lib/graphs/tree.ts
Normal file
52
frontend/src/lib/graphs/tree.ts
Normal file
@ -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 };
|
||||||
|
|
||||||
|
}
|
45
frontend/src/lib/node-registry.ts
Normal file
45
frontend/src/lib/node-registry.ts
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
52
frontend/src/lib/panzoom/domController.ts
Normal file
52
frontend/src/lib/panzoom/domController.ts
Normal file
@ -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;
|
||||||
|
}
|
773
frontend/src/lib/panzoom/index.ts
Normal file
773
frontend/src/lib/panzoom/index.ts
Normal file
@ -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<string, unknown>;
|
||||||
|
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;
|
||||||
|
}
|
146
frontend/src/lib/panzoom/kinetic.ts
Normal file
146
frontend/src/lib/panzoom/kinetic.ts
Normal file
@ -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<string, unknown>,
|
||||||
|
) {
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
}
|
149
frontend/src/lib/runtime-executor.ts
Normal file
149
frontend/src/lib/runtime-executor.ts
Normal file
@ -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<string, NodeType>();
|
||||||
|
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<string, string | boolean | number> = {};
|
||||||
|
|
||||||
|
for (const node of sortedNodes) {
|
||||||
|
if (node?.tmp && node?.tmp?.type?.execute) {
|
||||||
|
const inputs: Record<string, string | number | boolean> = {};
|
||||||
|
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
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
0
frontend/src/lib/stores/localStore.ts
Normal file
0
frontend/src/lib/stores/localStore.ts
Normal file
@ -1,4 +1,4 @@
|
|||||||
import type { NodeInput } from "./inputs";
|
import type { NodeInput, NodeInputType } from "./inputs";
|
||||||
export type { NodeInput } from "./inputs";
|
export type { NodeInput } from "./inputs";
|
||||||
|
|
||||||
export type Node = {
|
export type Node = {
|
||||||
@ -6,8 +6,10 @@ export type Node = {
|
|||||||
type: string;
|
type: string;
|
||||||
props?: Record<string, any>,
|
props?: Record<string, any>,
|
||||||
tmp?: {
|
tmp?: {
|
||||||
|
depth?: number;
|
||||||
parents?: Node[],
|
parents?: Node[],
|
||||||
children?: Node[],
|
children?: Node[],
|
||||||
|
inputNodes?: Record<string, Node>
|
||||||
type?: NodeType;
|
type?: NodeType;
|
||||||
downX?: number;
|
downX?: number;
|
||||||
downY?: number;
|
downY?: number;
|
||||||
@ -29,11 +31,12 @@ export type Node = {
|
|||||||
|
|
||||||
export type NodeType = {
|
export type NodeType = {
|
||||||
id: string;
|
id: string;
|
||||||
inputs?: Record<string, NodeInput>;
|
inputs?: Record<string, NodeInput>
|
||||||
outputs?: string[];
|
outputs?: string[];
|
||||||
meta?: {
|
meta?: {
|
||||||
title?: string;
|
title?: string;
|
||||||
}
|
},
|
||||||
|
execute?: (inputs: Record<string, string | number | boolean>) => unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Socket = {
|
export type Socket = {
|
||||||
@ -47,6 +50,10 @@ export interface NodeRegistry {
|
|||||||
getNode: (id: string) => NodeType | undefined;
|
getNode: (id: string) => NodeType | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RuntimeExecutor {
|
||||||
|
execute: (graph: Graph) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export type Edge = [Node, number, Node, string];
|
export type Edge = [Node, number, Node, string];
|
||||||
|
|
||||||
|
@ -29,3 +29,8 @@ type DefaultOptions = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type NodeInput = (NodeInputBoolean | NodeInputFloat | NodeInputInteger | NodeInputSelect) & DefaultOptions;
|
export type NodeInput = (NodeInputBoolean | NodeInputFloat | NodeInputInteger | NodeInputSelect) & DefaultOptions;
|
||||||
|
|
||||||
|
|
||||||
|
export type NodeInputType<T extends Record<string, NodeInput>> = {
|
||||||
|
[K in keyof T]: T[K]["value"]
|
||||||
|
};
|
||||||
|
@ -1,15 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { onMount } from "svelte";
|
|
||||||
import { PerfMonitor } from "@threlte/extras";
|
|
||||||
import { Canvas } from "@threlte/core";
|
import { Canvas } from "@threlte/core";
|
||||||
import { GraphManager } from "$lib/graph-manager";
|
import { GraphManager } from "$lib/graph-manager";
|
||||||
import Graph from "$lib/components/graph/Graph.svelte";
|
import Graph from "$lib/components/graph/Graph.svelte";
|
||||||
import Details from "$lib/elements/Details.svelte";
|
import Details from "$lib/elements/Details.svelte";
|
||||||
import { JsonView } from "@zerodevx/svelte-json-view";
|
import { JsonView } from "@zerodevx/svelte-json-view";
|
||||||
|
import { MemoryRuntimeExecutor } from "$lib/runtime-executor";
|
||||||
|
import { MemoryNodeRegistry } from "$lib/node-registry";
|
||||||
|
|
||||||
const graph = GraphManager.createEmptyGraph({ width: 12, height: 12 });
|
const nodeRegistry = new MemoryNodeRegistry();
|
||||||
graph.load();
|
const runtimeExecutor = new MemoryRuntimeExecutor(nodeRegistry);
|
||||||
|
|
||||||
|
const graphManager = new GraphManager(nodeRegistry, runtimeExecutor);
|
||||||
|
graphManager.load(graphManager.createTemplate("grid", 5, 5));
|
||||||
|
|
||||||
let debug: undefined;
|
let debug: undefined;
|
||||||
|
|
||||||
@ -30,16 +32,16 @@
|
|||||||
// });
|
// });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="wrapper">
|
<!-- <div class="wrapper"> -->
|
||||||
<Details>
|
<!-- <Details> -->
|
||||||
<JsonView json={debug} />
|
<!-- <JsonView json={debug} /> -->
|
||||||
</Details>
|
<!-- </Details> -->
|
||||||
</div>
|
<!-- </div> -->
|
||||||
|
|
||||||
<div class="canvas-wrapper">
|
<div id="canvas-wrapper">
|
||||||
<Canvas shadows={false} renderMode="on-demand" colorManagementEnabled={false}>
|
<Canvas shadows={false} renderMode="on-demand" colorManagementEnabled={false}>
|
||||||
<!-- <PerfMonitor /> -->
|
<!-- <PerfMonitor /> -->
|
||||||
<Graph {graph} bind:debug />
|
<Graph graph={graphManager} bind:debug />
|
||||||
</Canvas>
|
</Canvas>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -51,7 +53,7 @@
|
|||||||
left: 10px;
|
left: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.canvas-wrapper {
|
#canvas-wrapper {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user