obsidian-visualiser/components/CanvasRenderer.client.vue

199 lines
8.0 KiB
Vue

<script setup lang="ts">
import("~/assets/canvas.css")
import type { Canvas, CanvasNode } from '~/types/canvas';
interface Props
{
canvas: Canvas;
}
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>();
onMounted(async () => {
await nextTick();
let _minX = +Infinity, _minY = +Infinity, _maxX = -Infinity, _maxY = -Infinity;
props.canvas.body.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);
});
minX.value = _minX = _minX - 32;
minY.value = _minY = _minY - 32;
maxX.value = _maxX = _maxX + 32;
maxY.value = _maxY = _maxY + 32;
minZoom.value = zoom.value = Math.min((canvas.value?.clientWidth ?? 0) / (_maxX - _minX), (canvas.value?.clientHeight ?? 0) / (_maxY - _minY)) * 0.9;
bbox.value = canvas.value?.getBoundingClientRect();
await nextTick();
centerX.value = (bbox.value?.width ?? 0) / 2;
centerY.value = (bbox.value?.height ?? 0) / 2;
dispX.value = -(_maxX + _minX) / 2;
dispY.value = -(_maxY + _minY) / 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) 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 *= 1 + (event.deltaY * -0.001);
if (zoom.value > 3)
zoom.value = 3;
if (zoom.value < minZoom.value)
zoom.value = minZoom.value;
}
const reset = (event: PointerEvent) => {
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 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.body.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: '',
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}`,
to: to,
side: toSide,
};
}
</script>
<template>
<div ref="canvas" @pointerdown="onPointerDown" @wheel.passive="onWheel"
@touchstart.prevent="" @dragstart.prevent="" class="canvas-wrapper node-insert-event mod-zoomed-out">
<div class="canvas-controls" style="z-index: 421;">
<div class="canvas-control-group">
<div @click="zoom = clamp(zoom * 1.1, minZoom, 3)" class="canvas-control-item" 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" class="svg-icon lucide-plus">
<path d="M5 12h14"></path>
<path d="M12 5v14"></path>
</svg>
</div>
<div @click="zoom = 1" class="canvas-control-item" 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" class="svg-icon lucide-rotate-cw">
<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="canvas-control-item" 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" class="svg-icon lucide-maximize">
<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="canvas-control-item" 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" class="svg-icon lucide-minus">
<path d="M5 12h14"></path>
</svg>
</div>
</div>
</div>
<div class="canvas" :style="{transform: `translate(${centerX}px, ${centerY}px) scale(${zoom}) translate(${dispX}px, ${dispY}px)`}">
<svg class="canvas-edges">
<CanvasEdge v-for="edge of props.canvas.body.edges" :key="edge.id" :path="path(getNode(edge.fromNode)!, edge.fromSide, getNode(edge.toNode)!, edge.toSide)" :color="edge.color"/>
</svg>
<CanvasNode v-for="node of props.canvas.body.nodes" :key="node.id" :node="node" :zoom="zoom" />
</div>
</div>
</template>