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:
@@ -14,7 +14,9 @@ import { useVueFlow } from "@vue-flow/core"
|
||||
import { computed } from "vue"
|
||||
import { type GlossEntry, INFO_LINK_GLOSS, NODE_GLOSSARY } from "@/model/glossary"
|
||||
import type { EdgeData, NodeData } from "@/model/projection"
|
||||
import { useModelStore } from "@/store/model"
|
||||
|
||||
const store = useModelStore()
|
||||
const { getSelectedNodes, getSelectedEdges } = useVueFlow("meadows")
|
||||
|
||||
const gloss = computed<GlossEntry | null>(() => {
|
||||
@@ -32,6 +34,33 @@ const gloss = computed<GlossEntry | 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>
|
||||
|
||||
<template>
|
||||
@@ -41,5 +70,13 @@ const gloss = computed<GlossEntry | null>(() => {
|
||||
>
|
||||
<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>
|
||||
<!-- 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>
|
||||
</template>
|
||||
|
||||
@@ -14,20 +14,25 @@
|
||||
import { useVueFlow } from "@vue-flow/core"
|
||||
import { computed } from "vue"
|
||||
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"
|
||||
|
||||
const store = useModelStore()
|
||||
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 nodes = getSelectedNodes.value
|
||||
const edges = getSelectedEdges.value
|
||||
if (nodes.length === 1 && edges.length === 0) return nodes[0].id
|
||||
if (edges.length === 1 && nodes.length === 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
|
||||
})
|
||||
@@ -41,8 +46,21 @@ const element = computed<StockNode | FlowNode | ConverterNode | 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
|
||||
|
||||
/** 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. */
|
||||
function ruleNumber(rule?: Rule): number {
|
||||
if (!rule) return 0
|
||||
@@ -68,6 +86,13 @@ function onUnit(event: Event): void {
|
||||
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 {
|
||||
const el = element.value
|
||||
if (el?.kind !== "flow" && el?.kind !== "converter") return
|
||||
@@ -95,9 +120,11 @@ const RULE_HINT: Record<Rule["kind"], string> = {
|
||||
|
||||
<template>
|
||||
<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"
|
||||
>
|
||||
<!-- A named node: Stock (value + unit) or Flow/Converter (rule). -->
|
||||
<template v-if="element">
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="text-sm font-semibold">{{ element.name }}</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] }}
|
||||
</p>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@@ -99,6 +99,14 @@ function nodeError(value: unknown, index: number): string | null {
|
||||
) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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.target !== "string") return `${at}.target must be a string`
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -78,6 +78,8 @@ export interface StockNode extends BaseNode {
|
||||
initialValue?: number
|
||||
/** Unit of the quantity, e.g. "°C", "people", "$" — shown beside the value (display only). */
|
||||
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
|
||||
/** How its rate is computed each instant (ADR-0004). Optional in the diagram phase. */
|
||||
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
|
||||
/** How its value is computed each instant (ADR-0004). Optional in the diagram phase. */
|
||||
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: string
|
||||
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. */
|
||||
|
||||
@@ -128,6 +128,28 @@ export const useModelStore = defineStore("model", () => {
|
||||
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:
|
||||
* one of the fixed rules, never a formula). No-op when unchanged.
|
||||
@@ -291,6 +313,7 @@ export const useModelStore = defineStore("model", () => {
|
||||
renameNode,
|
||||
setInitialValue,
|
||||
setUnit,
|
||||
setDescription,
|
||||
setRule,
|
||||
setSimSpec,
|
||||
removeNode,
|
||||
|
||||
Reference in New Issue
Block a user