You've already forked obsidian-visualiser
Move visualiser to explorer folder and start working on account
This commit is contained in:
43
components/canvas/CanvasEdge.vue
Normal file
43
components/canvas/CanvasEdge.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
interface Props
|
||||
{
|
||||
path: {
|
||||
path: string;
|
||||
from: { x: number; y: number };
|
||||
to: { x: number; y: number };
|
||||
side: 'bottom' | 'top' | 'left' | 'right';
|
||||
};
|
||||
color?: string;
|
||||
label?: string;
|
||||
}
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const rotation = {
|
||||
top: "180",
|
||||
bottom: "0",
|
||||
left: "90",
|
||||
right: "270"
|
||||
};
|
||||
|
||||
function hexToRgb(hex: string): string {
|
||||
return `${parseInt(hex.substring(1, 3), 16)}, ${parseInt(hex.substring(3, 5), 16)}, ${parseInt(hex.substring(5, 7), 16)}`;
|
||||
}
|
||||
|
||||
const classes: any = { 'is-themed': props.color !== undefined, 'mod-canvas-color-custom': (props?.color?.startsWith('#') ?? false) };
|
||||
|
||||
if (props.color !== undefined) {
|
||||
if (!props.color.startsWith('#'))
|
||||
classes['mod-canvas-color-' + props.color] = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<g :class="classes"
|
||||
:style="{ '--canvas-color': props?.color?.startsWith('#') ? hexToRgb(props.color) : undefined }">
|
||||
<path class="canvas-display-path" :d="props.path.path"></path>
|
||||
</g>
|
||||
<g :class="classes"
|
||||
:style="{ '--canvas-color': props?.color?.startsWith('#') ? hexToRgb(props.color) : undefined, transform: `translate(${props.path.to.x}px, ${props.path.to.y}px) rotate(${rotation[props.path.side]}deg)` }">
|
||||
<polygon class="canvas-path-end" points="0,0 6.5,10.4 -6.5,10.4"></polygon>
|
||||
</g>
|
||||
</template>
|
||||
66
components/canvas/CanvasNode.vue
Normal file
66
components/canvas/CanvasNode.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import type { CanvasNode } from '~/types/canvas';
|
||||
|
||||
interface Props {
|
||||
node: CanvasNode;
|
||||
zoom: number;
|
||||
}
|
||||
|
||||
function getColor(color: string): string
|
||||
{
|
||||
if(props.node?.color?.startsWith('#'))
|
||||
return hexToRgb(color);
|
||||
else
|
||||
return getComputedStyle(document.body, null).getPropertyValue('--canvas-color-' + props.node.color);
|
||||
}
|
||||
function hexToRgb(hex: string): string
|
||||
{
|
||||
return `${parseInt(hex.substring(1, 3), 16)},${parseInt(hex.substring(3, 5), 16)},${parseInt(hex.substring(5, 7), 16)}`;
|
||||
}
|
||||
function darken(rgb: string): boolean
|
||||
{
|
||||
const [r, g, b] = rgb.split(',');
|
||||
return (299 * parseInt(r) + 587 * parseInt(g) + 114 * parseInt(b)) / 1e3 >= 150;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const classes: any = { 'canvas-node-group': props.node.type === 'group', 'is-themed': props.node.color !== undefined, 'mod-canvas-color-custom': (props.node?.color?.startsWith('#') ?? false) };
|
||||
const size = Math.max(props.node.width, props.node.height);
|
||||
|
||||
if(props.node.color !== undefined)
|
||||
{
|
||||
if (!props.node.color.startsWith('#'))
|
||||
classes['mod-canvas-color-' + props.node.color] = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="canvas-node" :class="classes" :style="{transform: `translate(${node.x}px, ${node.y}px)`, width: `${node.width}px`, height: `${node.height}px`, '--canvas-node-width': `${node.width}px`, '--canvas-node-height': `${node.height}px`, '--canvas-color': props.node?.color?.startsWith('#') ? hexToRgb(props.node.color) : undefined}">
|
||||
<div class="canvas-node-container">
|
||||
<template v-if="props.node.type === 'group' || props.zoom > Math.min(0.38, 1000 / size)">
|
||||
<div class="canvas-node-content markdown-embed">
|
||||
<div v-if="props.node.text?.body?.children?.length > 0" class="markdown-embed-content node-insert-event" style="">
|
||||
<div class="markdown-preview-view markdown-rendered node-insert-event show-indentation-guide allow-fold-headings allow-fold-lists">
|
||||
<div class="markdown-preview-sizer markdown-preview-section">
|
||||
<ContentRenderer :value="props.node.text"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="canvas-node-placeholder">
|
||||
<div class="canvas-icon-placeholder">
|
||||
<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-align-left">
|
||||
<line x1="21" y1="6" x2="3" y2="6"></line>
|
||||
<line x1="15" y1="12" x2="3" y2="12"></line>
|
||||
<line x1="17" y1="18" x2="3" y2="18"></line>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="props.node.type === 'group' && props.node.label !== undefined" class="canvas-group-label" :class="{'mod-foreground-dark': darken(getColor(props?.node?.color ?? '')), 'mod-foreground-light': !darken(getColor(props?.node?.color ?? ''))}">{{ props.node.label }}</div>
|
||||
</div>
|
||||
</template>
|
||||
241
components/canvas/CanvasRenderer.client.vue
Normal file
241
components/canvas/CanvasRenderer.client.vue
Normal file
@@ -0,0 +1,241 @@
|
||||
<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>();
|
||||
|
||||
let _minX = +Infinity, _minY = +Infinity, _maxX = -Infinity, _maxY = -Infinity;
|
||||
|
||||
onMounted(async () => {
|
||||
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);
|
||||
});
|
||||
|
||||
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 ?? 0) / (_maxX - _minX), (canvas.value?.clientHeight ?? 0) / (_maxY - _minY)) * 0.9;
|
||||
zoom.value = clamp(zoom.value, minZoom.value, 3);
|
||||
|
||||
bbox.value = canvas.value?.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) 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);
|
||||
document.removeEventListener('pointercancel', onPointerUp);
|
||||
}
|
||||
|
||||
const onWheel = (event: WheelEvent) => {
|
||||
zoom.value = clamp(zoom.value * 1 + (event.deltaY * -0.001), minZoom.value, 3);
|
||||
}
|
||||
|
||||
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 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: '',
|
||||
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>
|
||||
<div ref="canvas" @pointerdown="onPointerDown" @wheel.passive="onWheel" @touchstart.prevent="" @dragstart.prevent=""
|
||||
class="canvas-wrapper node-insert-event mod-zoomed-out"
|
||||
:style="{ '--zoom-multiplier': (1 / Math.pow(zoom, 0.7)) }">
|
||||
<div class="canvas-controls" style="z-index: 999;">
|
||||
<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" :label="edge.label" />
|
||||
</svg>
|
||||
<CanvasNode v-for="node of props.canvas.body.nodes" :key="node.id" :node="node" :zoom="zoom" />
|
||||
<template v-for="edge of props.canvas.body.edges">
|
||||
<div :key="edge.id" v-if="edge.label" class="canvas-path-label-wrapper"
|
||||
:style="{ transform: labelCenter(getNode(edge.fromNode)!, edge.fromSide, getNode(edge.toNode)!, edge.toSide) }">
|
||||
<div class="canvas-path-label">{{ edge.label }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user