feat(editor): add node, edge, palette, and loop-overlay components

This commit is contained in:
Julien Calixte
2026-06-20 09:37:42 +02:00
parent 0416581799
commit 03848ae893
9 changed files with 412 additions and 0 deletions

View File

@@ -0,0 +1,89 @@
<script setup lang="ts">
/**
* Loop overlay (C11, ADR-0001) — an R/B badge floating at each detected loop's
* centre, Reinforcing in green and Balancing in amber. Loops are derived live
* from the wiring (store getter), so badges appear, vanish and reclassify as you
* wire, toggle polarity, or delete. Hovering a badge lights up that loop's nodes
* and links via the shared highlight.
*
* Badges live in screen space: each loop's centroid (the average of its node
* positions, in flow coords) is mapped through the live viewport transform so it
* tracks pan/zoom, and colliding badges are stacked so overlapping loops stay
* legible (the accepted cost of on-canvas badges).
*/
import { useVueFlow } from "@vue-flow/core"
import { computed } from "vue"
import { useLoopHighlight } from "@/composables/useLoopHighlight"
import type { Loop } from "@/model/loops"
import { useModelStore } from "@/store/model"
const store = useModelStore()
const { viewport } = useVueFlow("meadows")
const { highlight, clear } = useLoopHighlight()
const nameOf = computed(() => {
const map = new Map<string, string>()
for (const node of store.model.nodes) if (node.kind !== "cloud") map.set(node.id, node.name)
return map
})
interface Badge {
loop: Loop
x: number
y: number
}
const badges = computed<Badge[]>(() => {
const positions = new Map(store.model.nodes.map((n) => [n.id, n.position]))
const { x: vx, y: vy, zoom } = viewport.value
const placed: Badge[] = []
for (const loop of store.loops) {
let sx = 0
let sy = 0
for (const id of loop.nodeIds) {
const p = positions.get(id)
if (p) {
sx += p.x
sy += p.y
}
}
const n = loop.nodeIds.length || 1
// node positions are top-left; nudge toward node centre, then to screen.
const x = (sx / n + 24) * zoom + vx
let y = (sy / n + 18) * zoom + vy
while (placed.some((b) => Math.abs(b.x - x) < 26 && Math.abs(b.y - y) < 26)) y += 26
placed.push({ loop, x, y })
}
return placed
})
function title(loop: Loop): string {
const kind = loop.type === "R" ? "Reinforcing" : "Balancing"
const names = loop.nodeIds.map((id) => nameOf.value.get(id) ?? "·").join(" → ")
return `${kind} loop · ${names}`
}
</script>
<template>
<div class="pointer-events-none absolute inset-0 overflow-hidden">
<button
v-for="badge in badges"
:key="badge.loop.id"
class="pointer-events-auto absolute flex size-6 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border text-xs font-bold shadow transition-transform hover:scale-125"
:class="
badge.loop.type === 'R'
? 'border-success/60 bg-success text-success-content'
: 'border-warning/60 bg-warning text-warning-content'
"
:style="{ left: `${badge.x}px`, top: `${badge.y}px` }"
:title="title(badge.loop)"
@mouseenter="highlight(badge.loop)"
@mouseleave="clear()"
@focus="highlight(badge.loop)"
@blur="clear()"
>
{{ badge.loop.type }}
</button>
</div>
</template>

View File

