311 lines
13 KiB
Vue
311 lines
13 KiB
Vue
<script setup lang="ts">
|
|
import type { CanvasContent, CanvasNode } from '~/types/canvas';
|
|
|
|
interface Props
|
|
{
|
|
canvas: CanvasContent;
|
|
}
|
|
const props = defineProps<Props>();
|
|
|
|
let dragging = false, posX = 0, posY = 0, dispX = ref(0), dispY = ref(0), minZoom = ref(0.3), zoom = ref(1);
|
|
let centerX = ref(0), centerY = ref(0), canvas = ref<HTMLDivElement>();
|
|
let minX = ref(+Infinity), minY = ref(+Infinity), maxX = ref(-Infinity), maxY = ref(-Infinity);
|
|
let bbox = ref<DOMRect>();
|
|
|
|
let lastDistance = 0;
|
|
|
|
let _minX = +Infinity, _minY = +Infinity, _maxX = -Infinity, _maxY = -Infinity;
|
|
|
|
onMounted(async () => {
|
|
await nextTick();
|
|
|
|
props.canvas.nodes.forEach((e) => {
|
|
_minX = Math.min(_minX, e.x);
|
|
_minY = Math.min(_minY, e.y);
|
|
_maxX = Math.max(_maxX, e.x + e.width);
|
|
_maxY = Math.max(_maxY, e.y + e.height);
|
|
});
|
|
|
|
await nextTick();
|
|
|
|
window.addEventListener('resize', onResize);
|
|
onResize();
|
|
|
|
dispX.value = -(_maxX + _minX) / 2;
|
|
dispY.value = -(_maxY + _minY) / 2;
|
|
|
|
zoom.value = minZoom.value;
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
window.removeEventListener('resize', onResize);
|
|
})
|
|
|
|
const onResize = (event?: Event) => {
|
|
minX.value = _minX = _minX - 32;
|
|
minY.value = _minY = _minY - 32;
|
|
maxX.value = _maxX = _maxX + 32;
|
|
maxY.value = _maxY = _maxY + 32;
|
|
|
|
minZoom.value = Math.min((canvas.value?.clientWidth ?? 1920) / (_maxX - _minX), (canvas.value?.clientHeight ?? 1080) / (_maxY - _minY), 0.01) * 0.9;
|
|
zoom.value = clamp(zoom.value, minZoom.value, 3);
|
|
|
|
bbox.value = (canvas.value ?? document.getElementById('canvas'))?.getBoundingClientRect();
|
|
|
|
centerX.value = (bbox.value?.width ?? 0) / 2;
|
|
centerY.value = (bbox.value?.height ?? 0) / 2;
|
|
}
|
|
|
|
const onPointerDown = (event: PointerEvent) => {
|
|
if (event.isPrimary === false) return;
|
|
dragging = true;
|
|
|
|
posX = event.clientX;
|
|
posY = event.clientY;
|
|
|
|
document.addEventListener('pointermove', onPointerMove);
|
|
document.addEventListener('pointerup', onPointerUp);
|
|
}
|
|
|
|
const onPointerMove = (event: PointerEvent) => {
|
|
if (event.isPrimary === false || dragging === false) return;
|
|
dispX.value -= (posX - event.clientX) / zoom.value;
|
|
dispY.value -= (posY - event.clientY) / zoom.value;
|
|
|
|
posX = event.clientX;
|
|
posY = event.clientY;
|
|
}
|
|
|
|
const onPointerUp = (event: PointerEvent) => {
|
|
if (event.isPrimary === false) return;
|
|
dragging = false;
|
|
document.removeEventListener('pointermove', onPointerMove);
|
|
document.removeEventListener('pointerup', onPointerUp);
|
|
}
|
|
|
|
const onWheel = (event: WheelEvent) => {
|
|
zoom.value = clamp(zoom.value + (event.deltaY * -0.001), minZoom.value, 3);
|
|
}
|
|
|
|
const onTouchStart = (event: TouchEvent) => {
|
|
if(event.touches?.length === 2)
|
|
{
|
|
dragging = false;
|
|
lastDistance = length(event.touches[0].clientX, event.touches[0].clientY, event.touches[1].clientX, event.touches[1].clientY);
|
|
|
|
document.addEventListener('touchmove', onTouchMove);
|
|
document.addEventListener('touchend', onTouchEnd);
|
|
}
|
|
}
|
|
|
|
const onTouchEnd = (event: TouchEvent) => {
|
|
if(event.touches?.length !== 2)
|
|
dragging = true;
|
|
|
|
document.removeEventListener('touchmove', onTouchMove);
|
|
document.removeEventListener('touchend', onTouchEnd);
|
|
}
|
|
|
|
const onTouchMove = (event: TouchEvent) => {
|
|
if(event.touches?.length === 2)
|
|
{
|
|
const distance = length(event.touches[0].clientX, event.touches[0].clientY, event.touches[1].clientX, event.touches[1].clientY);
|
|
zoom.value = clamp(zoom.value * (distance / lastDistance), minZoom.value, 3);
|
|
|
|
lastDistance = distance;
|
|
}
|
|
}
|
|
|
|
const reset = (_: MouseEvent) => {
|
|
zoom.value = minZoom.value;
|
|
dispX.value = -(maxX.value + minX.value) / 2;
|
|
dispY.value = -(maxY.value + minY.value) / 2;
|
|
}
|
|
function clamp(x: number, min: number, max: number): number {
|
|
if (x > max)
|
|
return max;
|
|
if (x < min)
|
|
return min;
|
|
return x;
|
|
}
|
|
function length(x1: number, y1: number, x2: number, y2: number): number {
|
|
return Math.sqrt((x2 - x1)^2 + (y2 - y1)^2);
|
|
}
|
|
function edgePos(side: 'bottom' | 'top' | 'left' | 'right', pos: { x: number, y: number }, n: number): { x: number, y: number } {
|
|
switch (side) {
|
|
case "left":
|
|
return {
|
|
x: pos.x - n,
|
|
y: pos.y
|
|
};
|
|
case "right":
|
|
return {
|
|
x: pos.x + n,
|
|
y: pos.y
|
|
};
|
|
case "top":
|
|
return {
|
|
x: pos.x,
|
|
y: pos.y - n
|
|
};
|
|
case "bottom":
|
|
return {
|
|
x: pos.x,
|
|
y: pos.y + n
|
|
}
|
|
}
|
|
}
|
|
function getNode(id: string): CanvasNode | undefined
|
|
{
|
|
return props.canvas.nodes.find(e => e.id === id);
|
|
}
|
|
function mK(e: { minX: number, minY: number, maxX: number, maxY: number }, t: 'bottom' | 'top' | 'left' | 'right'): { x: number, y: number } {
|
|
switch (t) {
|
|
case "top":
|
|
return { x: (e.minX + e.maxX) / 2, y: e.minY };
|
|
case "right":
|
|
return { x: e.maxX, y: (e.minY + e.maxY) / 2 };
|
|
case "bottom":
|
|
return { x: (e.minX + e.maxX) / 2, y: e.maxY };
|
|
case "left":
|
|
return { x: e.minX, y: (e.minY + e.maxY) / 2 };
|
|
}
|
|
}
|
|
function getBbox(node: CanvasNode): { minX: number, minY: number, maxX: number, maxY: number } {
|
|
return { minX: node.x, minY: node.y, maxX: node.x + node.width, maxY: node.y + node.height };
|
|
}
|
|
function path(from: CanvasNode, fromSide: 'bottom' | 'top' | 'left' | 'right', to: CanvasNode, toSide: 'bottom' | 'top' | 'left' | 'right'): any {
|
|
if(from === undefined || to === undefined)
|
|
{
|
|
return {
|
|
path: '',
|
|
from: {},
|
|
to: {},
|
|
toSide: '',
|
|
}
|
|
}
|
|
const a = mK(getBbox(from), fromSide),
|
|
l = mK(getBbox(to), toSide);
|
|
return bezier(a, fromSide, l, toSide);
|
|
}
|
|
function bezier(from: { x: number, y: number }, fromSide: 'bottom' | 'top' | 'left' | 'right', to: { x: number, y: number }, toSide: 'bottom' | 'top' | 'left' | 'right'): any {
|
|
const r = Math.hypot(from.x - to.x, from.y - to.y), o = clamp(r / 2, 70, 150), a = edgePos(fromSide, from, o), s = edgePos(toSide, to, o);
|
|
return {
|
|
path: `M${from.x},${from.y} C${a.x},${a.y} ${s.x},${s.y} ${to.x},${to.y}`,
|
|
from: from,
|
|
to: to,
|
|
side: toSide,
|
|
};
|
|
}
|
|
function labelCenter(from: CanvasNode, fromSide: 'bottom' | 'top' | 'left' | 'right', to: CanvasNode, toSide: 'bottom' | 'top' | 'left' | 'right'): string {
|
|
const a = mK(getBbox(from), fromSide), l = mK(getBbox(to), toSide);
|
|
const r = Math.hypot(a.x - l.x, a.y - l.y), o = clamp(r / 2, 70, 150), b = edgePos(fromSide, a, o), s = edgePos(toSide, l, o);
|
|
const center = getCenter(a, l, b, s, 0.5);
|
|
return `translate(${center.x}px, ${center.y}px)`;
|
|
}
|
|
function getCenter(n: { x: number, y: number }, i: { x: number, y: number }, r: { x: number, y: number }, o: { x: number, y: number }, e: number): { x: number, y: number } {
|
|
const a = 1 - e, s = a * a * a, l = 3 * e * a * a, c = 3 * e * e * a, u = e * e * e;
|
|
return {
|
|
x: s * n.x + l * r.x + c * o.x + u * i.x,
|
|
y: s * n.y + l * r.y + c * o.y + u * i.y
|
|
};
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<Suspense>
|
|
<template #default>
|
|
<div id="canvas" ref="canvas" @pointerdown="onPointerDown" @wheel.passive="onWheel" @touchstart.passive="onTouchStart"
|
|
@dragstart.prevent="" class="absolute top-0 left-0 overflow-hidden w-full h-full"
|
|
:style="{ '--zoom-multiplier': (1 / Math.pow(zoom, 0.7)) }">
|
|
<div class="border border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10 absolute top-2 left-2 z-[100] overflow-hidden">
|
|
<div @click="zoom = clamp(zoom * 1.1, minZoom, 3)" class="w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer"
|
|
aria-label="Zoom in" data-tooltip-position="left">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"
|
|
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
|
stroke-linejoin="round">
|
|
<path d="M5 12h14"></path>
|
|
<path d="M12 5v14"></path>
|
|
</svg>
|
|
</div>
|
|
<div @click="zoom = 1" class="w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer" aria-label="Reset zoom"
|
|
data-tooltip-position="left">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"
|
|
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
|
stroke-linejoin="round">
|
|
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"></path>
|
|
<path d="M21 3v5h-5"></path>
|
|
</svg>
|
|
</div>
|
|
<div @click="reset" class="w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer" aria-label="Zoom to fit"
|
|
data-tooltip-position="left">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"
|
|
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
|
stroke-linejoin="round">
|
|
<path d="M8 3H5a2 2 0 0 0-2 2v3"></path>
|
|
<path d="M21 8V5a2 2 0 0 0-2-2h-3"></path>
|
|
<path d="M3 16v3a2 2 0 0 0 2 2h3"></path>
|
|
<path d="M16 21h3a2 2 0 0 0 2-2v-3"></path>
|
|
</svg>
|
|
</div>
|
|
<div @click="zoom = clamp(zoom * 0.9, minZoom, 3)" class="w-8 h-8 flex justify-center items-center p-2 hover:bg-light-30 dark:hover:bg-dark-30 cursor-pointer"
|
|
aria-label="Zoom out" data-tooltip-position="left">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"
|
|
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
|
stroke-linejoin="round">
|
|
<path d="M5 12h14"></path>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
<div class="absolute top-0 left-0 w-full h-full origin-top pointer-events-none z-10"
|
|
:style="{transform: `translate(${centerX}px, ${centerY}px) scale(${zoom}) translate(${dispX}px, ${dispY}px)`}">
|
|
<svg class="absolute top-0 left-0 overflow-visible w-full h-full origin-top pointer-events-none z-[1]">
|
|
<CanvasEdge v-for="edge of props.canvas.edges" :key="edge.id"
|
|
:path="path(getNode(edge.fromNode)!, edge.fromSide, getNode(edge.toNode)!, edge.toSide)"
|
|
:color="edge.color" :label="edge.label" />
|
|
</svg>
|
|
<CanvasNode v-for="node of props.canvas.nodes" :key="node.id" :node="node" :zoom="zoom" />
|
|
<template v-for="edge of props.canvas.edges">
|
|
<div :key="edge.id" v-if="edge.label" class="absolute z-10"
|
|
:style="{ transform: labelCenter(getNode(edge.fromNode)!, edge.fromSide, getNode(edge.toNode)!, edge.toSide) }">
|
|
<div class="relative bg-light-20 dark:bg-dark-20 border border-light-35 dark:border-dark-35 px-4 py-2 -translate-x-[50%] -translate-y-[50%]">{{ edge.label }}</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<template #fallback>
|
|
<div class="loading"></div>
|
|
</template>
|
|
</Suspense>
|
|
</template>
|
|
|
|
<style>
|
|
.useless
|
|
{
|
|
@apply fill-light-red;
|
|
@apply dark:fill-dark-red;
|
|
@apply stroke-light-red;
|
|
@apply dark:stroke-dark-red;
|
|
@apply fill-light-orange;
|
|
@apply dark:fill-dark-orange;
|
|
@apply stroke-light-orange;
|
|
@apply dark:stroke-dark-orange;
|
|
@apply fill-light-yellow;
|
|
@apply dark:fill-dark-yellow;
|
|
@apply stroke-light-yellow;
|
|
@apply dark:stroke-dark-yellow;
|
|
@apply fill-light-green;
|
|
@apply dark:fill-dark-green;
|
|
@apply stroke-light-green;
|
|
@apply dark:stroke-dark-green;
|
|
@apply fill-light-cyan;
|
|
@apply dark:fill-dark-cyan;
|
|
@apply stroke-light-cyan;
|
|
@apply dark:stroke-dark-cyan;
|
|
@apply fill-light-purple;
|
|
@apply dark:fill-dark-purple;
|
|
@apply stroke-light-purple;
|
|
@apply dark:stroke-dark-purple;
|
|
}
|
|
</style> |