feat(editor): add node, edge, palette, and loop-overlay components
This commit is contained in:
89
src/components/LoopOverlay.vue
Normal file
89
src/components/LoopOverlay.vue
Normal 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>
|
||||||
48
src/components/Palette.vue
Normal file
48
src/components/Palette.vue
Normal 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>
|
||||||
71
src/components/edges/InfoLinkEdge.vue
Normal file
71
src/components/edges/InfoLinkEdge.vue
Normal 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>
|
||||||
29
src/components/nodes/CloudNode.vue
Normal file
29
src/components/nodes/CloudNode.vue
Normal 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>
|
||||||
32
src/components/nodes/ConverterNode.vue
Normal file
32
src/components/nodes/ConverterNode.vue
Normal 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>
|
||||||
44
src/components/nodes/FlowNode.vue
Normal file
44
src/components/nodes/FlowNode.vue
Normal 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>
|
||||||
62
src/components/nodes/NodeLabel.vue
Normal file
62
src/components/nodes/NodeLabel.vue
Normal 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>
|
||||||
30
src/components/nodes/StockNode.vue
Normal file
30
src/components/nodes/StockNode.vue
Normal 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>
|
||||||
7
src/components/palette-dnd.ts
Normal file
7
src/components/palette-dnd.ts
Normal 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"
|
||||||
Reference in New Issue
Block a user