@@ -0,0 +1,48 @@
<script setup lang="ts">
/**
* Palette (C12) — place a node either way (F1, "1 click/drag"): click to drop at
* the viewport centre, or drag a chip onto the canvas to drop where you release.
* The Editor owns placement; this just signals the kind (via event on click, via
* dataTransfer on drag). Flows aren't placed here — they're drawn by connecting
* two nodes (F2, next).
*/
import { NODE_DND_MIME, type PlaceableKind } from "./palette-dnd"
const emit = defineEmits<{ add: [kind: PlaceableKind] }>()
function onDragStart(event: DragEvent, kind: PlaceableKind): void {
if (!event.dataTransfer) return
event.dataTransfer.setData(NODE_DND_MIME, kind)
event.dataTransfer.effectAllowed = "copy"
}
</script>
<template>
<div
class="flex flex-col gap-1 rounded-box border border-base-300 bg-base-100/90 p-2 shadow-md backdrop-blur"
>
<span
class="px-2 pt-1 pb-0.5 text-xs font-semibold tracking-wide text-base-content/50 uppercase"
>
Add
</span>
<button
class="btn btn-ghost btn-sm cursor-grab justify-start gap-2 active:cursor-grabbing"
draggable="true"
@click="emit('add', 'stock')"
@dragstart="onDragStart($event, 'stock')"
>
<span class="inline-block size-3.5 rounded-sm border-2 border-current" />
Stock
</button>
<button
class="btn btn-ghost btn-sm cursor-grab justify-start gap-2 active:cursor-grabbing"
draggable="true"
@click="emit('add', 'converter')"
@dragstart="onDragStart($event, 'converter')"
>
<span class="inline-block size-3.5 rounded-full border-2 border-current" />
Converter
</button>
</div>
</template>

View File

@@ -0,0 +1,71 @@
<script setup lang="ts">
/**
* Information Link edge (C3) — the dashed influence arrow plus a clickable
* polarity badge at its midpoint. Polarity is captured on create (default `+`,
* F6) and flipped in one click here; it is the loop detector's only structural
* input (ADR-0001), so it lives right on the link. The bezier pipe is drawn by
* BaseEdge; the badge is portalled over the viewport by EdgeLabelRenderer so it
* stays pinned to the path under pan/zoom.
*/
import { BaseEdge, type EdgeProps, EdgeLabelRenderer, getBezierPath } from "@vue-flow/core"
import { computed } from "vue"
import { useLoopHighlight } from "@/composables/useLoopHighlight"
import type { EdgeData } from "@/model/projection"
import { useModelStore } from "@/store/model"
const props = defineProps<EdgeProps<EdgeData>>()
const store = useModelStore()
const { highlighted } = useLoopHighlight()
// [path, labelX, labelY, ...] — labelX/Y is the path centre, in flow coords.
const path = computed(() =>
getBezierPath({
sourceX: props.sourceX,
sourceY: props.sourceY,
sourcePosition: props.sourcePosition,
targetX: props.targetX,
targetY: props.targetY,
targetPosition: props.targetPosition,
}),
)
const polarity = computed(() => props.data.polarity ?? "+")
// Display the typographic minus (the model stores ASCII "-").
const glyph = computed(() => (polarity.value === "+" ? "+" : ""))
// Light up when this link is an edge of the hovered loop (C11).
const inLoop = computed(
() => highlighted.value?.edges.has(`${props.source}>${props.target}`) ?? false,
)
const edgeStyle = computed(() =>
inLoop.value
? {
...props.style,
stroke: highlighted.value?.type === "R" ? "var(--color-success)" : "var(--color-warning)",
strokeWidth: "2.5px",
}
: props.style,
)
</script>
<template>
<BaseEdge :id="props.id" :path="path[0]" :marker-end="props.markerEnd" :style="edgeStyle" />
<EdgeLabelRenderer>
<button
class="nodrag nopan absolute flex size-5 items-center justify-center rounded-full border text-xs font-bold leading-none shadow-sm transition-colors"
:class="
polarity === '+'
? 'border-primary/40 bg-base-100 text-primary hover:bg-primary/10'
: 'border-error/50 bg-base-100 text-error hover:bg-error/10'
"
:style="{
transform: `translate(-50%, -50%) translate(${path[1]}px, ${path[2]}px)`,
pointerEvents: 'all',
}"
:title="`Polarity ${glyph} — click to toggle`"
@click="store.toggleLinkPolarity(props.id)"
>
{{ glyph }}
</button>
</EdgeLabelRenderer>
</template>

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
/**
* Cloud — the model boundary (Source/Sink) on an open Flow end. Auto-materialised
* (ADR-0003), never placed by hand, carries no name. Drawn as the conventional
* cloud symbol, with handles so a Flow can terminate on it either way.
*/
import { Handle, type NodeProps, Position } from "@vue-flow/core"
import { HANDLE_IN, HANDLE_OUT, type NodeData } from "@/model/projection"
defineProps<NodeProps<NodeData>>()
</script>
<template>
<div class="relative">
<Handle :id="HANDLE_IN" type="target" :position="Position.Left" />
<svg
viewBox="0 0 24 24"
class="size-9 text-base-content/40"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M6.5 19a4.5 4.5 0 0 1 0-9 5.5 5.5 0 0 1 10.5-1.5 4 4 0 0 1 1 7.9" />
</svg>
<Handle :id="HANDLE_OUT" type="source" :position="Position.Right" />
</div>
</template>

