Profectus has had a Board feature to allow for a pannable, zoomable canvas of nodes and links. In 0.7 it got changed from a feature to an entire system. The new system is very robust, but not trivial to convert old Board features to. So here’s an implementation of the Board feature that is compatible with Profectus 0.7. This is really only intended for updating older projects though, and you’ll want to use the new board system for new projects. Also, when updating your old code to 0.7, you’ll want to replace the various null
return values with undefined
. All these files expect to be in the same folder.
board.tsx
import { findFeatures, Visibility } from "features/feature";
import { globalBus } from "game/events";
import { DefaultValue, deletePersistent, Persistent, persistent, State } from "game/persistence";
import type { Unsubscribe } from "nanoevents";
import { Direction, isFunction } from "util/common";
import { processGetter } from "util/computed";
import { createLazyProxy } from "util/proxies";
import { VueFeature, vueFeatureMixin, VueFeatureOptions } from "util/vue";
import {
computed,
ComputedRef,
MaybeRef,
MaybeRefOrGetter,
ref,
Ref,
StyleValue,
unref
} from "vue";
import panZoom from "vue-panzoom";
import type { Link } from "../links/links";
import Board from "./Board.vue";
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 NodeMaybeRefOrGetter<T, S extends unknown[] = []> =
| MaybeRefOrGetter<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 | undefined;
selectedAction: string | undefined;
};
/**
* An object that configures a {@link NodeType}.
*/
export interface NodeTypeOptions {
/** The title to display for the node. */
title: NodeMaybeRefOrGetter<string>;
/** An optional label for the node. */
label?: NodeMaybeRefOrGetter<NodeLabel | undefined>;
/** The size of the node - diameter for circles, width and height for squares. */
size: NodeMaybeRefOrGetter<number>;
/** CSS to apply to this node. */
style?: NodeMaybeRefOrGetter<StyleValue>;
/** Dictionary of CSS classes to apply to this node. */
classes?: NodeMaybeRefOrGetter<Record<string, boolean>>;
/** Whether the node is draggable or not. */
draggable?: NodeMaybeRefOrGetter<boolean>;
/** The shape of the node. */
shape: NodeMaybeRefOrGetter<Shape>;
/** Whether the node can accept another node being dropped upon it. */
canAccept?: NodeMaybeRefOrGetter<boolean, [BoardNode]>;
/** The progress value of the node, from 0 to 1. */
progress?: NodeMaybeRefOrGetter<number>;
/** How the progress should be displayed on the node. */
progressDisplay?: NodeMaybeRefOrGetter<ProgressDisplay>;
/** The color of the progress indicator. */
progressColor?: NodeMaybeRefOrGetter<string>;
/** The fill color of the node. */
fillColor?: NodeMaybeRefOrGetter<string>;
/** The outline color of the node. */
outlineColor?: NodeMaybeRefOrGetter<string>;
/** The color of the title text. */
titleColor?: NodeMaybeRefOrGetter<string>;
/** The list of action options for the node. */
actions?: BoardNodeActionOptions[];
/** The arc between each action, in radians. */
actionDistance?: NodeMaybeRefOrGetter<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;
}
/** 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 interface NodeType {
/** The nodes currently on the board of this type. */
nodes: Ref<BoardNode[]>;
/** The title to display for the node. */
title: MaybeRef<string> | ((node: BoardNode) => string);
/** An optional label for the node. */
label?: MaybeRef<NodeLabel | undefined> | ((node: BoardNode) => NodeLabel | undefined);
/** The size of the node - diameter for circles, width and height for squares. */
size: MaybeRef<number> | ((node: BoardNode) => number);
/** CSS to apply to this node. */
style?: MaybeRef<StyleValue> | ((node: BoardNode) => StyleValue);
/** Dictionary of CSS classes to apply to this node. */
classes?: MaybeRef<Record<string, boolean>> | ((node: BoardNode) => Record<string, boolean>);
/** Whether the node is draggable or not. */
draggable: MaybeRef<boolean> | ((node: BoardNode) => boolean);
/** The shape of the node. */
shape: MaybeRef<Shape> | ((node: BoardNode) => Shape);
/** Whether the node can accept another node being dropped upon it. */
canAccept: MaybeRef<boolean> | ((node: BoardNode, otherNode: BoardNode) => boolean);
/** The progress value of the node, from 0 to 1. */
progress?: MaybeRef<number> | ((node: BoardNode) => number);
/** How the progress should be displayed on the node. */
progressDisplay: MaybeRef<ProgressDisplay> | ((node: BoardNode) => ProgressDisplay);
/** The color of the progress indicator. */
progressColor: MaybeRef<string> | ((node: BoardNode) => string);
/** The fill color of the node. */
fillColor?: MaybeRef<string> | ((node: BoardNode) => string);
/** The outline color of the node. */
outlineColor?: MaybeRef<string> | ((node: BoardNode) => string);
/** The color of the title text. */
titleColor?: MaybeRef<string> | ((node: BoardNode) => string);
/** The list of action options for the node. */
actions?: BoardNodeAction[];
/** The arc between each action, in radians. */
actionDistance: MaybeRef<number> | ((node: BoardNode) => 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;
}
/**
* 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?: NodeMaybeRefOrGetter<Visibility | boolean>;
/** The icon to display for the action. */
icon: NodeMaybeRefOrGetter<string>;
/** The fill color of the action. */
fillColor?: NodeMaybeRefOrGetter<string>;
/** The tooltip text to display for the action. */
tooltip: NodeMaybeRefOrGetter<NodeLabel>;
/** The confirmation label that appears under the action. */
confirmationLabel?: NodeMaybeRefOrGetter<NodeLabel>;
/** An array of board node links associated with the action. They appear when the action is focused. */
links?: NodeMaybeRefOrGetter<BoardNodeLink[]>;
/** A function that is called when the action is clicked. */
onClick: (node: BoardNode) => void;
}
/** An object that represents an action that can be taken upon a node. */
export interface BoardNodeAction {
/** A unique identifier for the action. */
id: string;
/** Whether this action should be visible. */
visibility: MaybeRef<Visibility | boolean> | ((node: BoardNode) => Visibility | boolean);
/** The icon to display for the action. */
icon: MaybeRef<string> | ((node: BoardNode) => string);
/** The fill color of the action. */
fillColor?: MaybeRef<string> | ((node: BoardNode) => string);
/** The tooltip text to display for the action. */
tooltip: MaybeRef<NodeLabel> | ((node: BoardNode) => NodeLabel);
/** The confirmation label that appears under the action. */
confirmationLabel: MaybeRef<NodeLabel> | ((node: BoardNode) => NodeLabel);
/** An array of board node links associated with the action. They appear when the action is focused. */
links?: MaybeRef<BoardNodeLink[]> | ((node: BoardNode) => BoardNodeLink[]);
/** A function that is called when the action is clicked. */
onClick: (node: BoardNode) => void;
}
/**
* An object that configures a {@link Board}.
*/
export interface BoardOptions extends VueFeatureOptions {
/** The height of the board. Defaults to 100% */
height?: MaybeRefOrGetter<string>;
/** The width of the board. Defaults to 100% */
width?: MaybeRefOrGetter<string>;
/** 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?: Ref<BoardData> | (() => BoardData);
/** An array of board node links to display. */
links?: MaybeRefOrGetter<BoardNodeLink[] | undefined>;
}
/** An object that represents a feature that is a zoomable, pannable board with various nodes upon it. */
export interface Board extends VueFeature {
/** All the nodes currently on the board. */
nodes: Ref<BoardNode[]>;
/** The currently selected node, if any. */
selectedNode: Ref<BoardNode | undefined>;
/** The currently selected action, if any. */
selectedAction: Ref<BoardNodeAction | undefined>;
/** The currently being dragged node, if any. */
draggingNode: Ref<BoardNode | undefined>;
/** If dragging a node, the node it's currently being hovered over, if any. */
receivingNode: Ref<BoardNode | undefined>;
/** The current mouse position, if over the board. */
mousePosition: Ref<{ x: number; y: number } | undefined>;
/** The height of the board. Defaults to 100% */
height?: MaybeRef<string>;
/** The width of the board. Defaults to 100% */
width?: MaybeRef<string>;
/** A dictionary of node types that can appear on the board. */
types: Record<string, NodeType>;
/** The persistent state of the board. */
state: Ref<BoardData>;
/** An array of board node links to display. */
links: MaybeRef<BoardNodeLink[] | undefined>;
/** 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;
}
/**
* Lazily creates a board with the given options.
* @param optionsFunc Board options.
*/
export function createBoard<T extends BoardOptions>(optionsFunc: () => T) {
let state: Ref<BoardData> = persistent<BoardData>(
{
nodes: [],
selectedNode: undefined,
selectedAction: undefined
},
false
);
return createLazyProxy(() => {
const options = optionsFunc();
const { height, width, startNodes, types, state: _state, links, ...props } = options;
if (_state) {
deletePersistent(state as unknown as Persistent);
state = processGetter(_state);
} else {
(state as unknown as Persistent<BoardData>)[DefaultValue] = {
nodes: startNodes().map((n, i) => {
(n as BoardNode).id = i;
return n as BoardNode;
}),
selectedNode: undefined,
selectedAction: undefined
};
}
const nodes: ComputedRef<BoardNode[]> = computed(() => unref(board.state).nodes);
const board = {
type: BoardType,
...(props as Omit<typeof props, keyof VueFeature | keyof BoardOptions>),
...vueFeatureMixin("board", options, () => (
<Board
types={board.types}
state={board.state}
width={board.width}
height={board.height}
links={board.links}
selectedAction={board.selectedAction}
selectedNode={board.selectedNode}
draggingNode={board.draggingNode}
receivingNode={board.receivingNode}
mousePosition={board.mousePosition}
nodes={board.nodes}
/>
)),
state,
nodes,
selectedNode: computed({
get(): BoardNode | undefined {
return nodes.value.find(node => node.id === unref(board.state).selectedNode);
},
set(node) {
state.value = {
...state.value,
selectedNode: node?.id
};
}
}),
selectedAction: computed({
get(): BoardNodeAction | undefined {
const selectedNode = board.selectedNode.value;
if (selectedNode == null) {
return undefined;
}
const type = board.types[selectedNode.type];
if (type.actions == null) {
return undefined;
}
return type.actions.find(
action => action.id === unref(board.state).selectedAction
);
},
set(action) {
board.state.value = {
...board.state.value,
selectedAction: action?.id
};
}
}),
mousePosition: ref<{ x: number; y: number }>(),
draggingNode: ref<BoardNode>(),
receivingNode: ref<BoardNode>(),
links: links
? processGetter(links)
: computed((): BoardNodeLink[] | undefined => {
if (board.selectedAction.value == null) {
return undefined;
}
if (
board.selectedAction.value.links != null &&
board.selectedNode.value != null
) {
return getNodeProperty(
board.selectedAction.value.links,
board.selectedNode.value
);
}
return undefined;
}),
width: processGetter(width) ?? "100%",
height: processGetter(height) ?? "100%",
placeInAvailableSpace: function (
node: BoardNode,
radius = 100,
direction = Direction.Right
) {
const nodes = board.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;
}
}
},
types: Object.keys(types).reduce(
(acc, curr) => {
const {
title,
label,
size,
style,
classes,
draggable,
shape,
canAccept,
progress,
progressDisplay,
progressColor,
fillColor,
outlineColor,
titleColor,
actions,
actionDistance,
onClick,
onDrop,
update,
...props
} = types[curr];
acc[curr] = {
...(props as Omit<typeof props, keyof NodeTypeOptions>),
title: processGetter(title),
label: processGetter(label),
size: processGetter(size),
style: processGetter(style),
classes: processGetter(classes),
draggable: processGetter(draggable) ?? false,
shape: processGetter(shape) ?? Shape.Circle,
canAccept: processGetter(canAccept) ?? false,
progress: processGetter(progress),
progressDisplay: processGetter(progressDisplay) ?? ProgressDisplay.Fill,
progressColor: processGetter(progressColor) ?? "none",
fillColor: processGetter(fillColor),
outlineColor: processGetter(outlineColor),
titleColor: processGetter(titleColor),
actions: actions
? actions.reduce((acc, curr) => {
const {
id,
visibility,
icon,
fillColor,
tooltip,
confirmationLabel,
links,
onClick
} = curr;
const action: BoardNodeAction = {
id: processGetter(id),
visibility: processGetter(visibility) ?? Visibility.Visible,
icon: processGetter(icon),
fillColor: processGetter(fillColor),
tooltip: processGetter(tooltip),
confirmationLabel: processGetter(confirmationLabel) ?? {
text: "Tap again to confirm"
},
links: processGetter(links),
onClick
};
return [...acc, action];
}, [] as BoardNodeAction[])
: undefined,
actionDistance: processGetter(actionDistance) ?? Math.PI / 6,
nodes: computed(() =>
unref(board.state).nodes.filter(node => node.type === curr)
),
onClick:
onClick ??
function (node: BoardNode) {
unref(board.state).selectedNode = node.id;
},
onDrop,
update
} satisfies NodeType;
return acc;
},
{} as Record<string, NodeType>
)
} satisfies Board;
return board;
});
}
/**
* 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: NodeMaybeRefOrGetter<T, S>,
node: BoardNode,
...args: S
): T {
return isFunction<T, [BoardNode, ...S], MaybeRef<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: Board): 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: Board[] = findFeatures(layer, BoardType) as Board[];
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;
});
Board.vue
<template>
<panZoom
:style="{ width, height }"
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(unref(draggingNode))"
@touchend.passive="() => endDragging(unref(draggingNode))"
@mouseleave="() => endDragging(unref(draggingNode), true)"
>
<svg class="stage" width="100%" height="100%">
<g class="g1">
<transition-group name="link" appear>
<g
v-for="link in unref(links) || []"
:key="`${link.startNode.id}-${link.endNode.id}`"
>
<BoardLinkVue
: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>
</template>
<script setup lang="ts">
import { computed, isReadonly, ref, unref, watchEffect } from "vue";
import type {
Board,
BoardNode
} from "./board";
import { getNodeProperty } from "./board";
import BoardLinkVue from "./BoardLink.vue";
import BoardNodeVue from "./BoardNode.vue";
const props = defineProps<{
nodes: Board["nodes"];
types: Board["types"];
state: Board["state"];
width?:Board["width"];
height?:Board["height"];
links: Board["links"];
selectedAction: Board["selectedAction"];
selectedNode: Board["selectedNode"];
draggingNode: Board["draggingNode"];
receivingNode: Board["receivingNode"];
mousePosition: Board["mousePosition"];
}>();
const lastMousePosition = ref({ x: 0, y: 0 });
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(() => {
const nodes = unref(props.nodes).slice();
const selectedNode = unref(props.selectedNode);
if (selectedNode) {
const node = nodes.splice(nodes.indexOf(selectedNode), 1)[0];
nodes.push(node);
}
const draggingNode = unref(props.draggingNode);
if (draggingNode) {
const node = nodes.splice(nodes.indexOf(draggingNode), 1)[0];
nodes.push(node);
}
return nodes;
});
watchEffect(() => {
const node = unref(props.draggingNode);
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.receivingNode.value = unref(props.nodes).reduce((smallest: BoardNode | undefined, curr: BoardNode) => {
if (curr.id === node.id) {
return smallest;
}
const nodeType = unref(props.types)[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;
}, undefined);
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function onInit(panzoomInstance: any) {
panzoomInstance.setTransformOrigin(null);
panzoomInstance.moveTo(stage.value.$el.clientWidth / 2, stage.value.$el.clientHeight / 2);
}
function mouseDown(e: MouseEvent | TouchEvent, node: BoardNode | undefined = undefined, draggable = false) {
if (unref(props.draggingNode) == 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.draggingNode.value = node;
}
}
if (node != null && !isReadonly(props.state)) {
props.state.value.selectedNode = undefined;
props.state.value.selectedAction = undefined;
}
}
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 = undefined;
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 | undefined, mouseLeave = false) {
const draggingNode = unref(props.draggingNode);
const receivingNode = unref(props.receivingNode);
if (draggingNode != null && draggingNode === node) {
if (receivingNode == null) {
draggingNode.position.x += Math.round(dragged.value.x / 25) * 25;
draggingNode.position.y += Math.round(dragged.value.y / 25) * 25;
}
const nodes = props.nodes.value;
nodes.push(nodes.splice(nodes.indexOf(draggingNode), 1)[0]);
if (receivingNode) {
unref(props.types)[receivingNode.type].onDrop?.(
receivingNode,
draggingNode
);
}
props.draggingNode.value = undefined;
} else if (!hasDragged.value && !mouseLeave && !isReadonly(props.state)) {
props.state.value.selectedNode = undefined;
props.state.value.selectedAction = undefined;
}
}
function clickAction(node: BoardNode, actionId: string) {
if (props.state.value.selectedAction === actionId) {
unref(props.selectedAction)?.onClick(unref(props.selectedNode)!);
} else if (!isReadonly(props.state)) {
props.state.value = { ...props.state.value, selectedAction: actionId };
}
}
</script>
<style>
.vue-pan-zoom-scene {
width: 100%;
height: 100%;
cursor: grab;
}
.vue-pan-zoom-scene:active {
cursor: grabbing;
}
.g1 {
transition-duration: 0s;
}
.link-enter-from,
.link-leave-to {
opacity: 0;
}
</style>
BoardLink.vue
<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 } from "vue";
const props = defineProps<{
link: BoardNodeLink;
dragging?: BoardNode;
dragged?: {
x: number;
y: number;
};
}>();
const startPosition = computed(() => {
const position = { ...props.link.startNode.position };
if (props.link.offsetStart) {
position.x += props.link.offsetStart.x;
position.y += props.link.offsetStart.y;
}
if (props.dragging === props.link.startNode) {
position.x += props.dragged?.x ?? 0;
position.y += props.dragged?.y ?? 0;
}
return position;
});
const endPosition = computed(() => {
const position = { ...props.link.endNode.position };
if (props.link.offsetEnd) {
position.x += props.link.offsetEnd.x;
position.y += props.link.offsetEnd.y;
}
if (props.dragging === props.link.endNode) {
position.x += props.dragged?.x ?? 0;
position.y += props.dragged?.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>
BoardNode.vue
<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)' : ''}`"
>
<BoardNodeActionVue
: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 { isVisible } from "features/feature";
import settings from "game/settings";
import { CSSProperties, computed, watch } from "vue";
import type { BoardNode, BoardNodeAction, NodeType } from "./board";
import { ProgressDisplay, Shape, getNodeProperty } from "./board";
import BoardNodeActionVue from "./BoardNodeAction.vue";
const sqrtTwo = Math.sqrt(2);
const props = defineProps<{
node: BoardNode;
nodeType: NodeType;
dragging?: BoardNode;
dragged?: {
x: number;
y: number;
};
hasDragged?: boolean;
receivingNode?: boolean;
isSelected: boolean;
selectedAction?: BoardNodeAction;
}>();
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.draggable, props.node));
watch(isDraggable, value => {
if (props.dragging === props.node && !value) {
emit("endDragging", props.node);
}
});
const actions = computed(() => getNodeProperty(props.nodeType.actions, props.node)?.filter(action =>
isVisible(getNodeProperty(action.visibility, props.node))
));
const position = computed(() => {
if (
getNodeProperty(props.nodeType.draggable, props.node) &&
props.dragging?.id === props.node.id &&
props.dragged != null
) {
const { x, y } = props.dragged;
return {
x: props.node.position.x + Math.round(x / 25) * 25,
y: props.node.position.y + Math.round(y / 25) * 25
};
}
return props.node.position;
});
const shape = computed(() => getNodeProperty(props.nodeType.shape, props.node));
const title = computed(() => getNodeProperty(props.nodeType.title, props.node));
const label = computed(
() =>
(props.isSelected
? props.selectedAction &&
getNodeProperty(props.selectedAction.tooltip, props.node)
: null) ?? getNodeProperty(props.nodeType.label, props.node)
);
const confirmationLabel = computed(() =>
getNodeProperty(
props.selectedAction?.confirmationLabel ?? {
text: "Tap again to confirm"
},
props.node
)
);
const size = computed(() => getNodeProperty(props.nodeType.size, props.node));
const progress = computed(
() => getNodeProperty(props.nodeType.progress, props.node) ?? 0
);
const backgroundColor = computed(() => themes[settings.theme].variables["--background"]);
const outlineColor = computed(
() =>
getNodeProperty(props.nodeType.outlineColor, props.node) ??
themes[settings.theme].variables["--outline"]
);
const fillColor = computed(
() =>
getNodeProperty(props.nodeType.fillColor, props.node) ??
themes[settings.theme].variables["--raised-background"]
);
const progressColor = computed(() =>
getNodeProperty(props.nodeType.progressColor, props.node)
);
const titleColor = computed(
() =>
getNodeProperty(props.nodeType.titleColor, props.node) ??
themes[settings.theme].variables["--foreground"]
);
const progressDisplay = computed(() =>
getNodeProperty(props.nodeType.progressDisplay, props.node)
);
const canAccept = computed(
() =>
props.dragging != null &&
props.hasDragged &&
getNodeProperty(props.nodeType.canAccept, props.node, props.dragging)
);
const style = computed(() => getNodeProperty(props.nodeType.style, props.node));
const classes = computed(() => getNodeProperty(props.nodeType.classes, props.node));
function mouseDown(e: MouseEvent | TouchEvent) {
emit("mouseDown", e, props.node, isDraggable.value);
}
function mouseUp(e: MouseEvent | TouchEvent) {
if (!props.hasDragged) {
emit("endDragging", props.node);
props.nodeType.onClick?.(props.node);
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>
BoardNodeAction.vue
<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 settings from "game/settings";
import { computed } from "vue";
import type { BoardNode, BoardNodeAction, NodeType } from "./board";
import { getNodeProperty } from "./board";
const props = defineProps<{
node: BoardNode;
nodeType: NodeType;
actions?: BoardNodeAction[];
isSelected: boolean;
selectedAction?: BoardNodeAction;
}>();
const emit = defineEmits<{
(e: "clickAction", actionId: string): void;
}>();
const size = computed(() => getNodeProperty(props.nodeType.size, props.node));
const outlineColor = computed(
() =>
getNodeProperty(props.nodeType.outlineColor, props.node) ??
themes[settings.theme].variables["--outline"]
);
const titleColor = computed(
() =>
getNodeProperty(props.nodeType.titleColor, props.node) ??
themes[settings.theme].variables["--foreground"]
);
const actionDistance = computed(() =>
getNodeProperty(props.nodeType.actionDistance, props.node)
);
function performAction(e: MouseEvent | TouchEvent, action: BoardNodeAction) {
emit("clickAction", action.id);
e.preventDefault();
e.stopPropagation();
}
function actionMouseUp(e: MouseEvent | TouchEvent, action: BoardNodeAction) {
if (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>