feat: make graph work in div
This commit is contained in:
parent
0ecf9798c4
commit
404fcbfe39
@ -4,7 +4,7 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite --host",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"tauri:dev": "tauri dev",
|
"tauri:dev": "tauri dev",
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { animate, lerp, snapToGrid } from "$lib/helpers";
|
import { animate, lerp, snapToGrid } from "$lib/helpers";
|
||||||
|
import { LinearSRGBColorSpace } from "three";
|
||||||
|
import { Canvas } from "@threlte/core";
|
||||||
import type { OrthographicCamera } from "three";
|
import type { 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";
|
||||||
@ -26,6 +28,12 @@
|
|||||||
const edges = graph.edges;
|
const edges = graph.edges;
|
||||||
const graphId = graph.id;
|
const graphId = graph.id;
|
||||||
|
|
||||||
|
let wrapper: HTMLDivElement;
|
||||||
|
$: rect =
|
||||||
|
wrapper && width
|
||||||
|
? wrapper.getBoundingClientRect()
|
||||||
|
: { x: 0, y: 0, width: 0, height: 0 };
|
||||||
|
|
||||||
let camera: OrthographicCamera;
|
let camera: OrthographicCamera;
|
||||||
const minZoom = 1;
|
const minZoom = 1;
|
||||||
const maxZoom = 40;
|
const maxZoom = 40;
|
||||||
@ -132,6 +140,9 @@
|
|||||||
function getNodeIdFromEvent(event: MouseEvent) {
|
function getNodeIdFromEvent(event: MouseEvent) {
|
||||||
let clickedNodeId = -1;
|
let clickedNodeId = -1;
|
||||||
|
|
||||||
|
let mx = event.clientX - rect.x;
|
||||||
|
let my = event.clientY - rect.y;
|
||||||
|
|
||||||
if (event.button === 0) {
|
if (event.button === 0) {
|
||||||
// check if the clicked element is a node
|
// check if the clicked element is a node
|
||||||
if (event.target instanceof HTMLElement) {
|
if (event.target instanceof HTMLElement) {
|
||||||
@ -145,10 +156,7 @@
|
|||||||
// if we do not have an active node,
|
// if we do not have an active node,
|
||||||
// we are going to check if we clicked on a node by coordinates
|
// we are going to check if we clicked on a node by coordinates
|
||||||
if (clickedNodeId === -1) {
|
if (clickedNodeId === -1) {
|
||||||
const [downX, downY] = projectScreenToWorld(
|
const [downX, downY] = projectScreenToWorld(mx, my);
|
||||||
event.clientX,
|
|
||||||
event.clientY,
|
|
||||||
);
|
|
||||||
for (const node of $nodes.values()) {
|
for (const node of $nodes.values()) {
|
||||||
const x = node.position[0];
|
const x = node.position[0];
|
||||||
const y = node.position[1];
|
const y = node.position[1];
|
||||||
@ -243,7 +251,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleMouseMove(event: MouseEvent) {
|
function handleMouseMove(event: MouseEvent) {
|
||||||
mousePosition = projectScreenToWorld(event.clientX, event.clientY);
|
let mx = event.clientX - rect.x;
|
||||||
|
let my = event.clientY - rect.y;
|
||||||
|
|
||||||
|
mousePosition = projectScreenToWorld(mx, my);
|
||||||
|
|
||||||
if (!mouseDown) return;
|
if (!mouseDown) return;
|
||||||
|
|
||||||
@ -305,8 +316,8 @@
|
|||||||
const oldX = node.tmp.downX || 0;
|
const oldX = node.tmp.downX || 0;
|
||||||
const oldY = node.tmp.downY || 0;
|
const oldY = node.tmp.downY || 0;
|
||||||
|
|
||||||
let newX = oldX + (event.clientX - mouseDown[0]) / cameraPosition[2];
|
let newX = oldX + (mx - mouseDown[0]) / cameraPosition[2];
|
||||||
let newY = oldY + (event.clientY - mouseDown[1]) / cameraPosition[2];
|
let newY = oldY + (my - mouseDown[1]) / cameraPosition[2];
|
||||||
|
|
||||||
if (event.ctrlKey) {
|
if (event.ctrlKey) {
|
||||||
const snapLevel = getSnapLevel();
|
const snapLevel = getSnapLevel();
|
||||||
@ -344,18 +355,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// here we are handling panning of camera
|
// here we are handling panning of camera
|
||||||
let newX =
|
let newX = cameraDown[0] - (mx - mouseDown[0]) / cameraPosition[2];
|
||||||
cameraDown[0] - (event.clientX - mouseDown[0]) / cameraPosition[2];
|
let newY = cameraDown[1] - (my - mouseDown[1]) / cameraPosition[2];
|
||||||
let newY =
|
|
||||||
cameraDown[1] - (event.clientY - mouseDown[1]) / cameraPosition[2];
|
|
||||||
|
|
||||||
setCameraTransform(newX, newY, cameraPosition[2]);
|
setCameraTransform(newX, newY, cameraPosition[2]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const zoomSpeed = 2;
|
const zoomSpeed = 2;
|
||||||
function handleMouseScroll(event: WheelEvent) {
|
function handleMouseScroll(event: WheelEvent) {
|
||||||
|
console.log(event);
|
||||||
const bodyIsFocused =
|
const bodyIsFocused =
|
||||||
document.activeElement === document.body ||
|
document.activeElement === document.body ||
|
||||||
|
document.activeElement === wrapper ||
|
||||||
document?.activeElement?.id === "graph";
|
document?.activeElement?.id === "graph";
|
||||||
if (!bodyIsFocused) return;
|
if (!bodyIsFocused) return;
|
||||||
|
|
||||||
@ -397,7 +408,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mouseDown = [event.clientX, event.clientY];
|
let mx = event.clientX - rect.x;
|
||||||
|
let my = event.clientY - rect.y;
|
||||||
|
|
||||||
|
mouseDown = [mx, my];
|
||||||
cameraDown[0] = cameraPosition[0];
|
cameraDown[0] = cameraPosition[0];
|
||||||
cameraDown[1] = cameraPosition[1];
|
cameraDown[1] = cameraPosition[1];
|
||||||
|
|
||||||
@ -720,49 +734,66 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:document
|
<div
|
||||||
on:mousemove={handleMouseMove}
|
|
||||||
on:mouseup={handleMouseUp}
|
|
||||||
on:mousedown={handleMouseDown}
|
|
||||||
on:keydown={handleKeyDown}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<svelte:window
|
|
||||||
on:wheel={handleMouseScroll}
|
on:wheel={handleMouseScroll}
|
||||||
bind:innerWidth={width}
|
bind:this={wrapper}
|
||||||
bind:innerHeight={height}
|
class="wrapper"
|
||||||
/>
|
aria-label="Graph"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
bind:clientWidth={width}
|
||||||
|
bind:clientHeight={height}
|
||||||
|
on:keydown={handleKeyDown}
|
||||||
|
on:mousemove={handleMouseMove}
|
||||||
|
on:mousedown={handleMouseDown}
|
||||||
|
on:mouseup={handleMouseUp}
|
||||||
|
>
|
||||||
|
<Canvas
|
||||||
|
shadows={false}
|
||||||
|
renderMode="on-demand"
|
||||||
|
colorManagementEnabled={false}
|
||||||
|
colorSpace={LinearSRGBColorSpace}
|
||||||
|
>
|
||||||
|
<Camera bind:camera position={cameraPosition} />
|
||||||
|
|
||||||
<Camera bind:camera position={cameraPosition} />
|
<Background {cameraPosition} {maxZoom} {minZoom} {width} {height} />
|
||||||
|
|
||||||
<Background {cameraPosition} {maxZoom} {minZoom} {width} {height} />
|
{#if boxSelection && mouseDown}
|
||||||
|
<BoxSelection
|
||||||
|
{cameraPosition}
|
||||||
|
p1={{
|
||||||
|
x: cameraPosition[0] + (mouseDown[0] - width / 2) / cameraPosition[2],
|
||||||
|
y:
|
||||||
|
cameraPosition[1] + (mouseDown[1] - height / 2) / cameraPosition[2],
|
||||||
|
}}
|
||||||
|
p2={{ x: mousePosition[0], y: mousePosition[1] }}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if boxSelection && mouseDown}
|
{#if $status === "idle"}
|
||||||
<BoxSelection
|
{#if addMenuPosition}
|
||||||
{cameraPosition}
|
<AddMenu bind:position={addMenuPosition} {graph} />
|
||||||
p1={{
|
{/if}
|
||||||
x: cameraPosition[0] + (mouseDown[0] - width / 2) / cameraPosition[2],
|
|
||||||
y: cameraPosition[1] + (mouseDown[1] - height / 2) / cameraPosition[2],
|
|
||||||
}}
|
|
||||||
p2={{ x: mousePosition[0], y: mousePosition[1] }}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if $status === "idle"}
|
{#if $activeSocket}
|
||||||
{#if addMenuPosition}
|
<FloatingEdge
|
||||||
<AddMenu bind:position={addMenuPosition} {graph} />
|
from={{ x: $activeSocket.position[0], y: $activeSocket.position[1] }}
|
||||||
{/if}
|
to={{ x: mousePosition[0], y: mousePosition[1] }}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if $activeSocket}
|
<GraphView {nodes} {edges} {cameraPosition} />
|
||||||
<FloatingEdge
|
{:else if $status === "loading"}
|
||||||
from={{ x: $activeSocket.position[0], y: $activeSocket.position[1] }}
|
<span>Loading</span>
|
||||||
to={{ x: mousePosition[0], y: mousePosition[1] }}
|
{:else if $status === "error"}
|
||||||
/>
|
<span>Error</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
</Canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
<GraphView {nodes} {edges} {cameraPosition} />
|
<style>
|
||||||
{:else if $status === "loading"}
|
.wrapper {
|
||||||
<span>Loading</span>
|
position: relative;
|
||||||
{:else if $status === "error"}
|
height: 100%;
|
||||||
<span>Error</span>
|
}
|
||||||
{/if}
|
</style>
|
||||||
|
10
app/src/lib/components/graph/Wrapper.svelte
Normal file
10
app/src/lib/components/graph/Wrapper.svelte
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { GraphManager } from "$lib/graph-manager";
|
||||||
|
export let manager: GraphManager;
|
||||||
|
|
||||||
|
import Graph from "$lib/components/graph/Graph.svelte";
|
||||||
|
export let debug: any;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- <PerfMonitor /> -->
|
||||||
|
<Graph graph={manager} bind:debug />
|
@ -0,0 +1,2 @@
|
|||||||
|
import Wrapper from './Wrapper.svelte';
|
||||||
|
export default Wrapper;
|
@ -1,5 +1,5 @@
|
|||||||
import { writable, type Writable } from "svelte/store";
|
import { writable, type Writable } from "svelte/store";
|
||||||
import type { Graph, Node, Edge, Socket, NodeRegistry, RuntimeExecutor, } from "@nodes/s";
|
import type { Graph, Node, Edge, Socket, NodeRegistry, RuntimeExecutor, } from "@nodes/types";
|
||||||
import { HistoryManager } from "./history-manager";
|
import { HistoryManager } from "./history-manager";
|
||||||
import * as templates from "./graphs";
|
import * as templates from "./graphs";
|
||||||
import EventEmitter from "./helpers/EventEmitter";
|
import EventEmitter from "./helpers/EventEmitter";
|
||||||
|
65
app/src/lib/grid/Cell.svelte
Normal file
65
app/src/lib/grid/Cell.svelte
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { getContext } from "svelte";
|
||||||
|
import type { Writable } from "svelte/store";
|
||||||
|
|
||||||
|
let index = -1;
|
||||||
|
let wrapper: HTMLDivElement;
|
||||||
|
|
||||||
|
$: if (index === -1) {
|
||||||
|
index = getContext<() => number>("registerCell")();
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizes = getContext<Writable<string[]>>("sizes");
|
||||||
|
|
||||||
|
let downSizes: string[] = [];
|
||||||
|
let downWidth = 0;
|
||||||
|
let mouseDown = false;
|
||||||
|
let startX = 0;
|
||||||
|
|
||||||
|
function handleMouseDown(event: MouseEvent) {
|
||||||
|
downSizes = [...$sizes];
|
||||||
|
mouseDown = true;
|
||||||
|
startX = event.clientX;
|
||||||
|
downWidth = wrapper.getBoundingClientRect().width;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseMove(event: MouseEvent) {
|
||||||
|
if (mouseDown) {
|
||||||
|
const width = downWidth + startX - event.clientX;
|
||||||
|
$sizes[index] = `${width}px`;
|
||||||
|
$sizes = $sizes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window
|
||||||
|
on:mouseup={() => (mouseDown = false)}
|
||||||
|
on:mousemove={handleMouseMove}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if index > 0}
|
||||||
|
<div
|
||||||
|
class="seperator"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
on:mousedown={handleMouseDown}
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="cell" bind:this={wrapper}>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.cell {
|
||||||
|
display: block;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.seperator {
|
||||||
|
background: white;
|
||||||
|
cursor: ew-resize;
|
||||||
|
height: 100%;
|
||||||
|
width: 5px;
|
||||||
|
}
|
||||||
|
</style>
|
9
app/src/lib/grid/Grid.svelte
Normal file
9
app/src/lib/grid/Grid.svelte
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { setContext } from "svelte";
|
||||||
|
|
||||||
|
export let id = "grid-0";
|
||||||
|
|
||||||
|
setContext("grid-id", id);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<slot {id} />
|
33
app/src/lib/grid/Row.svelte
Normal file
33
app/src/lib/grid/Row.svelte
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { setContext, getContext } from "svelte";
|
||||||
|
import localStore from "$lib/helpers/localStore";
|
||||||
|
|
||||||
|
const gridId = getContext<string>("grid-id");
|
||||||
|
let sizes = localStore<string[]>(gridId, []);
|
||||||
|
|
||||||
|
let registerIndex = 0;
|
||||||
|
setContext("registerCell", function () {
|
||||||
|
let index = registerIndex;
|
||||||
|
registerIndex++;
|
||||||
|
if (registerIndex > $sizes.length) {
|
||||||
|
$sizes = [...$sizes, "1fr"];
|
||||||
|
}
|
||||||
|
console.log("registering cell", registerIndex);
|
||||||
|
return index;
|
||||||
|
});
|
||||||
|
|
||||||
|
setContext("sizes", sizes);
|
||||||
|
|
||||||
|
$: cols = $sizes.map((size, i) => `${i > 0 ? "5px " : ""}` + size).join(" ");
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="wrapper" style={`grid-template-columns: ${cols};`}>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.wrapper {
|
||||||
|
display: grid;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
6
app/src/lib/grid/index.ts
Normal file
6
app/src/lib/grid/index.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { withSubComponents } from "$lib/helpers";
|
||||||
|
import Grid from "./Grid.svelte";
|
||||||
|
import Row from "./Row.svelte";
|
||||||
|
import Cell from "./Cell.svelte";
|
||||||
|
|
||||||
|
export default withSubComponents(Grid, { Row, Cell });
|
@ -94,3 +94,14 @@ export const createLogger = (() => {
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|
||||||
|
export function withSubComponents<A, B extends Record<string, any>>(
|
||||||
|
component: A,
|
||||||
|
subcomponents: B
|
||||||
|
): A & B {
|
||||||
|
Object.keys(subcomponents).forEach((key) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(component as any)[key] = (subcomponents as any)[key];
|
||||||
|
});
|
||||||
|
return component as A & B;
|
||||||
|
}
|
||||||
|
53
app/src/lib/helpers/localStore.ts
Normal file
53
app/src/lib/helpers/localStore.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { writable, type Writable } from "svelte/store";
|
||||||
|
|
||||||
|
function isStore(v: unknown): v is Writable<unknown> {
|
||||||
|
return v !== null && typeof v === "object" && "subscribe" in v && "set" in v;
|
||||||
|
}
|
||||||
|
|
||||||
|
const storeIds: Map<string, ReturnType<typeof createLocalStore>> = new Map();
|
||||||
|
|
||||||
|
const HAS_LOCALSTORAGE = "localStorage" in globalThis;
|
||||||
|
|
||||||
|
function createLocalStore<T>(key: string, initialValue: T | Writable<T>) {
|
||||||
|
|
||||||
|
let store: Writable<T>;
|
||||||
|
|
||||||
|
if (HAS_LOCALSTORAGE) {
|
||||||
|
const localValue = localStorage.getItem(key);
|
||||||
|
const value = localValue ? JSON.parse(localValue) : null;
|
||||||
|
if (value === null) {
|
||||||
|
if (isStore(initialValue)) {
|
||||||
|
store = initialValue;
|
||||||
|
} else {
|
||||||
|
store = writable(initialValue);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
store = writable(value);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return isStore(initialValue) ? initialValue : writable(initialValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
store.subscribe((value) => {
|
||||||
|
localStorage.setItem(key, JSON.stringify(value));
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscribe: store.subscribe,
|
||||||
|
set: store.set,
|
||||||
|
update: store.update
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default function localStore<T>(key: string, initialValue: T | Writable<T>): Writable<T> {
|
||||||
|
|
||||||
|
if (storeIds.has(key)) return storeIds.get(key) as Writable<T>;
|
||||||
|
|
||||||
|
const store = createLocalStore(key, initialValue)
|
||||||
|
|
||||||
|
storeIds.set(key, store);
|
||||||
|
|
||||||
|
return store
|
||||||
|
|
||||||
|
}
|
@ -14,7 +14,7 @@ const nodeTypes: NodeType[] = [
|
|||||||
{
|
{
|
||||||
id: "max/plantarium/math",
|
id: "max/plantarium/math",
|
||||||
inputs: {
|
inputs: {
|
||||||
"op_type": { title: "type", type: "select", labels: ["add", "subtract", "multiply", "divide"], value: 0 },
|
"op_type": { label: "type", type: "select", labels: ["add", "subtract", "multiply", "divide"], value: 0 },
|
||||||
"a": { type: "float" },
|
"a": { type: "float" },
|
||||||
"b": { type: "float" },
|
"b": { type: "float" },
|
||||||
},
|
},
|
||||||
|
@ -1,52 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
@ -1,773 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
@ -1,146 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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);
|
|
||||||
};
|
|
||||||
}
|
|
@ -1,15 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Canvas } from "@threlte/core";
|
import Grid from "$lib/grid";
|
||||||
|
import Graph from "$lib/components/graph";
|
||||||
import { GraphManager } from "$lib/graph-manager";
|
import { GraphManager } from "$lib/graph-manager";
|
||||||
import Graph from "$lib/components/graph/Graph.svelte";
|
|
||||||
import { MemoryRuntimeExecutor } from "$lib/runtime-executor";
|
import { MemoryRuntimeExecutor } from "$lib/runtime-executor";
|
||||||
import { MemoryNodeRegistry, RemoteNodeRegistry } from "$lib/node-registry";
|
import { MemoryNodeRegistry, RemoteNodeRegistry } from "$lib/node-registry";
|
||||||
import { LinearSRGBColorSpace } from "three";
|
|
||||||
import Details from "$lib/components/Details.svelte";
|
import Details from "$lib/components/Details.svelte";
|
||||||
import { JsonView } from "@zerodevx/svelte-json-view";
|
import { JsonView } from "@zerodevx/svelte-json-view";
|
||||||
|
|
||||||
const memNodeRegistry = new MemoryNodeRegistry();
|
const memNodeRegistry = new MemoryNodeRegistry();
|
||||||
const nodeRegistry = new RemoteNodeRegistry("http://localhost:5174");
|
const nodeRegistry = new RemoteNodeRegistry("http://localhost:3001");
|
||||||
|
|
||||||
const runtimeExecutor = new MemoryRuntimeExecutor(nodeRegistry);
|
const runtimeExecutor = new MemoryRuntimeExecutor(nodeRegistry);
|
||||||
|
|
||||||
@ -29,7 +28,7 @@
|
|||||||
let debug: undefined;
|
let debug: undefined;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="wrapper">
|
<div class="details-wrapper">
|
||||||
<Details>
|
<Details>
|
||||||
<button
|
<button
|
||||||
on:click={() => graphManager.load(graphManager.createTemplate("tree", 5))}
|
on:click={() => graphManager.load(graphManager.createTemplate("tree", 5))}
|
||||||
@ -53,24 +52,30 @@
|
|||||||
</Details>
|
</Details>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="canvas-wrapper">
|
<div class="wrapper">
|
||||||
<Canvas
|
<header>header</header>
|
||||||
shadows={false}
|
<Grid.Row>
|
||||||
renderMode="on-demand"
|
<Grid.Cell></Grid.Cell>
|
||||||
colorManagementEnabled={false}
|
<Grid.Cell>
|
||||||
colorSpace={LinearSRGBColorSpace}
|
<Graph manager={graphManager} />
|
||||||
>
|
</Grid.Cell>
|
||||||
<!-- <PerfMonitor /> -->
|
</Grid.Row>
|
||||||
<Graph graph={graphManager} bind:debug />
|
|
||||||
</Canvas>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
#canvas-wrapper {
|
header {
|
||||||
height: 100vh;
|
border-bottom: solid thin white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wrapper {
|
.wrapper {
|
||||||
|
height: 100vh;
|
||||||
|
width: 100vw;
|
||||||
|
color: white;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 50px 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-wrapper {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
top: 10px;
|
top: 10px;
|
||||||
@ -89,7 +94,5 @@
|
|||||||
:global(body) {
|
:global(body) {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100vw;
|
|
||||||
height: 100vh;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -59,6 +59,10 @@ body.theme-catppuccin {
|
|||||||
--background-color-darker: #11111b;
|
--background-color-darker: #11111b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* canvas { */
|
/* canvas { */
|
||||||
/* display: none !important; */
|
/* display: none !important; */
|
||||||
/* } */
|
/* } */
|
||||||
|
@ -3,16 +3,16 @@ import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
|||||||
|
|
||||||
/** @type {import('@sveltejs/kit').Config} */
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
const config = {
|
const config = {
|
||||||
// Consult https://kit.svelte.dev/docs/integrations#preprocessors
|
// Consult https://kit.svelte.dev/docs/integrations#preprocessors
|
||||||
// for more information about preprocessors
|
// for more information about preprocessors
|
||||||
preprocess: vitePreprocess(),
|
preprocess: vitePreprocess(),
|
||||||
|
|
||||||
kit: {
|
kit: {
|
||||||
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
|
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
|
||||||
// If your environment is not supported or you settled on a specific environment, switch out the adapter.
|
// If your environment is not supported or you settled on a specific environment, switch out the adapter.
|
||||||
// See https://kit.svelte.dev/docs/adapters for more information about adapters.
|
// See https://kit.svelte.dev/docs/adapters for more information about adapters.
|
||||||
adapter: adapter()
|
adapter: adapter()
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
@ -5,6 +5,11 @@ import wasm from "vite-plugin-wasm";
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [sveltekit(), glsl(), wasm()],
|
plugins: [sveltekit(), glsl(), wasm()],
|
||||||
|
|
||||||
|
server: {
|
||||||
|
port: 8080,
|
||||||
|
},
|
||||||
|
|
||||||
ssr: {
|
ssr: {
|
||||||
noExternal: ['three'],
|
noExternal: ['three'],
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build:nodes": "pnpm -r --filter './nodes/**' build"
|
"build:nodes": "pnpm -r --filter './nodes/**' build",
|
||||||
|
"dev": "pnpm -r --filter 'app' --filter './packages/node-registry' dev"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,16 +3,18 @@ import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
|||||||
|
|
||||||
/** @type {import('@sveltejs/kit').Config} */
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
const config = {
|
const config = {
|
||||||
// Consult https://kit.svelte.dev/docs/integrations#preprocessors
|
// Consult https://kit.svelte.dev/docs/integrations#preprocessors
|
||||||
// for more information about preprocessors
|
// for more information about preprocessors
|
||||||
preprocess: vitePreprocess(),
|
preprocess: vitePreprocess(),
|
||||||
|
|
||||||
kit: {
|
|
||||||
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
|
|
||||||
// If your environment is not supported or you settled on a specific environment, switch out the adapter.
|
kit: {
|
||||||
// See https://kit.svelte.dev/docs/adapters for more information about adapters.
|
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
|
||||||
adapter: adapter()
|
// If your environment is not supported or you settled on a specific environment, switch out the adapter.
|
||||||
}
|
// See https://kit.svelte.dev/docs/adapters for more information about adapters.
|
||||||
|
adapter: adapter()
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
@ -4,6 +4,10 @@ import wasm from 'vite-plugin-wasm';
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [sveltekit(), wasm()],
|
plugins: [sveltekit(), wasm()],
|
||||||
|
|
||||||
|
server: {
|
||||||
|
port: 3001,
|
||||||
|
},
|
||||||
test: {
|
test: {
|
||||||
include: ['src/**/*.{test,spec}.{js,ts}']
|
include: ['src/**/*.{test,spec}.{js,ts}']
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user