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