View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
/**
* Converter — a stateless helper value. Drawn as a circle, the classic
* auxiliary/converter symbol, with its name beneath. Handles let Information
* Links arrive (target) and leave (source).
*/
import { Handle, type NodeProps, Position } from "@vue-flow/core"
import { computed } from "vue"
import { useNodeLoopRing } from "@/composables/useLoopHighlight"
import { HANDLE_IN, HANDLE_OUT, type NodeData } from "@/model/projection"
import type { ConverterNode } from "@/model/types"
import NodeLabel from "./NodeLabel.vue"
const props = defineProps<NodeProps<NodeData>>()
// The projection guarantees a converter-typed node here.
const converter = computed(() => props.data.node as ConverterNode)
const loopRing = useNodeLoopRing(props.id)
</script>
<template>
<div class="flex flex-col items-center gap-1">
<div
class="relative size-12 rounded-full border-2 bg-base-100 shadow-sm transition-colors"
:class="[props.selected ? 'border-primary' : 'border-base-300', loopRing]"
>
<Handle :id="HANDLE_IN" type="target" :position="Position.Left" />
<Handle :id="HANDLE_OUT" type="source" :position="Position.Right" />
</div>
<NodeLabel :node-id="props.id" :name="converter.name" />
</div>
</template>

View File

