forked from profectus/Profectus
WIP on rewriting board
This commit is contained in:
parent
6ba25f9abd
commit
1cbe97251c
11 changed files with 820 additions and 1489 deletions
|
@ -7,3 +7,12 @@
|
||||||
.modifier-toggle.collapsed {
|
.modifier-toggle.collapsed {
|
||||||
transform: translate(-5px, -5px) rotate(-90deg);
|
transform: translate(-5px, -5px) rotate(-90deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.node-text {
|
||||||
|
text-anchor: middle;
|
||||||
|
dominant-baseline: middle;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 200%;
|
||||||
|
pointer-events: none;
|
||||||
|
filter: drop-shadow(3px 3px 2px var(--tooltip-background));
|
||||||
|
}
|
||||||
|
|
|
@ -1,92 +1,371 @@
|
||||||
import Node from "components/Node.vue";
|
import Board from "features/boards/Board.vue";
|
||||||
import Spacer from "components/layout/Spacer.vue";
|
import CircleProgress from "features/boards/CircleProgress.vue";
|
||||||
|
import SVGNode from "features/boards/SVGNode.vue";
|
||||||
|
import SquareProgress from "features/boards/SquareProgress.vue";
|
||||||
|
import {
|
||||||
|
NodePosition,
|
||||||
|
placeInAvailableSpace,
|
||||||
|
setupActions,
|
||||||
|
setupDraggableNode,
|
||||||
|
setupSelectable,
|
||||||
|
setupUniqueIds
|
||||||
|
} from "features/boards/board";
|
||||||
import { jsx } from "features/feature";
|
import { jsx } from "features/feature";
|
||||||
import { createResource, trackBest, trackOOMPS, trackTotal } from "features/resources/resource";
|
|
||||||
import type { GenericTree } from "features/trees/tree";
|
|
||||||
import { branchedResetPropagation, createTree } from "features/trees/tree";
|
|
||||||
import { globalBus } from "game/events";
|
|
||||||
import type { BaseLayer, GenericLayer } from "game/layers";
|
import type { BaseLayer, GenericLayer } from "game/layers";
|
||||||
import { createLayer } from "game/layers";
|
import { createLayer } from "game/layers";
|
||||||
|
import { persistent } from "game/persistence";
|
||||||
import type { Player } from "game/player";
|
import type { Player } from "game/player";
|
||||||
import player from "game/player";
|
import { ComponentPublicInstance, computed, ref, watch } from "vue";
|
||||||
import type { DecimalSource } from "util/bignum";
|
|
||||||
import Decimal, { format, formatTime } from "util/bignum";
|
|
||||||
import { render } from "util/vue";
|
|
||||||
import { computed, toRaw } from "vue";
|
|
||||||
import prestige from "./layers/prestige";
|
import prestige from "./layers/prestige";
|
||||||
|
|
||||||
|
type ANode = NodePosition & { id: number; links: number[]; type: "anode" };
|
||||||
|
type BNode = NodePosition & { id: number; links: number[]; type: "bnode" };
|
||||||
|
type NodeTypes = ANode | BNode;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @hidden
|
* @hidden
|
||||||
*/
|
*/
|
||||||
export const main = createLayer("main", function (this: BaseLayer) {
|
export const main = createLayer("main", function (this: BaseLayer) {
|
||||||
const points = createResource<DecimalSource>(10);
|
const board = ref<ComponentPublicInstance<typeof Board>>();
|
||||||
const best = trackBest(points);
|
|
||||||
const total = trackTotal(points);
|
|
||||||
|
|
||||||
const pointGain = computed(() => {
|
const { select, deselect, selected } = setupSelectable<NodeTypes>();
|
||||||
// eslint-disable-next-line prefer-const
|
const {
|
||||||
let gain = new Decimal(1);
|
select: selectAction,
|
||||||
return gain;
|
deselect: deselectAction,
|
||||||
});
|
selected: selectedAction
|
||||||
globalBus.on("update", diff => {
|
} = setupSelectable<number>();
|
||||||
points.value = Decimal.add(points.value, Decimal.times(pointGain.value, diff));
|
|
||||||
});
|
|
||||||
const oomps = trackOOMPS(points, pointGain);
|
|
||||||
|
|
||||||
const tree = createTree(() => ({
|
watch(selected, selected => {
|
||||||
nodes: [[prestige.treeNode]],
|
if (selected == null) {
|
||||||
branches: [],
|
deselectAction();
|
||||||
onReset() {
|
}
|
||||||
points.value = toRaw(this.resettingNode.value) === toRaw(prestige.treeNode) ? 0 : 10;
|
});
|
||||||
best.value = points.value;
|
|
||||||
total.value = points.value;
|
const {
|
||||||
|
startDrag,
|
||||||
|
endDrag,
|
||||||
|
drag,
|
||||||
|
nodeBeingDragged,
|
||||||
|
hasDragged,
|
||||||
|
receivingNodes,
|
||||||
|
receivingNode,
|
||||||
|
dragDelta
|
||||||
|
} = setupDraggableNode<NodeTypes /* | typeof cNode*/>({
|
||||||
|
board,
|
||||||
|
isDraggable: function (node) {
|
||||||
|
return nodes.value.includes(node);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// a nodes can be slotted into b nodes to draw a branch between them, with limited connections
|
||||||
|
// a nodes can be selected and have an action to spawn a b node, and vice versa
|
||||||
|
// Newly spawned nodes should find a safe spot to spawn, and display a link to their creator
|
||||||
|
// a nodes use all the stuff circles used to have, and b diamonds
|
||||||
|
// c node also exists but is a single Upgrade element that cannot be selected, but can be dragged
|
||||||
|
// d nodes are a performance test - 1000 simple nodes that have no interactions
|
||||||
|
// Make all nodes animate in (decorator? `fadeIn(feature)?)
|
||||||
|
const nodes = persistent<NodeTypes[]>([{ id: 0, x: 0, y: 0, links: [], type: "anode" }]);
|
||||||
|
const nodesById = computed<Record<string, NodeTypes>>(() =>
|
||||||
|
nodes.value.reduce((acc, curr) => ({ ...acc, [curr.id]: curr }), {})
|
||||||
|
);
|
||||||
|
function mouseDownNode(e: MouseEvent | TouchEvent, node: NodeTypes) {
|
||||||
|
if (nodeBeingDragged.value == null) {
|
||||||
|
startDrag(e, node);
|
||||||
|
}
|
||||||
|
deselect();
|
||||||
|
}
|
||||||
|
function mouseUpNode(e: MouseEvent | TouchEvent, node: NodeTypes) {
|
||||||
|
if (!hasDragged.value) {
|
||||||
|
endDrag();
|
||||||
|
select(node);
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function getTranslateString(node: NodePosition, overrideSelected?: boolean) {
|
||||||
|
const isSelected = overrideSelected == null ? selected.value === node : overrideSelected;
|
||||||
|
const isDragging = !isSelected && nodeBeingDragged.value === node;
|
||||||
|
let x = node.x;
|
||||||
|
let y = node.y;
|
||||||
|
if (isDragging) {
|
||||||
|
x += dragDelta.value.x;
|
||||||
|
y += dragDelta.value.y;
|
||||||
|
}
|
||||||
|
return ` translate(${x}px,${y}px)`;
|
||||||
|
}
|
||||||
|
function getRotateString(rotation: number) {
|
||||||
|
return ` rotate(${rotation}deg) `;
|
||||||
|
}
|
||||||
|
function getScaleString(node: NodePosition, overrideSelected?: boolean) {
|
||||||
|
const isSelected = overrideSelected == null ? selected.value === node : overrideSelected;
|
||||||
|
return isSelected ? " scale(1.2)" : "";
|
||||||
|
}
|
||||||
|
function getOpacityString(node: NodePosition, overrideSelected?: boolean) {
|
||||||
|
const isSelected = overrideSelected == null ? selected.value === node : overrideSelected;
|
||||||
|
const isDragging = !isSelected && nodeBeingDragged.value === node;
|
||||||
|
if (isDragging) {
|
||||||
|
return "; opacity: 0.5;";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderANode = function (node: ANode) {
|
||||||
|
return (
|
||||||
|
<SVGNode
|
||||||
|
style={`transform: ${getTranslateString(node)}${getOpacityString(node)}`}
|
||||||
|
onMouseDown={e => mouseDownNode(e, node)}
|
||||||
|
onMouseUp={e => mouseUpNode(e, node)}
|
||||||
|
>
|
||||||
|
<g style={`transform: ${getScaleString(node)}; transition-duration: 0s`}>
|
||||||
|
{receivingNodes.value.includes(node) && (
|
||||||
|
<circle
|
||||||
|
r="58"
|
||||||
|
fill="var(--background)"
|
||||||
|
stroke={receivingNode.value === node ? "#0F0" : "#0F03"}
|
||||||
|
stroke-width="2"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<CircleProgress r={54.5} progress={0.5} stroke="var(--accent2)" />
|
||||||
|
<circle
|
||||||
|
r="50"
|
||||||
|
fill="var(--raised-background)"
|
||||||
|
stroke="var(--outline)"
|
||||||
|
stroke-width="4"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
{selected.value === node && selectedAction.value === 0 && (
|
||||||
|
<text y="140" fill="var(--foreground)" class="node-text">
|
||||||
|
Spawn B Node
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
<text fill="var(--foreground)" class="node-text">
|
||||||
|
A
|
||||||
|
</text>
|
||||||
|
</SVGNode>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const aActions = setupActions({
|
||||||
|
node: selected,
|
||||||
|
shouldShowActions: () => selected.value?.type === "anode",
|
||||||
|
actions(node) {
|
||||||
|
return [
|
||||||
|
p => (
|
||||||
|
<g
|
||||||
|
style={`transform: ${getTranslateString(
|
||||||
|
p,
|
||||||
|
selectedAction.value === 0
|
||||||
|
)}${getScaleString(p, selectedAction.value === 0)}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (selectedAction.value === 0) {
|
||||||
|
spawnBNode(node);
|
||||||
|
} else {
|
||||||
|
selectAction(0);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<circle fill="black" r="20"></circle>
|
||||||
|
<text fill="white" class="material-icons" x="-12" y="12">
|
||||||
|
add
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
];
|
||||||
},
|
},
|
||||||
resetPropagation: branchedResetPropagation
|
distance: 100
|
||||||
})) as GenericTree;
|
});
|
||||||
|
const sqrtTwo = Math.sqrt(2);
|
||||||
|
const renderBNode = function (node: BNode) {
|
||||||
|
return (
|
||||||
|
<SVGNode
|
||||||
|
style={`transform: ${getTranslateString(node)}${getOpacityString(node)}`}
|
||||||
|
onMouseDown={e => mouseDownNode(e, node)}
|
||||||
|
onMouseUp={e => mouseUpNode(e, node)}
|
||||||
|
>
|
||||||
|
<g
|
||||||
|
style={`transform: ${getScaleString(node)}${getRotateString(
|
||||||
|
45
|
||||||
|
)}; transition-duration: 0s`}
|
||||||
|
>
|
||||||
|
{receivingNodes.value.includes(node) && (
|
||||||
|
<rect
|
||||||
|
width={50 * sqrtTwo + 16}
|
||||||
|
height={50 * sqrtTwo + 16}
|
||||||
|
style={`translate(${(-50 * sqrtTwo + 16) / 2}, ${
|
||||||
|
(-50 * sqrtTwo + 16) / 2
|
||||||
|
})`}
|
||||||
|
fill="var(--background)"
|
||||||
|
stroke={receivingNode.value === node ? "#0F0" : "#0F03"}
|
||||||
|
stroke-width="2"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<SquareProgress
|
||||||
|
size={50 * sqrtTwo + 9}
|
||||||
|
progress={0.5}
|
||||||
|
stroke="var(--accent2)"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
width={50 * sqrtTwo}
|
||||||
|
height={50 * sqrtTwo}
|
||||||
|
style={`transform: translate(${(-50 * sqrtTwo) / 2}px, ${
|
||||||
|
(-50 * sqrtTwo) / 2
|
||||||
|
}px)`}
|
||||||
|
fill="var(--raised-background)"
|
||||||
|
stroke="var(--outline)"
|
||||||
|
stroke-width="4"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
{selected.value === node && selectedAction.value === 0 && (
|
||||||
|
<text y="140" fill="var(--foreground)" class="node-text">
|
||||||
|
Spawn A Node
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
<text fill="var(--foreground)" class="node-text">
|
||||||
|
B
|
||||||
|
</text>
|
||||||
|
</SVGNode>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const bActions = setupActions({
|
||||||
|
node: selected,
|
||||||
|
shouldShowActions: () => selected.value?.type === "bnode",
|
||||||
|
actions(node) {
|
||||||
|
return [
|
||||||
|
p => (
|
||||||
|
<g
|
||||||
|
style={`transform: ${getTranslateString(
|
||||||
|
p,
|
||||||
|
selectedAction.value === 0
|
||||||
|
)}${getScaleString(p, selectedAction.value === 0)}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (selectedAction.value === 0) {
|
||||||
|
spawnANode(node);
|
||||||
|
} else {
|
||||||
|
selectAction(0);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<circle fill="white" r="20"></circle>
|
||||||
|
<text fill="black" class="material-icons" x="-12" y="12">
|
||||||
|
add
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
];
|
||||||
|
},
|
||||||
|
distance: 100
|
||||||
|
});
|
||||||
|
function spawnANode(parent: NodeTypes) {
|
||||||
|
const node: ANode = {
|
||||||
|
x: parent.x,
|
||||||
|
y: parent.y,
|
||||||
|
type: "anode",
|
||||||
|
links: [parent.id],
|
||||||
|
id: nextId.value
|
||||||
|
};
|
||||||
|
placeInAvailableSpace(node, nodes.value);
|
||||||
|
nodes.value.push(node);
|
||||||
|
}
|
||||||
|
function spawnBNode(parent: NodeTypes) {
|
||||||
|
const node: BNode = {
|
||||||
|
x: parent.x,
|
||||||
|
y: parent.y,
|
||||||
|
type: "bnode",
|
||||||
|
links: [parent.id],
|
||||||
|
id: nextId.value
|
||||||
|
};
|
||||||
|
placeInAvailableSpace(node, nodes.value);
|
||||||
|
nodes.value.push(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
// const cNode = createUpgrade(() => ({
|
||||||
|
// requirements: createCostRequirement(() => ({ cost: 10, resource: points })),
|
||||||
|
// style: {
|
||||||
|
// x: "100px",
|
||||||
|
// y: "100px"
|
||||||
|
// }
|
||||||
|
// }));
|
||||||
|
// makeDraggable(cNode); // TODO make decorator
|
||||||
|
|
||||||
|
// const dNodes;
|
||||||
|
|
||||||
|
const links = jsx(() => (
|
||||||
|
<>
|
||||||
|
{nodes.value
|
||||||
|
.reduce(
|
||||||
|
(acc, curr) => [
|
||||||
|
...acc,
|
||||||
|
...curr.links.map(l => ({ from: curr, to: nodesById.value[l] }))
|
||||||
|
],
|
||||||
|
[] as { from: NodeTypes; to: NodeTypes }[]
|
||||||
|
)
|
||||||
|
.map(link => (
|
||||||
|
<line
|
||||||
|
stroke="white"
|
||||||
|
stroke-width={4}
|
||||||
|
x1={
|
||||||
|
nodeBeingDragged.value === link.from
|
||||||
|
? dragDelta.value.x + link.from.x
|
||||||
|
: link.from.x
|
||||||
|
}
|
||||||
|
y1={
|
||||||
|
nodeBeingDragged.value === link.from
|
||||||
|
? dragDelta.value.y + link.from.y
|
||||||
|
: link.from.y
|
||||||
|
}
|
||||||
|
x2={
|
||||||
|
nodeBeingDragged.value === link.to
|
||||||
|
? dragDelta.value.x + link.to.x
|
||||||
|
: link.to.x
|
||||||
|
}
|
||||||
|
y2={
|
||||||
|
nodeBeingDragged.value === link.to
|
||||||
|
? dragDelta.value.y + link.to.y
|
||||||
|
: link.to.y
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
));
|
||||||
|
|
||||||
|
const nextId = setupUniqueIds(() => nodes.value);
|
||||||
|
|
||||||
|
function filterNodes(n: NodeTypes) {
|
||||||
|
return n !== nodeBeingDragged.value && n !== selected.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNode(node: NodeTypes | undefined) {
|
||||||
|
if (node == undefined) {
|
||||||
|
return undefined;
|
||||||
|
} else if (node.type === "anode") {
|
||||||
|
return renderANode(node);
|
||||||
|
} else if (node.type === "bnode") {
|
||||||
|
return renderBNode(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: "Tree",
|
name: "Tree",
|
||||||
links: tree.links,
|
|
||||||
display: jsx(() => (
|
display: jsx(() => (
|
||||||
<>
|
<>
|
||||||
{player.devSpeed === 0 ? (
|
<Board
|
||||||
<div>
|
onDrag={drag}
|
||||||
Game Paused
|
onMouseDown={deselect}
|
||||||
<Node id="paused" />
|
onMouseUp={endDrag}
|
||||||
</div>
|
onMouseLeave={endDrag}
|
||||||
) : null}
|
ref={board}
|
||||||
{player.devSpeed != null && player.devSpeed !== 0 && player.devSpeed !== 1 ? (
|
>
|
||||||
<div>
|
<SVGNode>{links()}</SVGNode>
|
||||||
Dev Speed: {format(player.devSpeed)}x
|
{nodes.value.filter(filterNodes).map(renderNode)}
|
||||||
<Node id="devspeed" />
|
<SVGNode>
|
||||||
</div>
|
{aActions()}
|
||||||
) : null}
|
{bActions()}
|
||||||
{player.offlineTime != null && player.offlineTime !== 0 ? (
|
</SVGNode>
|
||||||
<div>
|
{renderNode(selected.value)}
|
||||||
Offline Time: {formatTime(player.offlineTime)}
|
{renderNode(nodeBeingDragged.value)}
|
||||||
<Node id="offline" />
|
</Board>
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div>
|
|
||||||
{Decimal.lt(points.value, "1e1000") ? <span>You have </span> : null}
|
|
||||||
<h2>{format(points.value)}</h2>
|
|
||||||
{Decimal.lt(points.value, "1e1e6") ? <span> points</span> : null}
|
|
||||||
</div>
|
|
||||||
{Decimal.gt(pointGain.value, 0) ? (
|
|
||||||
<div>
|
|
||||||
({oomps.value})
|
|
||||||
<Node id="oomps" />
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<Spacer />
|
|
||||||
{render(tree)}
|
|
||||||
</>
|
</>
|
||||||
)),
|
)),
|
||||||
points,
|
boardNodes: nodes
|
||||||
best,
|
// cNode
|
||||||
total,
|
|
||||||
oomps,
|
|
||||||
tree
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -1,278 +1,75 @@
|
||||||
<template>
|
<template>
|
||||||
<panZoom
|
<panZoom
|
||||||
v-if="isVisible(visibility)"
|
selector=".stage"
|
||||||
:style="[
|
|
||||||
{
|
|
||||||
width,
|
|
||||||
height
|
|
||||||
},
|
|
||||||
style
|
|
||||||
]"
|
|
||||||
:class="classes"
|
|
||||||
selector=".g1"
|
|
||||||
:options="{ initialZoom: 1, minZoom: 0.1, maxZoom: 10, zoomDoubleClickSpeed: 1 }"
|
:options="{ initialZoom: 1, minZoom: 0.1, maxZoom: 10, zoomDoubleClickSpeed: 1 }"
|
||||||
ref="stage"
|
ref="stage"
|
||||||
@init="onInit"
|
@init="onInit"
|
||||||
@mousemove="drag"
|
@mousemove="(e: MouseEvent) => emit('drag', e)"
|
||||||
@touchmove="drag"
|
@touchmove="(e: TouchEvent) => emit('drag', e)"
|
||||||
@mousedown="(e: MouseEvent) => mouseDown(e)"
|
@mouseleave="(e: MouseEvent) => emit('mouseLeave', e)"
|
||||||
@touchstart="(e: TouchEvent) => mouseDown(e)"
|
@mouseup="(e: MouseEvent) => emit('mouseUp', e)"
|
||||||
@mouseup="() => endDragging(unref(draggingNode))"
|
@touchend.passive="(e: TouchEvent) => emit('mouseUp', e)"
|
||||||
@touchend.passive="() => endDragging(unref(draggingNode))"
|
|
||||||
@mouseleave="() => endDragging(unref(draggingNode), true)"
|
|
||||||
>
|
>
|
||||||
<svg class="stage" width="100%" height="100%">
|
<div
|
||||||
<g class="g1">
|
class="event-listener"
|
||||||
<transition-group name="link" appear>
|
@mousedown="(e: MouseEvent) => emit('mouseDown', e)"
|
||||||
<g
|
@touchstart="(e: TouchEvent) => emit('mouseDown', e)"
|
||||||
v-for="link in unref(links) || []"
|
/>
|
||||||
:key="`${link.startNode.id}-${link.endNode.id}`"
|
<div class="stage">
|
||||||
>
|
<slot />
|
||||||
<BoardLinkVue
|
</div>
|
||||||
:link="link"
|
|
||||||
:dragging="unref(draggingNode)"
|
|
||||||
:dragged="
|
|
||||||
link.startNode === unref(draggingNode) ||
|
|
||||||
link.endNode === unref(draggingNode)
|
|
||||||
? dragged
|
|
||||||
: undefined
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</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="unref(draggingNode)"
|
|
||||||
:dragged="unref(draggingNode) === node ? dragged : undefined"
|
|
||||||
:hasDragged="unref(draggingNode) == null ? false : hasDragged"
|
|
||||||
:receivingNode="unref(receivingNode) === node"
|
|
||||||
:isSelected="unref(selectedNode) === node"
|
|
||||||
:selectedAction="
|
|
||||||
unref(selectedNode) === node ? unref(selectedAction) : null
|
|
||||||
"
|
|
||||||
@mouseDown="mouseDown"
|
|
||||||
@endDragging="endDragging"
|
|
||||||
@clickAction="(actionId: string) => clickAction(node, actionId)"
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
</transition-group>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
</panZoom>
|
</panZoom>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type {
|
import type { PanZoom } from "panzoom";
|
||||||
BoardData,
|
import type { ComponentPublicInstance } from "vue";
|
||||||
BoardNode,
|
import { computed, ref } from "vue";
|
||||||
BoardNodeLink,
|
// Required to make sure panzoom component gets registered:
|
||||||
GenericBoardNodeAction,
|
import "features/boards/board";
|
||||||
GenericNodeType
|
|
||||||
} from "features/boards/board";
|
|
||||||
import { getNodeProperty } from "features/boards/board";
|
|
||||||
import type { StyleValue } from "features/feature";
|
|
||||||
import { Visibility, isVisible } from "features/feature";
|
|
||||||
import type { ProcessedComputable } from "util/computed";
|
|
||||||
import { Ref, computed, ref, toRefs, unref, watchEffect } from "vue";
|
|
||||||
import BoardLinkVue from "./BoardLink.vue";
|
|
||||||
import BoardNodeVue from "./BoardNode.vue";
|
|
||||||
|
|
||||||
const _props = defineProps<{
|
defineExpose({
|
||||||
nodes: Ref<BoardNode[]>;
|
panZoomInstance: computed(() => stage.value?.panZoomInstance)
|
||||||
types: Record<string, GenericNodeType>;
|
});
|
||||||
state: Ref<BoardData>;
|
const emit = defineEmits<{
|
||||||
visibility: ProcessedComputable<Visibility | boolean>;
|
(event: "drag", e: MouseEvent | TouchEvent): void;
|
||||||
width?: ProcessedComputable<string>;
|
(event: "mouseDown", e: MouseEvent | TouchEvent): void;
|
||||||
height?: ProcessedComputable<string>;
|
(event: "mouseUp", e: MouseEvent | TouchEvent): void;
|
||||||
style?: ProcessedComputable<StyleValue>;
|
(event: "mouseLeave", e: MouseEvent | TouchEvent): void;
|
||||||
classes?: ProcessedComputable<Record<string, boolean>>;
|
|
||||||
links: Ref<BoardNodeLink[] | null>;
|
|
||||||
selectedAction: Ref<GenericBoardNodeAction | null>;
|
|
||||||
selectedNode: Ref<BoardNode | null>;
|
|
||||||
draggingNode: Ref<BoardNode | null>;
|
|
||||||
receivingNode: Ref<BoardNode | null>;
|
|
||||||
mousePosition: Ref<{ x: number; y: number } | null>;
|
|
||||||
setReceivingNode: (node: BoardNode | null) => void;
|
|
||||||
setDraggingNode: (node: BoardNode | null) => void;
|
|
||||||
}>();
|
}>();
|
||||||
const props = toRefs(_props);
|
|
||||||
|
|
||||||
const lastMousePosition = ref({ x: 0, y: 0 });
|
const stage = ref<{ panZoomInstance: PanZoom } & ComponentPublicInstance<HTMLElement>>();
|
||||||
const dragged = ref({ x: 0, y: 0 });
|
|
||||||
const hasDragged = ref(false);
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const stage = ref<any>(null);
|
|
||||||
|
|
||||||
const sortedNodes = computed(() => {
|
function onInit(panzoomInstance: PanZoom) {
|
||||||
const nodes = props.nodes.value.slice();
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
if (props.selectedNode.value) {
|
// @ts-ignore
|
||||||
const node = nodes.splice(nodes.indexOf(props.selectedNode.value), 1)[0];
|
|
||||||
nodes.push(node);
|
|
||||||
}
|
|
||||||
if (props.draggingNode.value) {
|
|
||||||
const node = nodes.splice(nodes.indexOf(props.draggingNode.value), 1)[0];
|
|
||||||
nodes.push(node);
|
|
||||||
}
|
|
||||||
return nodes;
|
|
||||||
});
|
|
||||||
|
|
||||||
watchEffect(() => {
|
|
||||||
const node = props.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;
|
|
||||||
|
|
||||||
props.setReceivingNode.value(
|
|
||||||
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, node);
|
|
||||||
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);
|
panzoomInstance.setTransformOrigin(null);
|
||||||
panzoomInstance.moveTo(stage.value.$el.clientWidth / 2, stage.value.$el.clientHeight / 2);
|
panzoomInstance.moveTo(0, stage.value?.$el.clientHeight / 2);
|
||||||
}
|
|
||||||
|
|
||||||
function mouseDown(e: MouseEvent | TouchEvent, node: BoardNode | null = null, draggable = false) {
|
|
||||||
if (props.draggingNode.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) {
|
|
||||||
props.setDraggingNode.value(node);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (node != null) {
|
|
||||||
props.state.value.selectedNode = null;
|
|
||||||
props.state.value.selectedAction = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function drag(e: MouseEvent | TouchEvent) {
|
|
||||||
const { x, y, scale } = stage.value.panZoomInstance.getTransform();
|
|
||||||
|
|
||||||
let clientX, clientY;
|
|
||||||
if ("touches" in e) {
|
|
||||||
if (e.touches.length === 1) {
|
|
||||||
clientX = e.touches[0].clientX;
|
|
||||||
clientY = e.touches[0].clientY;
|
|
||||||
} else {
|
|
||||||
endDragging(props.draggingNode.value);
|
|
||||||
props.mousePosition.value = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
clientX = e.clientX;
|
|
||||||
clientY = e.clientY;
|
|
||||||
}
|
|
||||||
|
|
||||||
props.mousePosition.value = {
|
|
||||||
x: (clientX - x) / scale,
|
|
||||||
y: (clientY - y) / scale
|
|
||||||
};
|
|
||||||
|
|
||||||
dragged.value = {
|
|
||||||
x: dragged.value.x + (clientX - lastMousePosition.value.x) / scale,
|
|
||||||
y: dragged.value.y + (clientY - lastMousePosition.value.y) / scale
|
|
||||||
};
|
|
||||||
lastMousePosition.value = {
|
|
||||||
x: clientX,
|
|
||||||
y: clientY
|
|
||||||
};
|
|
||||||
|
|
||||||
if (Math.abs(dragged.value.x) > 10 || Math.abs(dragged.value.y) > 10) {
|
|
||||||
hasDragged.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.draggingNode.value != null) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function endDragging(node: BoardNode | null, mouseLeave = false) {
|
|
||||||
if (props.draggingNode.value != null && props.draggingNode.value === node) {
|
|
||||||
if (props.receivingNode.value == null) {
|
|
||||||
props.draggingNode.value.position.x += Math.round(dragged.value.x / 25) * 25;
|
|
||||||
props.draggingNode.value.position.y += Math.round(dragged.value.y / 25) * 25;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nodes = props.nodes.value;
|
|
||||||
nodes.push(nodes.splice(nodes.indexOf(props.draggingNode.value), 1)[0]);
|
|
||||||
|
|
||||||
if (props.receivingNode.value) {
|
|
||||||
props.types.value[props.receivingNode.value.type].onDrop?.(
|
|
||||||
props.receivingNode.value,
|
|
||||||
props.draggingNode.value
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
props.setDraggingNode.value(null);
|
|
||||||
} else if (!hasDragged.value && !mouseLeave) {
|
|
||||||
props.state.value.selectedNode = null;
|
|
||||||
props.state.value.selectedAction = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function clickAction(node: BoardNode, actionId: string) {
|
|
||||||
if (props.state.value.selectedAction === actionId) {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
unref(props.selectedAction)!.onClick(unref(props.selectedNode)!);
|
|
||||||
} else {
|
|
||||||
props.state.value = { ...props.state.value, selectedAction: actionId };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.event-listener {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage {
|
||||||
|
transition-duration: 0s;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.vue-pan-zoom-item {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.vue-pan-zoom-scene {
|
.vue-pan-zoom-scene {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
@ -283,12 +80,14 @@ function clickAction(node: BoardNode, actionId: string) {
|
||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
|
|
||||||
.g1 {
|
.stage > * {
|
||||||
transition-duration: 0s;
|
pointer-events: initial;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link-enter-from,
|
/* "Only" child (excluding resize listener) */
|
||||||
.link-leave-to {
|
.layer-tab > .vue-pan-zoom-item:first-child:nth-last-child(2) {
|
||||||
opacity: 0;
|
width: calc(100% + 20px);
|
||||||
|
height: calc(100% + 100px);
|
||||||
|
margin: -50px -10px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -1,80 +0,0 @@
|
||||||
<template>
|
|
||||||
<line
|
|
||||||
class="link"
|
|
||||||
v-bind="linkProps"
|
|
||||||
:class="{ pulsing: link.pulsing }"
|
|
||||||
:x1="startPosition.x"
|
|
||||||
:y1="startPosition.y"
|
|
||||||
:x2="endPosition.x"
|
|
||||||
:y2="endPosition.y"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { BoardNode, BoardNodeLink } from "features/boards/board";
|
|
||||||
import { kebabifyObject } from "util/vue";
|
|
||||||
import { computed, toRefs, unref } from "vue";
|
|
||||||
|
|
||||||
const _props = defineProps<{
|
|
||||||
link: BoardNodeLink;
|
|
||||||
dragging: BoardNode | null;
|
|
||||||
dragged?: {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
};
|
|
||||||
}>();
|
|
||||||
const props = toRefs(_props);
|
|
||||||
|
|
||||||
const startPosition = computed(() => {
|
|
||||||
const position = { ...props.link.value.startNode.position };
|
|
||||||
if (props.link.value.offsetStart) {
|
|
||||||
position.x += unref(props.link.value.offsetStart).x;
|
|
||||||
position.y += unref(props.link.value.offsetStart).y;
|
|
||||||
}
|
|
||||||
if (props.dragging?.value === props.link.value.startNode) {
|
|
||||||
position.x += props.dragged?.value?.x ?? 0;
|
|
||||||
position.y += props.dragged?.value?.y ?? 0;
|
|
||||||
}
|
|
||||||
return position;
|
|
||||||
});
|
|
||||||
|
|
||||||
const endPosition = computed(() => {
|
|
||||||
const position = { ...props.link.value.endNode.position };
|
|
||||||
if (props.link.value.offsetEnd) {
|
|
||||||
position.x += unref(props.link.value.offsetEnd).x;
|
|
||||||
position.y += unref(props.link.value.offsetEnd).y;
|
|
||||||
}
|
|
||||||
if (props.dragging?.value === props.link.value.endNode) {
|
|
||||||
position.x += props.dragged?.value?.x ?? 0;
|
|
||||||
position.y += props.dragged?.value?.y ?? 0;
|
|
||||||
}
|
|
||||||
return position;
|
|
||||||
});
|
|
||||||
|
|
||||||
const linkProps = computed(() => kebabifyObject(_props.link as unknown as Record<string, unknown>));
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.link {
|
|
||||||
transition-duration: 0s;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link.pulsing {
|
|
||||||
animation: pulsing 2s ease-in infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulsing {
|
|
||||||
0% {
|
|
||||||
opacity: 0.25;
|
|
||||||
}
|
|
||||||
|
|
||||||
50% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
opacity: 0.25;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,339 +0,0 @@
|
||||||
<template>
|
|
||||||
<!-- Ugly casting to prevent TS compiler error about style because vue doesn't think it supports arrays when it does -->
|
|
||||||
<g
|
|
||||||
class="boardnode"
|
|
||||||
:class="{ [node.type]: true, isSelected, isDraggable, ...classes }"
|
|
||||||
:style="[{ opacity: dragging?.id === node.id && hasDragged ? 0.5 : 1 }, style ?? []] as unknown as (string | CSSProperties)"
|
|
||||||
:transform="`translate(${position.x},${position.y})${isSelected ? ' scale(1.2)' : ''}`"
|
|
||||||
>
|
|
||||||
<BoardNodeAction
|
|
||||||
:actions="actions ?? []"
|
|
||||||
:is-selected="isSelected"
|
|
||||||
:node="node"
|
|
||||||
:node-type="nodeType"
|
|
||||||
:selected-action="selectedAction"
|
|
||||||
@click-action="(actionId: string) => emit('clickAction', actionId)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<g
|
|
||||||
class="node-container"
|
|
||||||
@mousedown="mouseDown"
|
|
||||||
@touchstart.passive="mouseDown"
|
|
||||||
@mouseup="mouseUp"
|
|
||||||
@touchend.passive="mouseUp"
|
|
||||||
>
|
|
||||||
<g v-if="shape === Shape.Circle">
|
|
||||||
<circle
|
|
||||||
v-if="canAccept"
|
|
||||||
class="receiver"
|
|
||||||
:r="size + 8"
|
|
||||||
:fill="backgroundColor"
|
|
||||||
:stroke="receivingNode ? '#0F0' : '#0F03'"
|
|
||||||
:stroke-width="2"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<circle
|
|
||||||
class="body"
|
|
||||||
:r="size"
|
|
||||||
:fill="fillColor"
|
|
||||||
:stroke="outlineColor"
|
|
||||||
:stroke-width="4"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<circle
|
|
||||||
class="progress progressFill"
|
|
||||||
v-if="progressDisplay === ProgressDisplay.Fill"
|
|
||||||
:r="Math.max(size * progress - 2, 0)"
|
|
||||||
:fill="progressColor"
|
|
||||||
/>
|
|
||||||
<circle
|
|
||||||
v-else
|
|
||||||
:r="size + 4.5"
|
|
||||||
class="progress progressRing"
|
|
||||||
fill="transparent"
|
|
||||||
:stroke-dasharray="(size + 4.5) * 2 * Math.PI"
|
|
||||||
:stroke-width="5"
|
|
||||||
:stroke-dashoffset="
|
|
||||||
(size + 4.5) * 2 * Math.PI - progress * (size + 4.5) * 2 * Math.PI
|
|
||||||
"
|
|
||||||
:stroke="progressColor"
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
<g v-else-if="shape === Shape.Diamond" transform="rotate(45, 0, 0)">
|
|
||||||
<rect
|
|
||||||
v-if="canAccept"
|
|
||||||
class="receiver"
|
|
||||||
:width="size * sqrtTwo + 16"
|
|
||||||
:height="size * sqrtTwo + 16"
|
|
||||||
:transform="`translate(${-(size * sqrtTwo + 16) / 2}, ${
|
|
||||||
-(size * sqrtTwo + 16) / 2
|
|
||||||
})`"
|
|
||||||
:fill="backgroundColor"
|
|
||||||
:stroke="receivingNode ? '#0F0' : '#0F03'"
|
|
||||||
:stroke-width="2"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<rect
|
|
||||||
class="body"
|
|
||||||
:width="size * sqrtTwo"
|
|
||||||
:height="size * sqrtTwo"
|
|
||||||
:transform="`translate(${(-size * sqrtTwo) / 2}, ${(-size * sqrtTwo) / 2})`"
|
|
||||||
:fill="fillColor"
|
|
||||||
:stroke="outlineColor"
|
|
||||||
:stroke-width="4"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<rect
|
|
||||||
v-if="progressDisplay === ProgressDisplay.Fill"
|
|
||||||
class="progress progressFill"
|
|
||||||
:width="Math.max(size * sqrtTwo * progress - 2, 0)"
|
|
||||||
:height="Math.max(size * sqrtTwo * progress - 2, 0)"
|
|
||||||
:transform="`translate(${-Math.max(size * sqrtTwo * progress - 2, 0) / 2}, ${
|
|
||||||
-Math.max(size * sqrtTwo * progress - 2, 0) / 2
|
|
||||||
})`"
|
|
||||||
:fill="progressColor"
|
|
||||||
/>
|
|
||||||
<rect
|
|
||||||
v-else
|
|
||||||
class="progress progressDiamond"
|
|
||||||
:width="size * sqrtTwo + 9"
|
|
||||||
:height="size * sqrtTwo + 9"
|
|
||||||
:transform="`translate(${-(size * sqrtTwo + 9) / 2}, ${
|
|
||||||
-(size * sqrtTwo + 9) / 2
|
|
||||||
})`"
|
|
||||||
fill="transparent"
|
|
||||||
:stroke-dasharray="(size * sqrtTwo + 9) * 4"
|
|
||||||
:stroke-width="5"
|
|
||||||
:stroke-dashoffset="
|
|
||||||
(size * sqrtTwo + 9) * 4 - progress * (size * sqrtTwo + 9) * 4
|
|
||||||
"
|
|
||||||
:stroke="progressColor"
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<text :fill="titleColor" class="node-title">{{ title }}</text>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<transition name="fade" appear>
|
|
||||||
<g v-if="label">
|
|
||||||
<text
|
|
||||||
:fill="label.color ?? titleColor"
|
|
||||||
class="node-title"
|
|
||||||
:class="{ pulsing: label.pulsing }"
|
|
||||||
:y="-size - 20"
|
|
||||||
>{{ label.text }}
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
</transition>
|
|
||||||
|
|
||||||
<transition name="fade" appear>
|
|
||||||
<text
|
|
||||||
v-if="isSelected && selectedAction"
|
|
||||||
:fill="confirmationLabel.color ?? titleColor"
|
|
||||||
class="node-title"
|
|
||||||
:class="{ pulsing: confirmationLabel.pulsing }"
|
|
||||||
:y="size + 75"
|
|
||||||
>{{ confirmationLabel.text }}</text
|
|
||||||
>
|
|
||||||
</transition>
|
|
||||||
</g>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import themes from "data/themes";
|
|
||||||
import type { BoardNode, GenericBoardNodeAction, GenericNodeType } from "features/boards/board";
|
|
||||||
import { ProgressDisplay, Shape, getNodeProperty } from "features/boards/board";
|
|
||||||
import { isVisible } from "features/feature";
|
|
||||||
import settings from "game/settings";
|
|
||||||
import { CSSProperties, computed, toRefs, unref, watch } from "vue";
|
|
||||||
import BoardNodeAction from "./BoardNodeAction.vue";
|
|
||||||
|
|
||||||
const sqrtTwo = Math.sqrt(2);
|
|
||||||
|
|
||||||
const _props = defineProps<{
|
|
||||||
node: BoardNode;
|
|
||||||
nodeType: GenericNodeType;
|
|
||||||
dragging: BoardNode | null;
|
|
||||||
dragged?: {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
};
|
|
||||||
hasDragged?: boolean;
|
|
||||||
receivingNode?: boolean;
|
|
||||||
isSelected: boolean;
|
|
||||||
selectedAction: GenericBoardNodeAction | null;
|
|
||||||
}>();
|
|
||||||
const props = toRefs(_props);
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: "mouseDown", event: MouseEvent | TouchEvent, node: BoardNode, isDraggable: boolean): void;
|
|
||||||
(e: "endDragging", node: BoardNode): void;
|
|
||||||
(e: "clickAction", actionId: string): void;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const isDraggable = computed(() =>
|
|
||||||
getNodeProperty(props.nodeType.value.draggable, unref(props.node))
|
|
||||||
);
|
|
||||||
|
|
||||||
watch(isDraggable, value => {
|
|
||||||
const node = unref(props.node);
|
|
||||||
if (unref(props.dragging) === node && !value) {
|
|
||||||
emit("endDragging", node);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const actions = computed(() => {
|
|
||||||
const node = unref(props.node);
|
|
||||||
return getNodeProperty(props.nodeType.value.actions, node)?.filter(action =>
|
|
||||||
isVisible(getNodeProperty(action.visibility, node))
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const position = computed(() => {
|
|
||||||
const node = unref(props.node);
|
|
||||||
|
|
||||||
if (
|
|
||||||
getNodeProperty(props.nodeType.value.draggable, node) &&
|
|
||||||
unref(props.dragging)?.id === node.id &&
|
|
||||||
unref(props.dragged) != null
|
|
||||||
) {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
const { x, y } = unref(props.dragged)!;
|
|
||||||
return {
|
|
||||||
x: node.position.x + Math.round(x / 25) * 25,
|
|
||||||
y: node.position.y + Math.round(y / 25) * 25
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return node.position;
|
|
||||||
});
|
|
||||||
|
|
||||||
const shape = computed(() => getNodeProperty(props.nodeType.value.shape, unref(props.node)));
|
|
||||||
const title = computed(() => getNodeProperty(props.nodeType.value.title, unref(props.node)));
|
|
||||||
const label = computed(
|
|
||||||
() =>
|
|
||||||
(props.isSelected.value
|
|
||||||
? unref(props.selectedAction) &&
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
getNodeProperty(unref(props.selectedAction)!.tooltip, unref(props.node))
|
|
||||||
: null) ?? getNodeProperty(props.nodeType.value.label, unref(props.node))
|
|
||||||
);
|
|
||||||
const confirmationLabel = computed(() =>
|
|
||||||
getNodeProperty(
|
|
||||||
unref(props.selectedAction)?.confirmationLabel ?? {
|
|
||||||
text: "Tap again to confirm"
|
|
||||||
},
|
|
||||||
unref(props.node)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const size = computed(() => getNodeProperty(props.nodeType.value.size, unref(props.node)));
|
|
||||||
const progress = computed(
|
|
||||||
() => getNodeProperty(props.nodeType.value.progress, unref(props.node)) ?? 0
|
|
||||||
);
|
|
||||||
const backgroundColor = computed(() => themes[settings.theme].variables["--background"]);
|
|
||||||
const outlineColor = computed(
|
|
||||||
() =>
|
|
||||||
getNodeProperty(props.nodeType.value.outlineColor, unref(props.node)) ??
|
|
||||||
themes[settings.theme].variables["--outline"]
|
|
||||||
);
|
|
||||||
const fillColor = computed(
|
|
||||||
() =>
|
|
||||||
getNodeProperty(props.nodeType.value.fillColor, unref(props.node)) ??
|
|
||||||
themes[settings.theme].variables["--raised-background"]
|
|
||||||
);
|
|
||||||
const progressColor = computed(() =>
|
|
||||||
getNodeProperty(props.nodeType.value.progressColor, unref(props.node))
|
|
||||||
);
|
|
||||||
const titleColor = computed(
|
|
||||||
() =>
|
|
||||||
getNodeProperty(props.nodeType.value.titleColor, unref(props.node)) ??
|
|
||||||
themes[settings.theme].variables["--foreground"]
|
|
||||||
);
|
|
||||||
const progressDisplay = computed(() =>
|
|
||||||
getNodeProperty(props.nodeType.value.progressDisplay, unref(props.node))
|
|
||||||
);
|
|
||||||
const canAccept = computed(
|
|
||||||
() =>
|
|
||||||
props.dragging.value != null &&
|
|
||||||
unref(props.hasDragged) &&
|
|
||||||
getNodeProperty(props.nodeType.value.canAccept, unref(props.node), props.dragging.value)
|
|
||||||
);
|
|
||||||
const style = computed(() => getNodeProperty(props.nodeType.value.style, unref(props.node)));
|
|
||||||
const classes = computed(() => getNodeProperty(props.nodeType.value.classes, unref(props.node)));
|
|
||||||
|
|
||||||
function mouseDown(e: MouseEvent | TouchEvent) {
|
|
||||||
emit("mouseDown", e, props.node.value, isDraggable.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function mouseUp(e: MouseEvent | TouchEvent) {
|
|
||||||
if (!props.hasDragged?.value) {
|
|
||||||
emit("endDragging", props.node.value);
|
|
||||||
props.nodeType.value.onClick?.(props.node.value);
|
|
||||||
e.stopPropagation();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.boardnode {
|
|
||||||
cursor: pointer;
|
|
||||||
transition-duration: 0s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.boardnode:hover .body {
|
|
||||||
fill: var(--highlighted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.boardnode.isSelected .body {
|
|
||||||
fill: var(--accent1) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.boardnode:not(.isDraggable) .body {
|
|
||||||
fill: var(--locked);
|
|
||||||
}
|
|
||||||
|
|
||||||
.node-title {
|
|
||||||
text-anchor: middle;
|
|
||||||
dominant-baseline: middle;
|
|
||||||
font-family: monospace;
|
|
||||||
font-size: 200%;
|
|
||||||
pointer-events: none;
|
|
||||||
filter: drop-shadow(3px 3px 2px var(--tooltip-background));
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress {
|
|
||||||
transition-duration: 0.05s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progressRing {
|
|
||||||
transform: rotate(-90deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fade-enter-from,
|
|
||||||
.fade-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pulsing {
|
|
||||||
animation: pulsing 2s ease-in infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulsing {
|
|
||||||
0% {
|
|
||||||
opacity: 0.25;
|
|
||||||
}
|
|
||||||
|
|
||||||
50% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
opacity: 0.25;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.grow-enter-from .node-container,
|
|
||||||
.grow-leave-to .node-container {
|
|
||||||
transform: scale(0);
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,109 +0,0 @@
|
||||||
<template>
|
|
||||||
<transition name="actions" appear>
|
|
||||||
<g v-if="isSelected && actions">
|
|
||||||
<g
|
|
||||||
v-for="(action, index) in actions"
|
|
||||||
:key="action.id"
|
|
||||||
class="action"
|
|
||||||
:class="{ selected: selectedAction?.id === action.id }"
|
|
||||||
:transform="`translate(
|
|
||||||
${
|
|
||||||
(-size - 30) *
|
|
||||||
Math.sin(((actions.length - 1) / 2 - index) * actionDistance)
|
|
||||||
},
|
|
||||||
${
|
|
||||||
(size + 30) *
|
|
||||||
Math.cos(((actions.length - 1) / 2 - index) * actionDistance)
|
|
||||||
}
|
|
||||||
)`"
|
|
||||||
@mousedown="e => performAction(e, action)"
|
|
||||||
@touchstart="e => performAction(e, action)"
|
|
||||||
@mouseup="e => actionMouseUp(e, action)"
|
|
||||||
@touchend.stop="e => actionMouseUp(e, action)"
|
|
||||||
>
|
|
||||||
<circle
|
|
||||||
:fill="getNodeProperty(action.fillColor, node)"
|
|
||||||
r="20"
|
|
||||||
:stroke-width="selectedAction?.id === action.id ? 4 : 0"
|
|
||||||
:stroke="outlineColor"
|
|
||||||
/>
|
|
||||||
<text :fill="titleColor" class="material-icons">{{
|
|
||||||
getNodeProperty(action.icon, node)
|
|
||||||
}}</text>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</transition>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import themes from "data/themes";
|
|
||||||
import type { BoardNode, GenericBoardNodeAction, GenericNodeType } from "features/boards/board";
|
|
||||||
import { getNodeProperty } from "features/boards/board";
|
|
||||||
import settings from "game/settings";
|
|
||||||
import { computed, toRefs, unref } from "vue";
|
|
||||||
|
|
||||||
const _props = defineProps<{
|
|
||||||
node: BoardNode;
|
|
||||||
nodeType: GenericNodeType;
|
|
||||||
actions?: GenericBoardNodeAction[];
|
|
||||||
isSelected: boolean;
|
|
||||||
selectedAction: GenericBoardNodeAction | null;
|
|
||||||
}>();
|
|
||||||
const props = toRefs(_props);
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: "clickAction", actionId: string): void;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const size = computed(() => getNodeProperty(props.nodeType.value.size, unref(props.node)));
|
|
||||||
const outlineColor = computed(
|
|
||||||
() =>
|
|
||||||
getNodeProperty(props.nodeType.value.outlineColor, unref(props.node)) ??
|
|
||||||
themes[settings.theme].variables["--outline"]
|
|
||||||
);
|
|
||||||
const titleColor = computed(
|
|
||||||
() =>
|
|
||||||
getNodeProperty(props.nodeType.value.titleColor, unref(props.node)) ??
|
|
||||||
themes[settings.theme].variables["--foreground"]
|
|
||||||
);
|
|
||||||
const actionDistance = computed(() =>
|
|
||||||
getNodeProperty(props.nodeType.value.actionDistance, unref(props.node))
|
|
||||||
);
|
|
||||||
|
|
||||||
function performAction(e: MouseEvent | TouchEvent, action: GenericBoardNodeAction) {
|
|
||||||
emit("clickAction", action.id);
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
}
|
|
||||||
|
|
||||||
function actionMouseUp(e: MouseEvent | TouchEvent, action: GenericBoardNodeAction) {
|
|
||||||
if (unref(props.selectedAction)?.id === action.id) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.action:not(.boardnode):hover circle,
|
|
||||||
.action:not(.boardnode).selected circle {
|
|
||||||
r: 25;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action:not(.boardnode):hover text,
|
|
||||||
.action:not(.boardnode).selected text {
|
|
||||||
font-size: 187.5%; /* 150% * 1.25 */
|
|
||||||
}
|
|
||||||
|
|
||||||
.action:not(.boardnode) text {
|
|
||||||
text-anchor: middle;
|
|
||||||
dominant-baseline: central;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.actions-enter-from .action,
|
|
||||||
.actions-leave-to .action {
|
|
||||||
transform: translate(0, 0);
|
|
||||||
}
|
|
||||||
</style>
|
|
29
src/features/boards/CircleProgress.vue
Normal file
29
src/features/boards/CircleProgress.vue
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
<template>
|
||||||
|
<circle
|
||||||
|
:r="r"
|
||||||
|
fill="transparent"
|
||||||
|
:stroke-dasharray="r * 2 * Math.PI"
|
||||||
|
:stroke-width="5"
|
||||||
|
:stroke-dashoffset="r * 2 * Math.PI - progress * r * 2 * Math.PI"
|
||||||
|
:stroke="stroke"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { SVGAttributes } from "vue";
|
||||||
|
|
||||||
|
interface CircleProgressProps extends SVGAttributes {
|
||||||
|
r: number;
|
||||||
|
progress: number;
|
||||||
|
stroke: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<CircleProgressProps>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
circle {
|
||||||
|
transition-duration: 0.05s;
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
</style>
|
27
src/features/boards/SVGNode.vue
Normal file
27
src/features/boards/SVGNode.vue
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
@mousedown="e => emit('mouseDown', e)"
|
||||||
|
@touchstart.passive="e => emit('mouseDown', e)"
|
||||||
|
@mouseup="e => emit('mouseUp', e)"
|
||||||
|
@touchend.passive="e => emit('mouseUp', e)"
|
||||||
|
width="1"
|
||||||
|
height="1"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "mouseDown", event: MouseEvent | TouchEvent): void;
|
||||||
|
(e: "mouseUp", event: MouseEvent | TouchEvent): void;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
svg {
|
||||||
|
cursor: pointer;
|
||||||
|
transition-duration: 0s;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
</style>
|
30
src/features/boards/SquareProgress.vue
Normal file
30
src/features/boards/SquareProgress.vue
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
<template>
|
||||||
|
<rect
|
||||||
|
:width="size"
|
||||||
|
:height="size"
|
||||||
|
:transform="`translate(${-size / 2}, ${-size / 2})`"
|
||||||
|
fill="transparent"
|
||||||
|
:stroke-dasharray="size * 4"
|
||||||
|
:stroke-width="5"
|
||||||
|
:stroke-dashoffset="size * 4 - progress * size * 4"
|
||||||
|
:stroke="stroke"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { SVGAttributes } from "vue";
|
||||||
|
|
||||||
|
interface SquareProgressProps extends SVGAttributes {
|
||||||
|
size: number;
|
||||||
|
progress: number;
|
||||||
|
stroke: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<SquareProgressProps>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
rect {
|
||||||
|
transition-duration: 0.05s;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -1,631 +0,0 @@
|
||||||
import BoardComponent from "features/boards/Board.vue";
|
|
||||||
import type { GenericComponent, OptionsFunc, Replace, StyleValue } from "features/feature";
|
|
||||||
import {
|
|
||||||
Component,
|
|
||||||
findFeatures,
|
|
||||||
GatherProps,
|
|
||||||
getUniqueID,
|
|
||||||
setDefault,
|
|
||||||
Visibility
|
|
||||||
} from "features/feature";
|
|
||||||
import { globalBus } from "game/events";
|
|
||||||
import { DefaultValue, deletePersistent, Persistent, State } from "game/persistence";
|
|
||||||
import { persistent } from "game/persistence";
|
|
||||||
import type { Unsubscribe } from "nanoevents";
|
|
||||||
import { Direction, isFunction } from "util/common";
|
|
||||||
import type {
|
|
||||||
Computable,
|
|
||||||
GetComputableType,
|
|
||||||
GetComputableTypeWithDefault,
|
|
||||||
ProcessedComputable
|
|
||||||
} from "util/computed";
|
|
||||||
import { processComputable } from "util/computed";
|
|
||||||
import { createLazyProxy } from "util/proxies";
|
|
||||||
import { computed, isRef, ref, Ref, unref } from "vue";
|
|
||||||
import panZoom from "vue-panzoom";
|
|
||||||
import type { Link } from "../links/links";
|
|
||||||
|
|
||||||
globalBus.on("setupVue", app => panZoom.install(app));
|
|
||||||
|
|
||||||
/** A symbol used to identify {@link Board} features. */
|
|
||||||
export const BoardType = Symbol("Board");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A type representing a computable value for a node on the board. Used for node types to return different values based on the given node and the state of the board.
|
|
||||||
*/
|
|
||||||
export type NodeComputable<T, S extends unknown[] = []> =
|
|
||||||
| Computable<T>
|
|
||||||
| ((node: BoardNode, ...args: S) => T);
|
|
||||||
|
|
||||||
/** Ways to display progress of an action with a duration. */
|
|
||||||
export enum ProgressDisplay {
|
|
||||||
Outline = "Outline",
|
|
||||||
Fill = "Fill"
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Node shapes. */
|
|
||||||
export enum Shape {
|
|
||||||
Circle = "Circle",
|
|
||||||
Diamond = "Triangle"
|
|
||||||
}
|
|
||||||
|
|
||||||
/** An object representing a node on the board. */
|
|
||||||
export interface BoardNode {
|
|
||||||
id: number;
|
|
||||||
position: {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
};
|
|
||||||
type: string;
|
|
||||||
state?: State;
|
|
||||||
pinned?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** An object representing a link between two nodes on the board. */
|
|
||||||
export interface BoardNodeLink extends Omit<Link, "startNode" | "endNode"> {
|
|
||||||
startNode: BoardNode;
|
|
||||||
endNode: BoardNode;
|
|
||||||
stroke: string;
|
|
||||||
strokeWidth: number;
|
|
||||||
pulsing?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** An object representing a label for a node. */
|
|
||||||
export interface NodeLabel {
|
|
||||||
text: string;
|
|
||||||
color?: string;
|
|
||||||
pulsing?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The persistent data for a board. */
|
|
||||||
export type BoardData = {
|
|
||||||
nodes: BoardNode[];
|
|
||||||
selectedNode: number | null;
|
|
||||||
selectedAction: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An object that configures a {@link NodeType}.
|
|
||||||
*/
|
|
||||||
export interface NodeTypeOptions {
|
|
||||||
/** The title to display for the node. */
|
|
||||||
title: NodeComputable<string>;
|
|
||||||
/** An optional label for the node. */
|
|
||||||
label?: NodeComputable<NodeLabel | null>;
|
|
||||||
/** The size of the node - diameter for circles, width and height for squares. */
|
|
||||||
size: NodeComputable<number>;
|
|
||||||
/** CSS to apply to this node. */
|
|
||||||
style?: NodeComputable<StyleValue>;
|
|
||||||
/** Dictionary of CSS classes to apply to this node. */
|
|
||||||
classes?: NodeComputable<Record<string, boolean>>;
|
|
||||||
/** Whether the node is draggable or not. */
|
|
||||||
draggable?: NodeComputable<boolean>;
|
|
||||||
/** The shape of the node. */
|
|
||||||
shape: NodeComputable<Shape>;
|
|
||||||
/** Whether the node can accept another node being dropped upon it. */
|
|
||||||
canAccept?: NodeComputable<boolean, [BoardNode]>;
|
|
||||||
/** The progress value of the node, from 0 to 1. */
|
|
||||||
progress?: NodeComputable<number>;
|
|
||||||
/** How the progress should be displayed on the node. */
|
|
||||||
progressDisplay?: NodeComputable<ProgressDisplay>;
|
|
||||||
/** The color of the progress indicator. */
|
|
||||||
progressColor?: NodeComputable<string>;
|
|
||||||
/** The fill color of the node. */
|
|
||||||
fillColor?: NodeComputable<string>;
|
|
||||||
/** The outline color of the node. */
|
|
||||||
outlineColor?: NodeComputable<string>;
|
|
||||||
/** The color of the title text. */
|
|
||||||
titleColor?: NodeComputable<string>;
|
|
||||||
/** The list of action options for the node. */
|
|
||||||
actions?: BoardNodeActionOptions[];
|
|
||||||
/** The arc between each action, in radians. */
|
|
||||||
actionDistance?: NodeComputable<number>;
|
|
||||||
/** A function that is called when the node is clicked. */
|
|
||||||
onClick?: (node: BoardNode) => void;
|
|
||||||
/** A function that is called when a node is dropped onto this node. */
|
|
||||||
onDrop?: (node: BoardNode, otherNode: BoardNode) => void;
|
|
||||||
/** A function that is called for each node of this type every tick. */
|
|
||||||
update?: (node: BoardNode, diff: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The properties that are added onto a processed {@link NodeTypeOptions} to create a {@link NodeType}.
|
|
||||||
*/
|
|
||||||
export interface BaseNodeType {
|
|
||||||
/** The nodes currently on the board of this type. */
|
|
||||||
nodes: Ref<BoardNode[]>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** An object that represents a type of node that can appear on a board. It will handle getting properties and callbacks for every node of that type. */
|
|
||||||
export type NodeType<T extends NodeTypeOptions> = Replace<
|
|
||||||
T & BaseNodeType,
|
|
||||||
{
|
|
||||||
title: GetComputableType<T["title"]>;
|
|
||||||
label: GetComputableType<T["label"]>;
|
|
||||||
size: GetComputableTypeWithDefault<T["size"], 50>;
|
|
||||||
style: GetComputableType<T["style"]>;
|
|
||||||
classes: GetComputableType<T["classes"]>;
|
|
||||||
draggable: GetComputableTypeWithDefault<T["draggable"], false>;
|
|
||||||
shape: GetComputableTypeWithDefault<T["shape"], Shape.Circle>;
|
|
||||||
canAccept: GetComputableTypeWithDefault<T["canAccept"], false>;
|
|
||||||
progress: GetComputableType<T["progress"]>;
|
|
||||||
progressDisplay: GetComputableTypeWithDefault<T["progressDisplay"], ProgressDisplay.Fill>;
|
|
||||||
progressColor: GetComputableTypeWithDefault<T["progressColor"], "none">;
|
|
||||||
fillColor: GetComputableType<T["fillColor"]>;
|
|
||||||
outlineColor: GetComputableType<T["outlineColor"]>;
|
|
||||||
titleColor: GetComputableType<T["titleColor"]>;
|
|
||||||
actions?: GenericBoardNodeAction[];
|
|
||||||
actionDistance: GetComputableTypeWithDefault<T["actionDistance"], number>;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
|
|
||||||
/** A type that matches any valid {@link NodeType} object. */
|
|
||||||
export type GenericNodeType = Replace<
|
|
||||||
NodeType<NodeTypeOptions>,
|
|
||||||
{
|
|
||||||
size: NodeComputable<number>;
|
|
||||||
draggable: NodeComputable<boolean>;
|
|
||||||
shape: NodeComputable<Shape>;
|
|
||||||
canAccept: NodeComputable<boolean, [BoardNode]>;
|
|
||||||
progressDisplay: NodeComputable<ProgressDisplay>;
|
|
||||||
progressColor: NodeComputable<string>;
|
|
||||||
actionDistance: NodeComputable<number>;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An object that configures a {@link BoardNodeAction}.
|
|
||||||
*/
|
|
||||||
export interface BoardNodeActionOptions {
|
|
||||||
/** A unique identifier for the action. */
|
|
||||||
id: string;
|
|
||||||
/** Whether this action should be visible. */
|
|
||||||
visibility?: NodeComputable<Visibility | boolean>;
|
|
||||||
/** The icon to display for the action. */
|
|
||||||
icon: NodeComputable<string>;
|
|
||||||
/** The fill color of the action. */
|
|
||||||
fillColor?: NodeComputable<string>;
|
|
||||||
/** The tooltip text to display for the action. */
|
|
||||||
tooltip: NodeComputable<NodeLabel>;
|
|
||||||
/** The confirmation label that appears under the action. */
|
|
||||||
confirmationLabel?: NodeComputable<NodeLabel>;
|
|
||||||
/** An array of board node links associated with the action. They appear when the action is focused. */
|
|
||||||
links?: NodeComputable<BoardNodeLink[]>;
|
|
||||||
/** A function that is called when the action is clicked. */
|
|
||||||
onClick: (node: BoardNode) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The properties that are added onto a processed {@link BoardNodeActionOptions} to create an {@link BoardNodeAction}.
|
|
||||||
*/
|
|
||||||
export interface BaseBoardNodeAction {
|
|
||||||
links?: Ref<BoardNodeLink[]>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** An object that represents an action that can be taken upon a node. */
|
|
||||||
export type BoardNodeAction<T extends BoardNodeActionOptions> = Replace<
|
|
||||||
T & BaseBoardNodeAction,
|
|
||||||
{
|
|
||||||
visibility: GetComputableTypeWithDefault<T["visibility"], Visibility.Visible>;
|
|
||||||
icon: GetComputableType<T["icon"]>;
|
|
||||||
fillColor: GetComputableType<T["fillColor"]>;
|
|
||||||
tooltip: GetComputableType<T["tooltip"]>;
|
|
||||||
confirmationLabel: GetComputableTypeWithDefault<T["confirmationLabel"], NodeLabel>;
|
|
||||||
links: GetComputableType<T["links"]>;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
|
|
||||||
/** A type that matches any valid {@link BoardNodeAction} object. */
|
|
||||||
export type GenericBoardNodeAction = Replace<
|
|
||||||
BoardNodeAction<BoardNodeActionOptions>,
|
|
||||||
{
|
|
||||||
visibility: NodeComputable<Visibility | boolean>;
|
|
||||||
confirmationLabel: NodeComputable<NodeLabel>;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An object that configures a {@link Board}.
|
|
||||||
*/
|
|
||||||
export interface BoardOptions {
|
|
||||||
/** Whether this board should be visible. */
|
|
||||||
visibility?: Computable<Visibility | boolean>;
|
|
||||||
/** The height of the board. Defaults to 100% */
|
|
||||||
height?: Computable<string>;
|
|
||||||
/** The width of the board. Defaults to 100% */
|
|
||||||
width?: Computable<string>;
|
|
||||||
/** Dictionary of CSS classes to apply to this feature. */
|
|
||||||
classes?: Computable<Record<string, boolean>>;
|
|
||||||
/** CSS to apply to this feature. */
|
|
||||||
style?: Computable<StyleValue>;
|
|
||||||
/** A function that returns an array of initial board nodes, without IDs. */
|
|
||||||
startNodes: () => Omit<BoardNode, "id">[];
|
|
||||||
/** A dictionary of node types that can appear on the board. */
|
|
||||||
types: Record<string, NodeTypeOptions>;
|
|
||||||
/** The persistent state of the board. */
|
|
||||||
state?: Computable<BoardData>;
|
|
||||||
/** An array of board node links to display. */
|
|
||||||
links?: Computable<BoardNodeLink[] | null>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The properties that are added onto a processed {@link BoardOptions} to create a {@link Board}.
|
|
||||||
*/
|
|
||||||
export interface BaseBoard {
|
|
||||||
/** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
|
|
||||||
id: string;
|
|
||||||
/** All the nodes currently on the board. */
|
|
||||||
nodes: Ref<BoardNode[]>;
|
|
||||||
/** The currently selected node, if any. */
|
|
||||||
selectedNode: Ref<BoardNode | null>;
|
|
||||||
/** The currently selected action, if any. */
|
|
||||||
selectedAction: Ref<GenericBoardNodeAction | null>;
|
|
||||||
/** The currently being dragged node, if any. */
|
|
||||||
draggingNode: Ref<BoardNode | null>;
|
|
||||||
/** If dragging a node, the node it's currently being hovered over, if any. */
|
|
||||||
receivingNode: Ref<BoardNode | null>;
|
|
||||||
/** The current mouse position, if over the board. */
|
|
||||||
mousePosition: Ref<{ x: number; y: number } | null>;
|
|
||||||
/** Places a node in the nearest empty space in the given direction with the specified space around it. */
|
|
||||||
placeInAvailableSpace: (node: BoardNode, radius?: number, direction?: Direction) => void;
|
|
||||||
/** A symbol that helps identify features of the same type. */
|
|
||||||
type: typeof BoardType;
|
|
||||||
/** The Vue component used to render this feature. */
|
|
||||||
[Component]: GenericComponent;
|
|
||||||
/** A function to gather the props the vue component requires for this feature. */
|
|
||||||
[GatherProps]: () => Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** An object that represents a feature that is a zoomable, pannable board with various nodes upon it. */
|
|
||||||
export type Board<T extends BoardOptions> = Replace<
|
|
||||||
T & BaseBoard,
|
|
||||||
{
|
|
||||||
visibility: GetComputableTypeWithDefault<T["visibility"], Visibility.Visible>;
|
|
||||||
types: Record<string, GenericNodeType>;
|
|
||||||
height: GetComputableType<T["height"]>;
|
|
||||||
width: GetComputableType<T["width"]>;
|
|
||||||
classes: GetComputableType<T["classes"]>;
|
|
||||||
style: GetComputableType<T["style"]>;
|
|
||||||
state: GetComputableTypeWithDefault<T["state"], Persistent<BoardData>>;
|
|
||||||
links: GetComputableTypeWithDefault<T["links"], Ref<BoardNodeLink[] | null>>;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
|
|
||||||
/** A type that matches any valid {@link Board} object. */
|
|
||||||
export type GenericBoard = Replace<
|
|
||||||
Board<BoardOptions>,
|
|
||||||
{
|
|
||||||
visibility: ProcessedComputable<Visibility | boolean>;
|
|
||||||
state: ProcessedComputable<BoardData>;
|
|
||||||
links: ProcessedComputable<BoardNodeLink[] | null>;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lazily creates a board with the given options.
|
|
||||||
* @param optionsFunc Board options.
|
|
||||||
*/
|
|
||||||
export function createBoard<T extends BoardOptions>(
|
|
||||||
optionsFunc: OptionsFunc<T, BaseBoard, GenericBoard>
|
|
||||||
): Board<T> {
|
|
||||||
const state = persistent<BoardData>(
|
|
||||||
{
|
|
||||||
nodes: [],
|
|
||||||
selectedNode: null,
|
|
||||||
selectedAction: null
|
|
||||||
},
|
|
||||||
false
|
|
||||||
);
|
|
||||||
|
|
||||||
return createLazyProxy(feature => {
|
|
||||||
const board = optionsFunc.call(feature, feature);
|
|
||||||
board.id = getUniqueID("board-");
|
|
||||||
board.type = BoardType;
|
|
||||||
board[Component] = BoardComponent as GenericComponent;
|
|
||||||
|
|
||||||
if (board.state) {
|
|
||||||
deletePersistent(state);
|
|
||||||
processComputable(board as T, "state");
|
|
||||||
} else {
|
|
||||||
state[DefaultValue] = {
|
|
||||||
nodes: board.startNodes().map((n, i) => {
|
|
||||||
(n as BoardNode).id = i;
|
|
||||||
return n as BoardNode;
|
|
||||||
}),
|
|
||||||
selectedNode: null,
|
|
||||||
selectedAction: null
|
|
||||||
};
|
|
||||||
board.state = state;
|
|
||||||
}
|
|
||||||
|
|
||||||
board.nodes = computed(() => unref(processedBoard.state).nodes);
|
|
||||||
board.selectedNode = computed({
|
|
||||||
get() {
|
|
||||||
return (
|
|
||||||
processedBoard.nodes.value.find(
|
|
||||||
node => node.id === unref(processedBoard.state).selectedNode
|
|
||||||
) || null
|
|
||||||
);
|
|
||||||
},
|
|
||||||
set(node) {
|
|
||||||
if (isRef(processedBoard.state)) {
|
|
||||||
processedBoard.state.value = {
|
|
||||||
...processedBoard.state.value,
|
|
||||||
selectedNode: node?.id ?? null
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
processedBoard.state.selectedNode = node?.id ?? null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
board.selectedAction = computed({
|
|
||||||
get() {
|
|
||||||
const selectedNode = processedBoard.selectedNode.value;
|
|
||||||
if (selectedNode == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const type = processedBoard.types[selectedNode.type];
|
|
||||||
if (type.actions == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
type.actions.find(
|
|
||||||
action => action.id === unref(processedBoard.state).selectedAction
|
|
||||||
) || null
|
|
||||||
);
|
|
||||||
},
|
|
||||||
set(action) {
|
|
||||||
if (isRef(processedBoard.state)) {
|
|
||||||
processedBoard.state.value = {
|
|
||||||
...processedBoard.state.value,
|
|
||||||
selectedAction: action?.id ?? null
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
processedBoard.state.selectedAction = action?.id ?? null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
board.mousePosition = ref(null);
|
|
||||||
if (board.links) {
|
|
||||||
processComputable(board as T, "links");
|
|
||||||
} else {
|
|
||||||
board.links = computed(() => {
|
|
||||||
if (processedBoard.selectedAction.value == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
processedBoard.selectedAction.value.links &&
|
|
||||||
processedBoard.selectedNode.value
|
|
||||||
) {
|
|
||||||
return getNodeProperty(
|
|
||||||
processedBoard.selectedAction.value.links,
|
|
||||||
processedBoard.selectedNode.value
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
board.draggingNode = ref(null);
|
|
||||||
board.receivingNode = ref(null);
|
|
||||||
processComputable(board as T, "visibility");
|
|
||||||
setDefault(board, "visibility", Visibility.Visible);
|
|
||||||
processComputable(board as T, "width");
|
|
||||||
setDefault(board, "width", "100%");
|
|
||||||
processComputable(board as T, "height");
|
|
||||||
setDefault(board, "height", "100%");
|
|
||||||
processComputable(board as T, "classes");
|
|
||||||
processComputable(board as T, "style");
|
|
||||||
|
|
||||||
for (const type in board.types) {
|
|
||||||
const nodeType: NodeTypeOptions & Partial<BaseNodeType> = board.types[type];
|
|
||||||
|
|
||||||
processComputable(nodeType as NodeTypeOptions, "title");
|
|
||||||
processComputable(nodeType as NodeTypeOptions, "label");
|
|
||||||
processComputable(nodeType as NodeTypeOptions, "size");
|
|
||||||
setDefault(nodeType, "size", 50);
|
|
||||||
processComputable(nodeType as NodeTypeOptions, "style");
|
|
||||||
processComputable(nodeType as NodeTypeOptions, "classes");
|
|
||||||
processComputable(nodeType as NodeTypeOptions, "draggable");
|
|
||||||
setDefault(nodeType, "draggable", false);
|
|
||||||
processComputable(nodeType as NodeTypeOptions, "shape");
|
|
||||||
setDefault(nodeType, "shape", Shape.Circle);
|
|
||||||
processComputable(nodeType as NodeTypeOptions, "canAccept");
|
|
||||||
setDefault(nodeType, "canAccept", false);
|
|
||||||
processComputable(nodeType as NodeTypeOptions, "progress");
|
|
||||||
processComputable(nodeType as NodeTypeOptions, "progressDisplay");
|
|
||||||
setDefault(nodeType, "progressDisplay", ProgressDisplay.Fill);
|
|
||||||
processComputable(nodeType as NodeTypeOptions, "progressColor");
|
|
||||||
setDefault(nodeType, "progressColor", "none");
|
|
||||||
processComputable(nodeType as NodeTypeOptions, "fillColor");
|
|
||||||
processComputable(nodeType as NodeTypeOptions, "outlineColor");
|
|
||||||
processComputable(nodeType as NodeTypeOptions, "titleColor");
|
|
||||||
processComputable(nodeType as NodeTypeOptions, "actionDistance");
|
|
||||||
setDefault(nodeType, "actionDistance", Math.PI / 6);
|
|
||||||
nodeType.nodes = computed(() =>
|
|
||||||
unref(processedBoard.state).nodes.filter(node => node.type === type)
|
|
||||||
);
|
|
||||||
setDefault(nodeType, "onClick", function (node: BoardNode) {
|
|
||||||
unref(processedBoard.state).selectedNode = node.id;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (nodeType.actions) {
|
|
||||||
for (const action of nodeType.actions) {
|
|
||||||
processComputable(action, "visibility");
|
|
||||||
setDefault(action, "visibility", Visibility.Visible);
|
|
||||||
processComputable(action, "icon");
|
|
||||||
processComputable(action, "fillColor");
|
|
||||||
processComputable(action, "tooltip");
|
|
||||||
processComputable(action, "confirmationLabel");
|
|
||||||
setDefault(action, "confirmationLabel", { text: "Tap again to confirm" });
|
|
||||||
processComputable(action, "links");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setDraggingNode(node: BoardNode | null) {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
board.draggingNode!.value = node;
|
|
||||||
}
|
|
||||||
function setReceivingNode(node: BoardNode | null) {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
board.receivingNode!.value = node;
|
|
||||||
}
|
|
||||||
|
|
||||||
board.placeInAvailableSpace = function (
|
|
||||||
node: BoardNode,
|
|
||||||
radius = 100,
|
|
||||||
direction = Direction.Right
|
|
||||||
) {
|
|
||||||
const nodes = processedBoard.nodes.value
|
|
||||||
.slice()
|
|
||||||
.filter(n => {
|
|
||||||
// Exclude self
|
|
||||||
if (n === node) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exclude nodes that aren't within the corridor we'll be moving within
|
|
||||||
if (
|
|
||||||
(direction === Direction.Down || direction === Direction.Up) &&
|
|
||||||
Math.abs(n.position.x - node.position.x) > radius
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
(direction === Direction.Left || direction === Direction.Right) &&
|
|
||||||
Math.abs(n.position.y - node.position.y) > radius
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exclude nodes in the wrong direction
|
|
||||||
return !(
|
|
||||||
(direction === Direction.Right &&
|
|
||||||
n.position.x < node.position.x - radius) ||
|
|
||||||
(direction === Direction.Left && n.position.x > node.position.x + radius) ||
|
|
||||||
(direction === Direction.Up && n.position.y > node.position.y + radius) ||
|
|
||||||
(direction === Direction.Down && n.position.y < node.position.y - radius)
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.sort(
|
|
||||||
direction === Direction.Right
|
|
||||||
? (a, b) => a.position.x - b.position.x
|
|
||||||
: direction === Direction.Left
|
|
||||||
? (a, b) => b.position.x - a.position.x
|
|
||||||
: direction === Direction.Up
|
|
||||||
? (a, b) => b.position.y - a.position.y
|
|
||||||
: (a, b) => a.position.y - b.position.y
|
|
||||||
);
|
|
||||||
for (let i = 0; i < nodes.length; i++) {
|
|
||||||
const nodeToCheck = nodes[i];
|
|
||||||
const distance =
|
|
||||||
direction === Direction.Right || direction === Direction.Left
|
|
||||||
? Math.abs(node.position.x - nodeToCheck.position.x)
|
|
||||||
: Math.abs(node.position.y - nodeToCheck.position.y);
|
|
||||||
|
|
||||||
// If we're too close to this node, move further
|
|
||||||
if (distance < radius) {
|
|
||||||
if (direction === Direction.Right) {
|
|
||||||
node.position.x = nodeToCheck.position.x + radius;
|
|
||||||
} else if (direction === Direction.Left) {
|
|
||||||
node.position.x = nodeToCheck.position.x - radius;
|
|
||||||
} else if (direction === Direction.Up) {
|
|
||||||
node.position.y = nodeToCheck.position.y - radius;
|
|
||||||
} else if (direction === Direction.Down) {
|
|
||||||
node.position.y = nodeToCheck.position.y + radius;
|
|
||||||
}
|
|
||||||
} else if (i > 0 && distance > radius) {
|
|
||||||
// If we're further from this node than the radius, then the nodes are past us and we can early exit
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
board[GatherProps] = function (this: GenericBoard) {
|
|
||||||
const {
|
|
||||||
nodes,
|
|
||||||
types,
|
|
||||||
state,
|
|
||||||
visibility,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
style,
|
|
||||||
classes,
|
|
||||||
links,
|
|
||||||
selectedAction,
|
|
||||||
selectedNode,
|
|
||||||
mousePosition,
|
|
||||||
draggingNode,
|
|
||||||
receivingNode
|
|
||||||
} = this;
|
|
||||||
return {
|
|
||||||
nodes,
|
|
||||||
types,
|
|
||||||
state,
|
|
||||||
visibility,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
style: unref(style),
|
|
||||||
classes,
|
|
||||||
links,
|
|
||||||
selectedAction,
|
|
||||||
selectedNode,
|
|
||||||
mousePosition,
|
|
||||||
draggingNode,
|
|
||||||
receivingNode,
|
|
||||||
setDraggingNode,
|
|
||||||
setReceivingNode
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// This is necessary because board.types is different from T and Board
|
|
||||||
const processedBoard = board as unknown as Board<T>;
|
|
||||||
return processedBoard;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the value of a property for a specified node.
|
|
||||||
* @param property The property to find the value of
|
|
||||||
* @param node The node to get the property of
|
|
||||||
*/
|
|
||||||
export function getNodeProperty<T, S extends unknown[]>(
|
|
||||||
property: NodeComputable<T, S>,
|
|
||||||
node: BoardNode,
|
|
||||||
...args: S
|
|
||||||
): T {
|
|
||||||
return isFunction<T, [BoardNode, ...S], Computable<T>>(property)
|
|
||||||
? property(node, ...args)
|
|
||||||
: unref(property);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Utility to get an ID for a node that is guaranteed unique.
|
|
||||||
* @param board The board feature to generate an ID for
|
|
||||||
*/
|
|
||||||
export function getUniqueNodeID(board: GenericBoard): number {
|
|
||||||
let id = 0;
|
|
||||||
board.nodes.value.forEach(node => {
|
|
||||||
if (node.id >= id) {
|
|
||||||
id = node.id + 1;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
const listeners: Record<string, Unsubscribe | undefined> = {};
|
|
||||||
globalBus.on("addLayer", layer => {
|
|
||||||
const boards: GenericBoard[] = findFeatures(layer, BoardType) as GenericBoard[];
|
|
||||||
listeners[layer.id] = layer.on("postUpdate", diff => {
|
|
||||||
boards.forEach(board => {
|
|
||||||
Object.values(board.types).forEach(type =>
|
|
||||||
type.nodes.value.forEach(node => type.update?.(node, diff))
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
globalBus.on("removeLayer", layer => {
|
|
||||||
// unsubscribe from postUpdate
|
|
||||||
listeners[layer.id]?.();
|
|
||||||
listeners[layer.id] = undefined;
|
|
||||||
});
|
|
317
src/features/boards/board.tsx
Normal file
317
src/features/boards/board.tsx
Normal file
|
@ -0,0 +1,317 @@
|
||||||
|
import Board from "features/boards/Board.vue";
|
||||||
|
import { jsx } from "features/feature";
|
||||||
|
import { globalBus } from "game/events";
|
||||||
|
import type { PanZoom } from "panzoom";
|
||||||
|
import { Direction, isFunction } from "util/common";
|
||||||
|
import type { Computable, ProcessedComputable } from "util/computed";
|
||||||
|
import { convertComputable } from "util/computed";
|
||||||
|
import type { ComponentPublicInstance, Ref } from "vue";
|
||||||
|
import { computed, ref, unref, watchEffect } from "vue";
|
||||||
|
import panZoom from "vue-panzoom";
|
||||||
|
|
||||||
|
globalBus.on("setupVue", app => panZoom.install(app));
|
||||||
|
|
||||||
|
export type NodePosition = { x: number; y: number };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A type representing a computable value for a node on the board. Used for node types to return different values based on the given node and the state of the board.
|
||||||
|
*/
|
||||||
|
export type NodeComputable<T, R, S extends unknown[] = []> =
|
||||||
|
| Computable<R>
|
||||||
|
| ((node: T, ...args: S) => R);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the value of a property for a specified node.
|
||||||
|
* @param property The property to find the value of
|
||||||
|
* @param node The node to get the property of
|
||||||
|
*/
|
||||||
|
export function unwrapNodeRef<T, R, S extends unknown[]>(
|
||||||
|
property: NodeComputable<T, R, S>,
|
||||||
|
node: T,
|
||||||
|
...args: S
|
||||||
|
): R {
|
||||||
|
return isFunction<R, [T, ...S], ProcessedComputable<R>>(property)
|
||||||
|
? property(node, ...args)
|
||||||
|
: unref(property);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setupUniqueIds(nodes: Computable<{ id: number }[]>) {
|
||||||
|
const processedNodes = convertComputable(nodes);
|
||||||
|
return computed(() => Math.max(-1, ...unref(processedNodes).map(node => node.id)) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setupSelectable<T>() {
|
||||||
|
const selected = ref<T>();
|
||||||
|
return {
|
||||||
|
select: function (node: T) {
|
||||||
|
selected.value = node;
|
||||||
|
},
|
||||||
|
deselect: function () {
|
||||||
|
selected.value = undefined;
|
||||||
|
},
|
||||||
|
selected
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setupDraggableNode<T extends NodePosition, S extends NodePosition = T>(options: {
|
||||||
|
board: Ref<ComponentPublicInstance<typeof Board> | undefined>;
|
||||||
|
receivingNodes?: NodeComputable<T, S[]>;
|
||||||
|
dropAreaRadius?: NodeComputable<S, number>;
|
||||||
|
isDraggable?: NodeComputable<T, boolean>;
|
||||||
|
onDrop?: (acceptingNode: S, draggingNode: T) => void;
|
||||||
|
}) {
|
||||||
|
const nodeBeingDragged = ref<T>();
|
||||||
|
const receivingNode = ref<S>();
|
||||||
|
const hasDragged = ref(false);
|
||||||
|
const mousePosition = ref<NodePosition>();
|
||||||
|
const lastMousePosition = ref({ x: 0, y: 0 });
|
||||||
|
const dragDelta = ref({ x: 0, y: 0 });
|
||||||
|
const isDraggable = options.isDraggable ?? true;
|
||||||
|
const receivingNodes = computed(() =>
|
||||||
|
nodeBeingDragged.value == null
|
||||||
|
? []
|
||||||
|
: // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
unwrapNodeRef(options.receivingNodes ?? [], nodeBeingDragged.value!)
|
||||||
|
);
|
||||||
|
const dropAreaRadius = options.dropAreaRadius ?? 50;
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
if (nodeBeingDragged.value != null && !unwrapNodeRef(isDraggable, nodeBeingDragged.value)) {
|
||||||
|
result.endDrag();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
const node = nodeBeingDragged.value;
|
||||||
|
if (node == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const position = {
|
||||||
|
x: node.x + dragDelta.value.x,
|
||||||
|
y: node.y + dragDelta.value.y
|
||||||
|
};
|
||||||
|
let smallestDistance = Number.MAX_VALUE;
|
||||||
|
|
||||||
|
receivingNode.value = unref(receivingNodes).reduce((smallest: S | undefined, curr: S) => {
|
||||||
|
if ((curr as S | T) === node) {
|
||||||
|
return smallest;
|
||||||
|
}
|
||||||
|
|
||||||
|
const distanceSquared =
|
||||||
|
Math.pow(position.x - curr.x, 2) + Math.pow(position.y - curr.y, 2);
|
||||||
|
const size = unwrapNodeRef(dropAreaRadius, curr);
|
||||||
|
if (distanceSquared > smallestDistance || distanceSquared > size * size) {
|
||||||
|
return smallest;
|
||||||
|
}
|
||||||
|
|
||||||
|
smallestDistance = distanceSquared;
|
||||||
|
return curr;
|
||||||
|
}, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
nodeBeingDragged,
|
||||||
|
receivingNode,
|
||||||
|
hasDragged,
|
||||||
|
mousePosition,
|
||||||
|
lastMousePosition,
|
||||||
|
dragDelta,
|
||||||
|
receivingNodes,
|
||||||
|
startDrag: function (e: MouseEvent | TouchEvent, node?: T) {
|
||||||
|
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
|
||||||
|
};
|
||||||
|
dragDelta.value = { x: 0, y: 0 };
|
||||||
|
hasDragged.value = false;
|
||||||
|
|
||||||
|
if (node != null && unwrapNodeRef(isDraggable, node)) {
|
||||||
|
nodeBeingDragged.value = node;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
endDrag: function () {
|
||||||
|
if (nodeBeingDragged.value == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (receivingNode.value == null) {
|
||||||
|
nodeBeingDragged.value.x += Math.round(dragDelta.value.x / 25) * 25;
|
||||||
|
nodeBeingDragged.value.y += Math.round(dragDelta.value.y / 25) * 25;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (receivingNode.value != null) {
|
||||||
|
options.onDrop?.(receivingNode.value, nodeBeingDragged.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
nodeBeingDragged.value = undefined;
|
||||||
|
},
|
||||||
|
drag: function (e: MouseEvent | TouchEvent) {
|
||||||
|
const panZoomInstance = options.board.value?.panZoomInstance as PanZoom | undefined;
|
||||||
|
if (panZoomInstance == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { x, y, scale } = panZoomInstance.getTransform();
|
||||||
|
|
||||||
|
let clientX, clientY;
|
||||||
|
if ("touches" in e) {
|
||||||
|
if (e.touches.length === 1) {
|
||||||
|
clientX = e.touches[0].clientX;
|
||||||
|
clientY = e.touches[0].clientY;
|
||||||
|
} else {
|
||||||
|
result.endDrag();
|
||||||
|
mousePosition.value = undefined;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
clientX = e.clientX;
|
||||||
|
clientY = e.clientY;
|
||||||
|
}
|
||||||
|
|
||||||
|
mousePosition.value = {
|
||||||
|
x: (clientX - x) / scale,
|
||||||
|
y: (clientY - y) / scale
|
||||||
|
};
|
||||||
|
|
||||||
|
dragDelta.value = {
|
||||||
|
x: dragDelta.value.x + (clientX - lastMousePosition.value.x) / scale,
|
||||||
|
y: dragDelta.value.y + (clientY - lastMousePosition.value.y) / scale
|
||||||
|
};
|
||||||
|
lastMousePosition.value = {
|
||||||
|
x: clientX,
|
||||||
|
y: clientY
|
||||||
|
};
|
||||||
|
|
||||||
|
if (Math.abs(dragDelta.value.x) > 10 || Math.abs(dragDelta.value.y) > 10) {
|
||||||
|
hasDragged.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nodeBeingDragged.value != null) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setupActions<T extends NodePosition>(options: {
|
||||||
|
node: Computable<T | undefined>;
|
||||||
|
shouldShowActions?: NodeComputable<T, boolean>;
|
||||||
|
actions: NodeComputable<T, ((position: NodePosition) => JSX.Element)[]>;
|
||||||
|
distance: NodeComputable<T, number>;
|
||||||
|
arcLength?: NodeComputable<T, number>;
|
||||||
|
}) {
|
||||||
|
const node = convertComputable(options.node);
|
||||||
|
return jsx(() => {
|
||||||
|
const currNode = unref(node);
|
||||||
|
if (currNode == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const actions = unwrapNodeRef(options.actions, currNode);
|
||||||
|
const shouldShow = unwrapNodeRef(options.shouldShowActions, currNode) ?? true;
|
||||||
|
if (!shouldShow) {
|
||||||
|
return <>{actions.map(f => f(currNode))}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const distance = unwrapNodeRef(options.distance, currNode);
|
||||||
|
const arcLength = unwrapNodeRef(options.arcLength, currNode) ?? Math.PI / 6;
|
||||||
|
const firstAngle = Math.PI / 2 - ((actions.length - 1) / 2) * arcLength;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{actions.map((f, index) =>
|
||||||
|
f({
|
||||||
|
x: currNode.x + Math.cos(firstAngle + index * arcLength) * distance,
|
||||||
|
y: currNode.y + Math.sin(firstAngle + index * arcLength) * distance
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function placeInAvailableSpace<T extends NodePosition>(
|
||||||
|
nodeToPlace: T,
|
||||||
|
nodes: T[],
|
||||||
|
radius = 100,
|
||||||
|
direction = Direction.Right
|
||||||
|
) {
|
||||||
|
nodes = nodes
|
||||||
|
.filter(n => {
|
||||||
|
// Exclude self
|
||||||
|
if (n === nodeToPlace) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exclude nodes that aren't within the corridor we'll be moving within
|
||||||
|
if (
|
||||||
|
(direction === Direction.Down || direction === Direction.Up) &&
|
||||||
|
Math.abs(n.x - nodeToPlace.x) > radius
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(direction === Direction.Left || direction === Direction.Right) &&
|
||||||
|
Math.abs(n.y - nodeToPlace.y) > radius
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exclude nodes in the wrong direction
|
||||||
|
return !(
|
||||||
|
(direction === Direction.Right && n.x < nodeToPlace.x - radius) ||
|
||||||
|
(direction === Direction.Left && n.x > nodeToPlace.x + radius) ||
|
||||||
|
(direction === Direction.Up && n.y > nodeToPlace.y + radius) ||
|
||||||
|
(direction === Direction.Down && n.y < nodeToPlace.y - radius)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.sort(
|
||||||
|
direction === Direction.Right
|
||||||
|
? (a, b) => a.x - b.x
|
||||||
|
: direction === Direction.Left
|
||||||
|
? (a, b) => b.x - a.x
|
||||||
|
: direction === Direction.Up
|
||||||
|
? (a, b) => b.y - a.y
|
||||||
|
: (a, b) => a.y - b.y
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let i = 0; i < nodes.length; i++) {
|
||||||
|
const nodeToCheck = nodes[i];
|
||||||
|
const distance =
|
||||||
|
direction === Direction.Right || direction === Direction.Left
|
||||||
|
? Math.abs(nodeToPlace.x - nodeToCheck.x)
|
||||||
|
: Math.abs(nodeToPlace.y - nodeToCheck.y);
|
||||||
|
|
||||||
|
// If we're too close to this node, move further
|
||||||
|
if (distance < radius) {
|
||||||
|
if (direction === Direction.Right) {
|
||||||
|
nodeToPlace.x = nodeToCheck.x + radius;
|
||||||
|
} else if (direction === Direction.Left) {
|
||||||
|
nodeToPlace.x = nodeToCheck.x - radius;
|
||||||
|
} else if (direction === Direction.Up) {
|
||||||
|
nodeToPlace.y = nodeToCheck.y - radius;
|
||||||
|
} else if (direction === Direction.Down) {
|
||||||
|
nodeToPlace.y = nodeToCheck.y + radius;
|
||||||
|
}
|
||||||
|
} else if (i > 0 && distance > radius) {
|
||||||
|
// If we're further from this node than the radius, then the nodes are past us and we can early exit
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue