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,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,70 +120,96 @@ 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"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
<!-- 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>
|
||||
</div>
|
||||
|
||||
<!-- Stock: the quantity it starts from, and its unit. -->
|
||||
<template v-if="element.kind === 'stock'">
|
||||
<label class="mt-2 block">
|
||||
<span class="text-xs text-base-content/60">Initial value</span>
|
||||
<input
|
||||
type="number"
|
||||
class="input input-sm input-bordered mt-1 w-full"
|
||||
:value="element.initialValue ?? ''"
|
||||
placeholder="—"
|
||||
@change="onInitial"
|
||||
/>
|
||||
</label>
|
||||
<label class="mt-2 block">
|
||||
<span class="text-xs text-base-content/60">Unit</span>
|
||||
<input
|
||||
type="text"
|
||||
class="input input-sm input-bordered mt-1 w-full"
|
||||
:value="element.unit ?? ''"
|
||||
placeholder="e.g. °C, people, $"
|
||||
@change="onUnit"
|
||||
/>
|
||||
</label>
|
||||
<!-- Stock: the quantity it starts from, and its unit. -->
|
||||
<template v-if="element.kind === 'stock'">
|
||||
<label class="mt-2 block">
|
||||
<span class="text-xs text-base-content/60">Initial value</span>
|
||||
<input
|
||||
type="number"
|
||||
class="input input-sm input-bordered mt-1 w-full"
|
||||
:value="element.initialValue ?? ''"
|
||||
placeholder="—"
|
||||
@change="onInitial"
|
||||
/>
|
||||
</label>
|
||||
<label class="mt-2 block">
|
||||
<span class="text-xs text-base-content/60">Unit</span>
|
||||
<input
|
||||
type="text"
|
||||
class="input input-sm input-bordered mt-1 w-full"
|
||||
:value="element.unit ?? ''"
|
||||
placeholder="e.g. °C, people, $"
|
||||
@change="onUnit"
|
||||
/>
|
||||
</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>
|
||||
|
||||
<!-- 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] }}
|
||||
<!-- 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>
|
||||
|
||||
Reference in New Issue
Block a user