<template> <panZoom v-if="visibility !== Visibility.None" v-show="visibility === Visibility.Visible" :style="[ { width, height }, style ]" :class="classes" selector=".g1" :options="{ initialZoom: 1, minZoom: 0.1, maxZoom: 10, zoomDoubleClickSpeed: 1 }" ref="stage" @init="onInit" @mousemove="drag" @touchmove="drag" @mousedown="(e: MouseEvent) => mouseDown(e)" @touchstart="(e: TouchEvent) => mouseDown(e)" @mouseup="() => endDragging(dragging)" @touchend="() => endDragging(dragging)" @mouseleave="() => endDragging(dragging)" > <svg class="stage" width="100%" height="100%"> <g class="g1"> <transition-group name="link" appear> <g v-for="(link, i) in links || []" :key="i"> <BoardLinkVue :link="link" /> </g> </transition-group> <transition-group name="grow" :duration="500" appear> <g v-for="node in sortedNodes" :key="node.id" style="transition-duration: 0s"> <BoardNodeVue :node="node" :nodeType="types[node.type]" :dragging="draggingNode" :dragged="dragged" :hasDragged="hasDragged" :receivingNode="receivingNode?.id === node.id" :selectedNode="selectedNode" :selectedAction="selectedAction" @mouseDown="mouseDown" @endDragging="endDragging" /> </g> </transition-group> </g> </svg> </panZoom> </template> <script setup lang="ts"> import { BoardNode, GenericBoard, getNodeProperty } from "features/boards/board"; import { FeatureComponent, Visibility } from "features/feature"; import { PersistentState } from "game/persistence"; import { computed, ref, toRefs } from "vue"; import panZoom from "vue-panzoom"; import BoardLinkVue from "./BoardLink.vue"; import BoardNodeVue from "./BoardNode.vue"; const _props = defineProps<FeatureComponent<GenericBoard>>(); const props = toRefs(_props); const lastMousePosition = ref({ x: 0, y: 0 }); const dragged = ref({ x: 0, y: 0 }); const dragging = ref<number | null>(null); const hasDragged = ref(false); // eslint-disable-next-line @typescript-eslint/no-explicit-any const stage = ref<any>(null); const draggingNode = computed(() => dragging.value == null ? undefined : props.nodes.value.find(node => node.id === dragging.value) ); const sortedNodes = computed(() => { const nodes = props.nodes.value.slice(); if (draggingNode.value) { const node = nodes.splice(nodes.indexOf(draggingNode.value), 1)[0]; nodes.push(node); } return nodes; }); const receivingNode = computed(() => { const node = draggingNode.value; if (node == null) { return null; } const position = { x: node.position.x + dragged.value.x, y: node.position.y + dragged.value.y }; let smallestDistance = Number.MAX_VALUE; return props.nodes.value.reduce((smallest: BoardNode | null, curr: BoardNode) => { if (curr.id === node.id) { return smallest; } const nodeType = props.types.value[curr.type]; const canAccept = getNodeProperty(nodeType.canAccept, curr); if (!canAccept) { return smallest; } const distanceSquared = Math.pow(position.x - curr.position.x, 2) + Math.pow(position.y - curr.position.y, 2); let size = getNodeProperty(nodeType.size, curr); if (distanceSquared > smallestDistance || distanceSquared > size * size) { return smallest; } smallestDistance = distanceSquared; return curr; }, null); }); // eslint-disable-next-line @typescript-eslint/no-explicit-any function onInit(panzoomInstance: any) { panzoomInstance.setTransformOrigin(null); } function mouseDown(e: MouseEvent | TouchEvent, nodeID: number | null = null, draggable = false) { if (dragging.value == null) { e.preventDefault(); e.stopPropagation(); let clientX, clientY; if ("touches" in e) { if (e.touches.length === 1) { clientX = e.touches[0].clientX; clientY = e.touches[0].clientY; } else { return; } } else { clientX = e.clientX; clientY = e.clientY; } lastMousePosition.value = { x: clientX, y: clientY }; dragged.value = { x: 0, y: 0 }; hasDragged.value = false; if (draggable) { dragging.value = nodeID; } } if (nodeID != null) { props[PersistentState].value.selectedNode = null; props[PersistentState].value.selectedAction = null; } } function drag(e: MouseEvent | TouchEvent) { const zoom = stage.value.$panZoomInstance.getTransform().scale; let clientX, clientY; if ("touches" in e) { if (e.touches.length === 1) { clientX = e.touches[0].clientX; clientY = e.touches[0].clientY; } else { endDragging(dragging.value); return; } } else { clientX = e.clientX; clientY = e.clientY; } dragged.value = { x: dragged.value.x + (clientX - lastMousePosition.value.x) / zoom, y: dragged.value.y + (clientY - lastMousePosition.value.y) / zoom }; lastMousePosition.value = { x: clientX, y: clientY }; if (Math.abs(dragged.value.x) > 10 || Math.abs(dragged.value.y) > 10) { hasDragged.value = true; } if (dragging.value) { e.preventDefault(); e.stopPropagation(); } } function endDragging(nodeID: number | null) { if (dragging.value != null && dragging.value === nodeID && draggingNode.value != null) { draggingNode.value.position.x += Math.round(dragged.value.x / 25) * 25; draggingNode.value.position.y += Math.round(dragged.value.y / 25) * 25; const nodes = props.nodes.value; nodes.splice(nodes.indexOf(draggingNode.value), 1); nodes.push(draggingNode.value); if (receivingNode.value) { props.types.value[receivingNode.value.type].onDrop?.( receivingNode.value, draggingNode.value ); } dragging.value = null; } else if (!hasDragged.value) { props[PersistentState].value.selectedNode = null; props[PersistentState].value.selectedAction = null; } } </script> <style> .vue-pan-zoom-scene { width: 100%; height: 100%; cursor: move; } .g1 { transition-duration: 0s; } .link-enter-from, .link-leave-to { opacity: 0; } </style>