@@ -0,0 +1,44 @@
<script setup lang="ts">
/**
* Flow — a rate, drawn as the valve (an outlined bowtie/butterfly) sitting on
* the pipe between source and target. The pipe segments are projected edges
* (ADR-0003); this node is the valve and its name. Outlined (not filled) so it
* reads as a tap rather than two arrows. Handles sit on the valve so the pipe
* connects through it and Information Links can target/leave the rate.
*/
import { Handle, type NodeProps, Position } from "@vue-flow/core"
import { computed } from "vue"
import { useNodeLoopRing } from "@/composables/useLoopHighlight"
import { HANDLE_IN, HANDLE_OUT, type NodeData } from "@/model/projection"
import type { FlowNode } from "@/model/types"
import NodeLabel from "./NodeLabel.vue"
const props = defineProps<NodeProps<NodeData>>()
// The projection guarantees a flow-typed node here.
const flow = computed(() => props.data.node as FlowNode)
const loopRing = useNodeLoopRing(props.id)
</script>
<template>
<div class="flex flex-col items-center gap-1">
<div class="relative rounded-full" :class="loopRing">
<Handle :id="HANDLE_IN" type="target" :position="Position.Left" />
<svg
viewBox="0 0 24 24"
class="size-7 transition-colors"
:class="props.selected ? 'text-primary' : 'text-base-content/60'"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linejoin="round"
>
<!-- Outlined bowtie valve: two hollow triangles meeting at the centre. -->
<path d="M3 5 L12 12 L3 19 Z" />
<path d="M21 5 L12 12 L21 19 Z" />
</svg>
<Handle :id="HANDLE_OUT" type="source" :position="Position.Right" />
</div>
<NodeLabel :node-id="props.id" :name="flow.name" />
</div>
</template>

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
/**
* A node's name with frictionless inline rename: double-click to edit, Enter or
* blur to commit through the store, Escape to cancel. The `nodrag` class and
* stopped pointer events keep Vue Flow from starting a drag while typing.
*
* While editing, an invisible sizer span (mirroring the draft text) holds the
* box width and the input overlays it absolutely — so switching between display
* and edit never reflows the node (no width jump on a Stock, no shifted circle
* on a Converter).
*/
import { nextTick, ref } from "vue"
import { useModelStore } from "@/store/model"
const props = defineProps<{ nodeId: string; name: string }>()
const store = useModelStore()
const editing = ref(false)
const draft = ref("")
const input = ref<HTMLInputElement | null>(null)
async function begin(): Promise<void> {
draft.value = props.name
editing.value = true
await nextTick()
input.value?.focus()
input.value?.select()
}
function commit(): void {
if (!editing.value) return
editing.value = false
const trimmed = draft.value.trim()
if (trimmed && trimmed !== props.name) store.renameNode(props.nodeId, trimmed)
}
function cancel(): void {
editing.value = false
}
</script>
<template>
<span class="relative inline-block min-w-[1.5ch] text-center text-sm font-medium">
<template v-if="editing">
<!-- Invisible sizer: defines the box width from the draft, so the input
(absolutely positioned over it) matches the text it replaced. -->
<span class="invisible whitespace-pre" aria-hidden="true">{{ draft || " " }}</span>
<input
ref="input"
v-model="draft"
class="nodrag absolute inset-0 w-full rounded bg-base-100 px-0 text-center text-sm font-medium outline-none ring-1 ring-primary"
@blur="commit"
@keydown.enter.prevent="commit"
@keydown.esc.prevent="cancel"
@pointerdown.stop
/>
</template>
<span v-else class="cursor-text whitespace-pre select-none" @dblclick.stop="begin">
{{ name }}
</span>
</span>
</template>

View File

@@ -0,0 +1,30 @@
<script setup lang="ts">
/**
* Stock — the accumulation. Drawn as a rectangle, the classic stock-and-flow
* symbol for the one stateful element. A target handle (left) and source handle
* (right) let Flows attach and Information Links leave.
*/
import { Handle, type NodeProps, Position } from "@vue-flow/core"
import { computed } from "vue"
import { useNodeLoopRing } from "@/composables/useLoopHighlight"
import { HANDLE_IN, HANDLE_OUT, type NodeData } from "@/model/projection"
import type { StockNode } from "@/model/types"
import NodeLabel from "./NodeLabel.vue"
const props = defineProps<NodeProps<NodeData>>()
// The projection guarantees a stock-typed node here.
const stock = computed(() => props.data.node as StockNode)
const loopRing = useNodeLoopRing(props.id)
</script>
<template>
<div
class="min-w-24 rounded-md border-2 bg-base-100 px-4 py-3 text-center shadow-sm transition-colors"
:class="[props.selected ? 'border-primary' : 'border-base-300', loopRing]"
>
<Handle :id="HANDLE_IN" type="target" :position="Position.Left" />
<NodeLabel :node-id="props.id" :name="stock.name" />
<Handle :id="HANDLE_OUT" type="source" :position="Position.Right" />
</div>
</template>

View File

@@ -0,0 +1,7 @@
/** Shared contract between the Palette (drag source) and Editor (drop target). */
/** Node kinds the palette can place. Flows are drawn by connecting (F2). */
export type PlaceableKind = "stock" | "converter"
/** Custom MIME type carrying the kind across an HTML5 drag-and-drop. */
export const NODE_DND_MIME = "application/x-meadows-node"