Compare commits
13 Commits
feat/overs
...
faf8500fa5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
faf8500fa5 | ||
|
|
f691b99ca5 | ||
|
|
46d3279a3c | ||
|
|
15e9149095 | ||
|
|
889c7361ed | ||
|
|
26d3c4493a | ||
|
|
4a0498ee55 | ||
|
|
37bbdf5650 | ||
|
|
67c16a8d44 | ||
|
|
e7a1bfb5af | ||
|
|
59e3aafa63 | ||
|
|
2ae7cf87d7 | ||
|
|
69fec5fe2e |
@@ -24,7 +24,7 @@ import {
|
|||||||
VueFlow,
|
VueFlow,
|
||||||
type XYPosition,
|
type XYPosition,
|
||||||
} from "@vue-flow/core"
|
} from "@vue-flow/core"
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef } from "vue"
|
import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from "vue"
|
||||||
import { useAutosave } from "@/composables/useAutosave"
|
import { useAutosave } from "@/composables/useAutosave"
|
||||||
import { usePlayback } from "@/composables/usePlayback"
|
import { usePlayback } from "@/composables/usePlayback"
|
||||||
import { parseModel, serializeModel } from "@/model/io"
|
import { parseModel, serializeModel } from "@/model/io"
|
||||||
@@ -74,6 +74,7 @@ const {
|
|||||||
getSelectedEdges,
|
getSelectedEdges,
|
||||||
viewport,
|
viewport,
|
||||||
vueFlowRef,
|
vueFlowRef,
|
||||||
|
dimensions,
|
||||||
fitView,
|
fitView,
|
||||||
} = useVueFlow("meadows")
|
} = useVueFlow("meadows")
|
||||||
|
|
||||||
@@ -89,6 +90,26 @@ onNodesInitialized(() => {
|
|||||||
fitView({ padding: 0.2 })
|
fitView({ padding: 0.2 })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Docking the results panel (Simulate) shrinks the canvas; closing it gives the
|
||||||
|
// space back. Re-fit either way so the whole diagram stays framed in whatever
|
||||||
|
// height remains. Vue Flow re-measures the pane a frame after the panel
|
||||||
|
// mounts/unmounts and republishes the size via `dimensions`; fitting on *that*
|
||||||
|
// signal (not straight after the toggle) frames against the settled size, so the
|
||||||
|
// diagram never lands jammed against the new edge. The flag scopes the fit to
|
||||||
|
// panel toggles, leaving ordinary window resizes to Vue Flow's own handling.
|
||||||
|
let fitOnResize = false
|
||||||
|
watch(showResults, () => {
|
||||||
|
fitOnResize = true
|
||||||
|
})
|
||||||
|
watch(
|
||||||
|
() => [dimensions.value.width, dimensions.value.height],
|
||||||
|
() => {
|
||||||
|
if (!fitOnResize) return
|
||||||
|
fitOnResize = false
|
||||||
|
fitView({ padding: 0.2 })
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
// Restore the last document on mount and persist every change (F7). Arm the same
|
// Restore the last document on mount and persist every change (F7). Arm the same
|
||||||
// post-measure fit a load uses; fitting synchronously in onRestore would frame
|
// post-measure fit a load uses; fitting synchronously in onRestore would frame
|
||||||
// against unmeasured 0×0 nodes, landing the restored model jammed top-left.
|
// against unmeasured 0×0 nodes, landing the restored model jammed top-left.
|
||||||
@@ -460,7 +481,6 @@ onBeforeUnmount(() => {
|
|||||||
<LoopOverlay />
|
<LoopOverlay />
|
||||||
<GlossPanel />
|
<GlossPanel />
|
||||||
<Inspector />
|
<Inspector />
|
||||||
<ResultsPanel v-if="showResults" @close="showResults = false" />
|
|
||||||
|
|
||||||
<!-- Self-dismissing teaching hint, e.g. when a Flow is dragged back onto its
|
<!-- Self-dismissing teaching hint, e.g. when a Flow is dragged back onto its
|
||||||
own Stock to "close the loop". Click to dismiss early; z-30 keeps it above
|
own Stock to "close the loop". Click to dismiss early; z-30 keeps it above
|
||||||
@@ -479,5 +499,10 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Docked beneath the canvas (not floating over it): opening Simulate shrinks
|
||||||
|
the canvas to the space above, where the diagram re-fits (see the
|
||||||
|
`dimensions` watcher in <script>). -->
|
||||||
|
<ResultsPanel v-if="showResults" @close="showResults = false" />
|
||||||
</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
|
||||||
@@ -90,14 +115,18 @@ const RULE_HINT: Record<Rule["kind"], string> = {
|
|||||||
constant: "A fixed number — no inputs.",
|
constant: "A fixed number — no inputs.",
|
||||||
proportional: "rate = factor × its “+” inputs.",
|
proportional: "rate = factor × its “+” inputs.",
|
||||||
gap: "rate = factor × (level − target): the “+” input is the level, the “−” the target.",
|
gap: "rate = factor × (level − target): the “+” input is the level, the “−” the target.",
|
||||||
|
overflow:
|
||||||
|
"rate = max(0, factor × (level − threshold)): spills only once the “+” level passes the “−” threshold.",
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<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>
|
||||||
@@ -140,6 +169,7 @@ const RULE_HINT: Record<Rule["kind"], string> = {
|
|||||||
<option value="constant">Constant</option>
|
<option value="constant">Constant</option>
|
||||||
<option value="proportional">Proportional</option>
|
<option value="proportional">Proportional</option>
|
||||||
<option value="gap">Gap</option>
|
<option value="gap">Gap</option>
|
||||||
|
<option value="overflow">Overflow</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -160,5 +190,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>
|
||||||
|
|||||||
@@ -59,9 +59,7 @@ const chart = computed(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div class="shrink-0 border-t border-base-300 bg-base-100 p-3">
|
||||||
class="absolute inset-x-3 bottom-3 z-30 rounded-box border border-base-300 bg-base-100/95 p-3 shadow-lg backdrop-blur"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-sm font-semibold">Behaviour over time</span>
|
<span class="text-sm font-semibold">Behaviour over time</span>
|
||||||
<span class="truncate text-xs text-base-content/50">{{ store.model.name }}</span>
|
<span class="truncate text-xs text-base-content/50">{{ store.model.name }}</span>
|
||||||
|
|||||||
@@ -76,8 +76,54 @@ function themeColor(name: string, fallback: string): string {
|
|||||||
return value || fallback
|
return value || fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Peak |value| of a track as an order of magnitude (log10), or null when the
|
||||||
|
* track is empty or flat-zero — there is no magnitude to compare.
|
||||||
|
*/
|
||||||
|
function magnitude(track: (number | null)[]): number | null {
|
||||||
|
let peak = 0
|
||||||
|
for (const v of track) {
|
||||||
|
if (v !== null && Number.isFinite(v)) {
|
||||||
|
const a = Math.abs(v)
|
||||||
|
if (a > peak) peak = a
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return peak > 0 ? Math.log10(peak) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split tracks onto a second y-axis once they span more than this many orders of
|
||||||
|
// magnitude: below it a single shared axis reads fine; above it the smaller track
|
||||||
|
// is flattened against the baseline by the larger one.
|
||||||
|
const SPLIT_DECADES = 1
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assign each series a y-scale: "y" (left axis) or "y2" (right axis). Tracks are
|
||||||
|
* split at their widest magnitude gap when that gap exceeds SPLIT_DECADES, with the
|
||||||
|
* smaller-magnitude tracks moving to "y2"; otherwise every track stays on "y".
|
||||||
|
* Flat-zero tracks have no magnitude, so they stay on the primary axis.
|
||||||
|
*/
|
||||||
|
function scaleKeys(): ("y" | "y2")[] {
|
||||||
|
const tracks = props.data.slice(1) as (number | null)[][]
|
||||||
|
const mags = tracks.map(magnitude)
|
||||||
|
const known = mags.filter((m): m is number => m !== null).sort((a, b) => a - b)
|
||||||
|
if (known.length < 2) return tracks.map(() => "y")
|
||||||
|
// The widest gap between adjacent magnitudes is the natural cut between "big" and
|
||||||
|
// "small" tracks; its midpoint is the threshold each track is measured against.
|
||||||
|
let gap = 0
|
||||||
|
let cut = 0
|
||||||
|
for (let i = 1; i < known.length; i++) {
|
||||||
|
if (known[i] - known[i - 1] > gap) {
|
||||||
|
gap = known[i] - known[i - 1]
|
||||||
|
cut = (known[i] + known[i - 1]) / 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (gap < SPLIT_DECADES) return tracks.map(() => "y")
|
||||||
|
return mags.map((m) => (m !== null && m < cut ? "y2" : "y"))
|
||||||
|
}
|
||||||
|
|
||||||
function trackSig(): string {
|
function trackSig(): string {
|
||||||
return props.series.map((s) => `${s.label}|${s.stroke}`).join(",")
|
const scales = scaleKeys()
|
||||||
|
return props.series.map((s, i) => `${s.label}|${s.stroke}|${scales[i]}`).join(",")
|
||||||
}
|
}
|
||||||
|
|
||||||
function options(width: number): uPlot.Options {
|
function options(width: number): uPlot.Options {
|
||||||
@@ -92,16 +138,24 @@ function options(width: number): uPlot.Options {
|
|||||||
ticks: { stroke: themeColor("--color-base-300", "#e5e7eb"), width: 1 },
|
ticks: { stroke: themeColor("--color-base-300", "#e5e7eb"), width: 1 },
|
||||||
font: `11px ${family}`,
|
font: `11px ${family}`,
|
||||||
}
|
}
|
||||||
|
const scales = scaleKeys()
|
||||||
|
const split = scales.includes("y2")
|
||||||
return {
|
return {
|
||||||
width,
|
width,
|
||||||
height: props.height,
|
height: props.height,
|
||||||
cursor: { y: false },
|
// No y crosshair, and no click-drag-to-zoom (uPlot's drag.setScale defaults
|
||||||
|
// on): the chart is a read-only readout — the playhead drives time, not a
|
||||||
|
// drag-selected zoom region.
|
||||||
|
cursor: { y: false, drag: { x: false, y: false } },
|
||||||
scales: { x: { time: false } },
|
scales: { x: { time: false } },
|
||||||
series: [
|
series: [
|
||||||
{},
|
// x is simulation time, so the live legend's first column reads "Time"
|
||||||
|
// rather than uPlot's default "Value".
|
||||||
|
{ label: "Time" },
|
||||||
...props.series.map(
|
...props.series.map(
|
||||||
(s): uPlot.Series => ({
|
(s, i): uPlot.Series => ({
|
||||||
label: s.label,
|
label: s.label,
|
||||||
|
scale: scales[i],
|
||||||
stroke: s.stroke,
|
stroke: s.stroke,
|
||||||
width: 2,
|
width: 2,
|
||||||
points: { show: false },
|
points: { show: false },
|
||||||
@@ -109,7 +163,15 @@ function options(width: number): uPlot.Options {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
axes: [axis, { ...axis, size: 52 }],
|
// When tracks span orders of magnitude the small ones move to a right-hand
|
||||||
|
// axis ("y2"); its grid is suppressed so the two grids don't clash.
|
||||||
|
axes: split
|
||||||
|
? [
|
||||||
|
axis,
|
||||||
|
{ ...axis, size: 52 },
|
||||||
|
{ ...axis, scale: "y2", side: 1, size: 52, grid: { show: false } },
|
||||||
|
]
|
||||||
|
: [axis, { ...axis, size: 52 }],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,4 +235,12 @@ watch(() => props.marker, syncMarker)
|
|||||||
:deep(.u-legend .u-value) {
|
:deep(.u-legend .u-value) {
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
/* Each track row toggles its line on click (⌘/Ctrl-click isolates it); the first
|
||||||
|
row is the Time readout and has no such handler. */
|
||||||
|
:deep(.u-legend .u-series) {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
:deep(.u-legend .u-series:first-child) {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -55,7 +55,8 @@ function isPolarity(value: unknown): value is Polarity {
|
|||||||
function isRule(value: unknown): value is Rule {
|
function isRule(value: unknown): value is Rule {
|
||||||
if (!isObject(value)) return false
|
if (!isObject(value)) return false
|
||||||
if (value.kind === "constant") return isFiniteNumber(value.value)
|
if (value.kind === "constant") return isFiniteNumber(value.value)
|
||||||
if (value.kind === "proportional" || value.kind === "gap") return isFiniteNumber(value.factor)
|
if (value.kind === "proportional" || value.kind === "gap" || value.kind === "overflow")
|
||||||
|
return isFiniteNumber(value.factor)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,6 +100,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 +119,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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
1093
src/model/samples.ts
1093
src/model/samples.ts
File diff suppressed because it is too large
Load Diff
@@ -107,6 +107,19 @@ function evalRule(rule: Rule, links: InformationLink[], valueOf: (id: string) =>
|
|||||||
rule.factor * ((level ? valueOf(level.source) : 0) - (target ? valueOf(target.source) : 0))
|
rule.factor * ((level ? valueOf(level.source) : 0) - (target ? valueOf(target.source) : 0))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
case "overflow": {
|
||||||
|
// max(0, factor × (level − threshold)): a one-sided gap. The `+` input is the
|
||||||
|
// level, the `−` the threshold; it stays shut until the level passes it, so an
|
||||||
|
// overflow Flow spills only the excess. Clamping at 0 is what stops it running
|
||||||
|
// backwards below the threshold — gap can't, by design (it's bidirectional).
|
||||||
|
const level = links.find((link) => link.polarity === "+")
|
||||||
|
const threshold = links.find((link) => link.polarity === "-")
|
||||||
|
return Math.max(
|
||||||
|
0,
|
||||||
|
rule.factor *
|
||||||
|
((level ? valueOf(level.source) : 0) - (threshold ? valueOf(threshold.source) : 0)),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,11 +42,15 @@ export interface Position {
|
|||||||
* (→ exponential growth/decay)
|
* (→ exponential growth/decay)
|
||||||
* - `gap` — `factor × (level − target)`, where the `+` input is the
|
* - `gap` — `factor × (level − target)`, where the `+` input is the
|
||||||
* level and the `−` input the target. (→ goal-seeking)
|
* level and the `−` input the target. (→ goal-seeking)
|
||||||
|
* - `overflow` — `max(0, factor × (level − threshold))`: a one-sided `gap`
|
||||||
|
* that only fires once the `+` level exceeds the `−`
|
||||||
|
* threshold. (→ a spillway / hard ceiling)
|
||||||
*/
|
*/
|
||||||
export type Rule =
|
export type Rule =
|
||||||
| { kind: "constant"; value: number }
|
| { kind: "constant"; value: number }
|
||||||
| { kind: "proportional"; factor: number }
|
| { kind: "proportional"; factor: number }
|
||||||
| { kind: "gap"; factor: number }
|
| { kind: "gap"; factor: number }
|
||||||
|
| { kind: "overflow"; factor: number }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The run parameters for a simulation: integrate from `start` to `stop` in steps
|
* The run parameters for a simulation: integrate from `start` to `stop` in steps
|
||||||
@@ -78,6 +82,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 +101,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 +114,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 +141,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