feat(editor): give each node and link a description

A free-text "why this element is here" note on Stocks, Flows,
Converters, and Information Links — editable in the Inspector next to
the unit, shown read-only in the gloss card below the generic kind
definition on selection. The Inspector now also opens for Information
Links, which previously had no editing surface.
This commit is contained in:
Julien Calixte
2026-06-21 15:19:31 +02:00
parent 37bbdf5650
commit 4a0498ee55
5 changed files with 191 additions and 61 deletions

View File

@@ -14,7 +14,9 @@ import { useVueFlow } from "@vue-flow/core"
import { computed } from "vue" import { computed } from "vue"
import { type GlossEntry, INFO_LINK_GLOSS, NODE_GLOSSARY } from "@/model/glossary" import { type GlossEntry, INFO_LINK_GLOSS, NODE_GLOSSARY } from "@/model/glossary"
import type { EdgeData, NodeData } from "@/model/projection" import type { EdgeData, NodeData } from "@/model/projection"
import { useModelStore } from "@/store/model"
const store = useModelStore()
const { getSelectedNodes, getSelectedEdges } = useVueFlow("meadows") const { getSelectedNodes, getSelectedEdges } = useVueFlow("meadows")
const gloss = computed<GlossEntry | null>(() => { const gloss = computed<GlossEntry | null>(() => {
@@ -32,6 +34,33 @@ const gloss = computed<GlossEntry | null>(() => {
} }
return null return null
}) })
/**
* The selected element's own "why it's here" note (G4) — distinct from the
* generic `gloss` above, which defines the *kind*. Read live from the store so
* it tracks edits, and resolves the same selection a pipe/info edge stands in for.
*/
const description = computed<string | null>(() => {
const nodes = getSelectedNodes.value
const edges = getSelectedEdges.value
if (nodes.length === 1 && edges.length === 0) {
const node = store.model.nodes.find((n) => n.id === nodes[0].id)
return node && node.kind !== "cloud" ? (node.description ?? null) : null
}
if (edges.length === 1 && nodes.length === 0) {
const edge = edges[0]
const kind = (edge.data as EdgeData | undefined)?.kind
if (kind === "pipe") {
const flow = store.model.nodes.find((n) => n.id === edge.id.split("::")[0])
return flow && flow.kind !== "cloud" ? (flow.description ?? null) : null
}
if (kind === "info") {
return store.model.infoLinks.find((l) => l.id === edge.id)?.description ?? null
}
}
return null
})
</script> </script>
<template> <template>
@@ -41,5 +70,13 @@ const gloss = computed<GlossEntry | null>(() => {
> >
<div class="text-sm font-semibold">{{ gloss.term }}</div> <div class="text-sm font-semibold">{{ gloss.term }}</div>
<p class="mt-0.5 text-xs leading-snug text-base-content/70">{{ gloss.short }}</p> <p class="mt-0.5 text-xs leading-snug text-base-content/70">{{ gloss.short }}</p>
<!-- This element's own rationale, below a divider so it reads apart from the
generic definition above (the "tour" note for the loaded sample). -->
<p
v-if="description"
class="mt-2 border-t border-base-300 pt-2 text-xs leading-snug text-base-content/80"
>
{{ description }}
</p>
</div> </div>
</template> </template>

View File

@@ -14,20 +14,25 @@
import { useVueFlow } from "@vue-flow/core" import { useVueFlow } from "@vue-flow/core"
import { computed } from "vue" import { computed } from "vue"
import type { EdgeData } from "@/model/projection" import type { EdgeData } from "@/model/projection"
import type { ConverterNode, FlowNode, Rule, StockNode } from "@/model/types" import type { ConverterNode, FlowNode, InformationLink, Rule, StockNode } from "@/model/types"
import { useModelStore } from "@/store/model" import { useModelStore } from "@/store/model"
const store = useModelStore() const store = useModelStore()
const { getSelectedNodes, getSelectedEdges } = useVueFlow("meadows") const { getSelectedNodes, getSelectedEdges } = useVueFlow("meadows")
/** The single selected element's id — a node directly, or a Flow via its pipe edge. */ /**
* The single selected element's id — a node directly, a Flow via its pipe edge,
* or an Information Link via its info edge (the edge id *is* the link id).
*/
const selectedId = computed<string | null>(() => { const selectedId = computed<string | null>(() => {
const nodes = getSelectedNodes.value const nodes = getSelectedNodes.value
const edges = getSelectedEdges.value const edges = getSelectedEdges.value
if (nodes.length === 1 && edges.length === 0) return nodes[0].id if (nodes.length === 1 && edges.length === 0) return nodes[0].id
if (edges.length === 1 && nodes.length === 0) { if (edges.length === 1 && nodes.length === 0) {
const edge = edges[0] const edge = edges[0]
if ((edge.data as EdgeData | undefined)?.kind === "pipe") return edge.id.split("::")[0] const kind = (edge.data as EdgeData | undefined)?.kind
if (kind === "pipe") return edge.id.split("::")[0]
if (kind === "info") return edge.id
} }
return null return null
}) })
@@ -41,8 +46,21 @@ const element = computed<StockNode | FlowNode | ConverterNode | null>(() => {
return null return null
}) })
/** The live Information Link behind the selection, when a link (not a node) is selected. */
const link = computed<InformationLink | null>(() => {
const id = selectedId.value
if (!id) return null
return store.model.infoLinks.find((l) => l.id === id) ?? null
})
const KIND_LABEL = { stock: "Stock", flow: "Flow", converter: "Converter" } as const const KIND_LABEL = { stock: "Stock", flow: "Flow", converter: "Converter" } as const
/** A link's endpoints are always named nodes — it never touches a Cloud (validation.ts). */
function nodeName(id: string): string {
const node = store.model.nodes.find((n) => n.id === id)
return node && "name" in node ? node.name : ""
}
/** Every rule carries exactly one number (a value or a factor); read it uniformly. */ /** Every rule carries exactly one number (a value or a factor); read it uniformly. */
function ruleNumber(rule?: Rule): number { function ruleNumber(rule?: Rule): number {
if (!rule) return 0 if (!rule) return 0
@@ -68,6 +86,13 @@ function onUnit(event: Event): void {
store.setUnit(el.id, (event.target as HTMLInputElement).value) store.setUnit(el.id, (event.target as HTMLInputElement).value)
} }
/** Write the description for whichever element (node or link) is selected. */
function onDescription(event: Event): void {
const id = element.value?.id ?? link.value?.id
if (!id) return
store.setDescription(id, (event.target as HTMLTextAreaElement).value)
}
function onKind(event: Event): void { function onKind(event: Event): void {
const el = element.value const el = element.value
if (el?.kind !== "flow" && el?.kind !== "converter") return if (el?.kind !== "flow" && el?.kind !== "converter") return
@@ -95,9 +120,11 @@ const RULE_HINT: Record<Rule["kind"], string> = {
<template> <template>
<div <div
v-if="element" v-if="element || link"
class="absolute top-3 right-3 z-20 w-60 rounded-box border border-base-300 bg-base-100/95 p-3 shadow-md backdrop-blur" class="absolute top-3 right-3 z-20 w-60 rounded-box border border-base-300 bg-base-100/95 p-3 shadow-md backdrop-blur"
> >
<!-- A named node: Stock (value + unit) or Flow/Converter (rule). -->
<template v-if="element">
<div class="flex items-baseline gap-2"> <div class="flex items-baseline gap-2">
<span class="text-sm font-semibold">{{ element.name }}</span> <span class="text-sm font-semibold">{{ element.name }}</span>
<span class="text-xs text-base-content/50">{{ KIND_LABEL[element.kind] }}</span> <span class="text-xs text-base-content/50">{{ KIND_LABEL[element.kind] }}</span>
@@ -160,5 +187,29 @@ const RULE_HINT: Record<Rule["kind"], string> = {
{{ RULE_HINT[element.rule.kind] }} {{ RULE_HINT[element.rule.kind] }}
</p> </p>
</template> </template>
</template>
<!-- An Information Link: its endpoints and polarity, then its description. -->
<template v-else-if="link">
<div class="flex items-baseline gap-2">
<span class="text-sm font-semibold">Information Link</span>
<span class="text-xs text-base-content/50">{{ link.polarity === "+" ? "+" : "" }}</span>
</div>
<p class="mt-1 text-xs text-base-content/60">
{{ nodeName(link.source) }} {{ nodeName(link.target) }}
</p>
</template>
<!-- Shared across nodes and links: the "why this element is here" note (G4). -->
<label class="mt-2 block">
<span class="text-xs text-base-content/60">Description</span>
<textarea
rows="3"
class="textarea textarea-bordered textarea-sm mt-1 w-full leading-snug"
:value="(element ?? link)?.description ?? ''"
placeholder="Why this element is here…"
@change="onDescription"
/>
</label>
</div> </div>
</template> </template>

View File

@@ -99,6 +99,14 @@ function nodeError(value: unknown, index: number): string | null {
) { ) {
return `${at}.rule must be a valid rule (constant/proportional/gap)` return `${at}.rule must be a valid rule (constant/proportional/gap)`
} }
// The selection-time rationale (display only); any named node may carry one.
if (
kind !== "cloud" &&
value.description !== undefined &&
typeof value.description !== "string"
) {
return `${at}.description must be a string`
}
return null return null
} }
@@ -110,6 +118,9 @@ function linkError(value: unknown, index: number): string | null {
if (typeof value.source !== "string") return `${at}.source must be a string` if (typeof value.source !== "string") return `${at}.source must be a string`
if (typeof value.target !== "string") return `${at}.target must be a string` if (typeof value.target !== "string") return `${at}.target must be a string`
if (!isPolarity(value.polarity)) return `${at}.polarity must be "+" or "-"` if (!isPolarity(value.polarity)) return `${at}.polarity must be "+" or "-"`
if (value.description !== undefined && typeof value.description !== "string") {
return `${at}.description must be a string`
}
return null return null
} }

View File

@@ -78,6 +78,8 @@ export interface StockNode extends BaseNode {
initialValue?: number initialValue?: number
/** Unit of the quantity, e.g. "°C", "people", "$" — shown beside the value (display only). */ /** Unit of the quantity, e.g. "°C", "people", "$" — shown beside the value (display only). */
unit?: string unit?: string
/** Why this Stock exists in *this* model — a free-text note surfaced on selection (display only). */
description?: string
} }
/** /**
@@ -95,6 +97,8 @@ export interface FlowNode extends BaseNode {
target: string target: string
/** How its rate is computed each instant (ADR-0004). Optional in the diagram phase. */ /** How its rate is computed each instant (ADR-0004). Optional in the diagram phase. */
rule?: Rule rule?: Rule
/** Why this Flow exists in *this* model — a free-text note surfaced on selection (display only). */
description?: string
} }
/** /**
@@ -106,6 +110,8 @@ export interface ConverterNode extends BaseNode {
name: string name: string
/** How its value is computed each instant (ADR-0004). Optional in the diagram phase. */ /** How its value is computed each instant (ADR-0004). Optional in the diagram phase. */
rule?: Rule rule?: Rule
/** Why this Converter exists in *this* model — a free-text note surfaced on selection (display only). */
description?: string
} }
/** /**
@@ -131,6 +137,8 @@ export interface InformationLink {
/** Target node id — a Flow or Converter, never a Stock (ADR-0001). */ /** Target node id — a Flow or Converter, never a Stock (ADR-0001). */
target: string target: string
polarity: Polarity polarity: Polarity
/** Why this link exists in *this* model — a free-text note surfaced on selection (display only). */
description?: string
} }
/** One saved document: the unit of save / export / reopen. */ /** One saved document: the unit of save / export / reopen. */

