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 { 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>
|
||||||
|
|||||||
@@ -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,70 +120,96 @@ 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"
|
||||||
>
|
>
|
||||||
<div class="flex items-baseline gap-2">
|
<!-- A named node: Stock (value + unit) or Flow/Converter (rule). -->
|
||||||
<span class="text-sm font-semibold">{{ element.name }}</span>
|
<template v-if="element">
|
||||||
<span class="text-xs text-base-content/50">{{ KIND_LABEL[element.kind] }}</span>
|
<div class="flex items-baseline gap-2">
|
||||||
</div>
|
<span class="text-sm font-semibold">{{ element.name }}</span>
|
||||||
|
<span class="text-xs text-base-content/50">{{ KIND_LABEL[element.kind] }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Stock: the quantity it starts from, and its unit. -->
|
<!-- Stock: the quantity it starts from, and its unit. -->
|
||||||
<template v-if="element.kind === 'stock'">
|
<template v-if="element.kind === 'stock'">
|
||||||
<label class="mt-2 block">
|
<label class="mt-2 block">
|
||||||
<span class="text-xs text-base-content/60">Initial value</span>
|
<span class="text-xs text-base-content/60">Initial value</span>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
class="input input-sm input-bordered mt-1 w-full"
|
class="input input-sm input-bordered mt-1 w-full"
|
||||||
:value="element.initialValue ?? ''"
|
:value="element.initialValue ?? ''"
|
||||||
placeholder="—"
|
placeholder="—"
|
||||||
@change="onInitial"
|
@change="onInitial"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label class="mt-2 block">
|
<label class="mt-2 block">
|
||||||
<span class="text-xs text-base-content/60">Unit</span>
|
<span class="text-xs text-base-content/60">Unit</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-sm input-bordered mt-1 w-full"
|
class="input input-sm input-bordered mt-1 w-full"
|
||||||
:value="element.unit ?? ''"
|
:value="element.unit ?? ''"
|
||||||
placeholder="e.g. °C, people, $"
|
placeholder="e.g. °C, people, $"
|
||||||
@change="onUnit"
|
@change="onUnit"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Flow / Converter: pick a rule, then its number. -->
|
||||||
|
<template v-else>
|
||||||
|
<label class="mt-2 block">
|
||||||
|
<span class="text-xs text-base-content/60">Rule</span>
|
||||||
|
<select
|
||||||
|
class="select select-sm select-bordered mt-1 w-full"
|
||||||
|
:value="element.rule?.kind ?? ''"
|
||||||
|
@change="onKind"
|
||||||
|
>
|
||||||
|
<option value="" disabled>Choose a rule…</option>
|
||||||
|
<option value="constant">Constant</option>
|
||||||
|
<option value="proportional">Proportional</option>
|
||||||
|
<option value="gap">Gap</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label v-if="element.rule" class="mt-2 block">
|
||||||
|
<span class="text-xs text-base-content/60">
|
||||||
|
{{ element.rule.kind === "constant" ? "Value" : "Factor" }}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
class="input input-sm input-bordered mt-1 w-full"
|
||||||
|
:value="ruleNumber(element.rule)"
|
||||||
|
@change="onParam"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<p v-if="element.rule" class="mt-2 text-xs leading-snug text-base-content/50">
|
||||||
|
{{ RULE_HINT[element.rule.kind] }}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Flow / Converter: pick a rule, then its number. -->
|
<!-- An Information Link: its endpoints and polarity, then its description. -->
|
||||||
<template v-else>
|
<template v-else-if="link">
|
||||||
<label class="mt-2 block">
|
<div class="flex items-baseline gap-2">
|
||||||
<span class="text-xs text-base-content/60">Rule</span>
|
<span class="text-sm font-semibold">Information Link</span>
|
||||||
<select
|
<span class="text-xs text-base-content/50">{{ link.polarity === "+" ? "+" : "−" }}</span>
|
||||||
class="select select-sm select-bordered mt-1 w-full"
|
</div>
|
||||||
:value="element.rule?.kind ?? ''"
|
<p class="mt-1 text-xs text-base-content/60">
|
||||||
@change="onKind"
|
{{ nodeName(link.source) }} → {{ nodeName(link.target) }}
|
||||||
>
|
|
||||||
<option value="" disabled>Choose a rule…</option>
|
|
||||||
<option value="constant">Constant</option>
|
|
||||||
<option value="proportional">Proportional</option>
|
|
||||||
<option value="gap">Gap</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label v-if="element.rule" class="mt-2 block">
|
|
||||||
<span class="text-xs text-base-content/60">
|
|
||||||
{{ element.rule.kind === "constant" ? "Value" : "Factor" }}
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="any"
|
|
||||||
class="input input-sm input-bordered mt-1 w-full"
|
|
||||||
:value="ruleNumber(element.rule)"
|
|
||||||
@change="onParam"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<p v-if="element.rule" class="mt-2 text-xs leading-snug text-base-content/50">
|
|
||||||
{{ RULE_HINT[element.rule.kind] }}
|
|
||||||
</p>
|
</p>
|
||||||
</template>
|
</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>
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user