View File

@@ -128,6 +128,28 @@ export const useModelStore = defineStore("model", () => {
else node.unit = next else node.unit = next
} }
/**
* Set (or clear) the free-text description on a named node or an Information
* Link — the "why this element exists" note surfaced on selection. Empty
* clears it; no-op when unchanged, so a blur with no edit doesn't burn undo.
*/
function setDescription(id: string, text: string): void {
const next = text.trim() || undefined
const node = findNode(id)
if (node && node.kind !== "cloud") {
if (node.description === next) return
record()
if (next === undefined) delete node.description
else node.description = next
return
}
const link = model.value.infoLinks.find((l) => l.id === id)
if (!link || link.description === next) return
record()
if (next === undefined) delete link.description
else link.description = next
}
/** /**
* Set (or clear) how a Flow's rate or a Converter's value is computed (ADR-0004: * Set (or clear) how a Flow's rate or a Converter's value is computed (ADR-0004:
* one of the fixed rules, never a formula). No-op when unchanged. * one of the fixed rules, never a formula). No-op when unchanged.
@@ -291,6 +313,7 @@ export const useModelStore = defineStore("model", () => {
renameNode, renameNode,
setInitialValue, setInitialValue,
setUnit, setUnit,
setDescription,
setRule, setRule,
setSimSpec, setSimSpec,
removeNode, removeNode,