feat(editor): give each Stock a unit

A Stock can carry a display unit (°C, people, $, …): a new optional field, an
inspector text input, validation + round-trip in io, and a readout beside the
live value on the canvas. Equip the demonstrative samples (Water L, Balance $,
Coffee °C, Population/Epidemic people, Yeast cells). Also bumps the fill gauge
opacity for a more vivid level.
This commit is contained in:
Julien Calixte
2026-06-20 14:47:08 +02:00
parent ed6f011e69
commit 382fdddc68
6 changed files with 57 additions and 13 deletions

View File

@@ -62,6 +62,12 @@ function onInitial(event: Event): void {
if (Number.isFinite(n)) store.setInitialValue(el.id, n) if (Number.isFinite(n)) store.setInitialValue(el.id, n)
} }
function onUnit(event: Event): void {
const el = element.value
if (el?.kind !== "stock") return
store.setUnit(el.id, (event.target as HTMLInputElement).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
@@ -97,8 +103,9 @@ const RULE_HINT: Record<Rule["kind"], string> = {
<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>
</div> </div>
<!-- Stock: the quantity it starts from. --> <!-- Stock: the quantity it starts from, and its unit. -->
<label v-if="element.kind === 'stock'" class="mt-2 block"> <template v-if="element.kind === 'stock'">
<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"
@@ -108,6 +115,17 @@ const RULE_HINT: Record<Rule["kind"], string> = {
@change="onInitial" @change="onInitial"
/> />
</label> </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. --> <!-- Flow / Converter: pick a rule, then its number. -->
<template v-else> <template v-else>

View File

@@ -37,7 +37,7 @@ const fill = computed(() => sim.fill(props.id))
class="pointer-events-none absolute inset-[2px] z-0 flex flex-col justify-end" class="pointer-events-none absolute inset-[2px] z-0 flex flex-col justify-end"
> >
<div <div
class="rounded-b bg-primary/40 transition-[height]" class="rounded-b bg-primary/60 transition-[height]"
:style="{ height: fill * 100 + '%' }" :style="{ height: fill * 100 + '%' }"
/> />
</div> </div>
@@ -45,7 +45,8 @@ const fill = computed(() => sim.fill(props.id))
<div class="relative z-10"> <div class="relative z-10">
<NodeLabel :node-id="props.id" :name="stock.name" /> <NodeLabel :node-id="props.id" :name="stock.name" />
<div v-if="value !== null" class="mt-0.5 font-mono text-xs tabular-nums text-base-content/70"> <div v-if="value !== null" class="mt-0.5 font-mono text-xs tabular-nums text-base-content/70">
{{ formatValue(value) }} {{ formatValue(value)
}}<span v-if="stock.unit" class="ml-0.5 text-base-content/50">{{ stock.unit }}</span>
</div> </div>
</div> </div>
<Handle :id="HANDLE_OUT" type="source" :position="Position.Right" /> <Handle :id="HANDLE_OUT" type="source" :position="Position.Right" />

View File

@@ -89,6 +89,9 @@ function nodeError(value: unknown, index: number): string | null {
if (kind === "stock" && value.initialValue !== undefined && !isFiniteNumber(value.initialValue)) { if (kind === "stock" && value.initialValue !== undefined && !isFiniteNumber(value.initialValue)) {
return `${at}.initialValue must be a finite number` return `${at}.initialValue must be a finite number`
} }
if (kind === "stock" && value.unit !== undefined && typeof value.unit !== "string") {
return `${at}.unit must be a string`
}
if ( if (
(kind === "flow" || kind === "converter") && (kind === "flow" || kind === "converter") &&
value.rule !== undefined && value.rule !== undefined &&

View File

@@ -77,6 +77,7 @@ function bathtub(): Model {
const source = makeCloud({ x: -280, y: 0 }) const source = makeCloud({ x: -280, y: 0 })
const water = makeStock({ x: 0, y: 0 }, "Water") const water = makeStock({ x: 0, y: 0 }, "Water")
water.initialValue = 20 water.initialValue = 20
water.unit = "L"
const sink = makeCloud({ x: 280, y: 0 }) const sink = makeCloud({ x: 280, y: 0 })
const filling = makeFlow( const filling = makeFlow(
midpoint(source.position, water.position), midpoint(source.position, water.position),
@@ -108,6 +109,7 @@ function savings(): Model {
const source = makeCloud({ x: -240, y: -80 }) const source = makeCloud({ x: -240, y: -80 })
const balance = makeStock({ x: 120, y: 40 }, "Balance") const balance = makeStock({ x: 120, y: 40 }, "Balance")
balance.initialValue = 1000 balance.initialValue = 1000
balance.unit = "$"
const interest = makeFlow( const interest = makeFlow(
midpoint(source.position, balance.position), midpoint(source.position, balance.position),
"interest", "interest",
@@ -133,6 +135,7 @@ function savings(): Model {
function coffee(): Model { function coffee(): Model {
const coffee = makeStock({ x: -200, y: 0 }, "Coffee") const coffee = makeStock({ x: -200, y: 0 }, "Coffee")
coffee.initialValue = 90 coffee.initialValue = 90
coffee.unit = "°C"
const sink = makeCloud({ x: 200, y: 0 }) const sink = makeCloud({ x: 200, y: 0 })
const cooling = makeFlow(midpoint(coffee.position, sink.position), "cooling", coffee.id, sink.id) const cooling = makeFlow(midpoint(coffee.position, sink.position), "cooling", coffee.id, sink.id)
// cooling = 0.1 × (Coffee room): the `+` input is the level, the `` the target. // cooling = 0.1 × (Coffee room): the `+` input is the level, the `` the target.
@@ -169,6 +172,7 @@ function population(): Model {
lifeExpectancy.rule = { kind: "constant", value: 70 } lifeExpectancy.rule = { kind: "constant", value: 70 }
const people = makeStock({ x: 0, y: 0 }, "Population") const people = makeStock({ x: 0, y: 0 }, "Population")
people.initialValue = 100 people.initialValue = 100
people.unit = "people"
const births = makeFlow({ x: -160, y: -160 }, "births", source.id, people.id) const births = makeFlow({ x: -160, y: -160 }, "births", source.id, people.id)
// births = fertility × Population (both `+` inputs): more people and higher // births = fertility × Population (both `+` inputs): more people and higher
// fertility, more births — the Reinforcing engine. // fertility, more births — the Reinforcing engine.
@@ -206,6 +210,7 @@ function limitsToGrowth(): Model {
const source = makeCloud({ x: -280, y: 0 }) const source = makeCloud({ x: -280, y: 0 })
const yeast = makeStock({ x: 40, y: 0 }, "Yeast") const yeast = makeStock({ x: 40, y: 0 }, "Yeast")
yeast.initialValue = 20 yeast.initialValue = 20
yeast.unit = "cells"
const growth = makeFlow(midpoint(source.position, yeast.position), "growth", source.id, yeast.id) const growth = makeFlow(midpoint(source.position, yeast.position), "growth", source.id, yeast.id)
// growth = 30% of Yeast (its `+` input): the Reinforcing engine. // growth = 30% of Yeast (its `+` input): the Reinforcing engine.
growth.rule = { kind: "proportional", factor: 0.3 } growth.rule = { kind: "proportional", factor: 0.3 }
@@ -321,10 +326,13 @@ function predatorPrey(): Model {
function epidemic(): Model { function epidemic(): Model {
const susceptible = makeStock({ x: -280, y: 0 }, "Susceptible") const susceptible = makeStock({ x: -280, y: 0 }, "Susceptible")
susceptible.initialValue = 990 susceptible.initialValue = 990
susceptible.unit = "people"
const infected = makeStock({ x: 0, y: 0 }, "Infected") const infected = makeStock({ x: 0, y: 0 }, "Infected")
infected.initialValue = 10 infected.initialValue = 10
infected.unit = "people"
const recovered = makeStock({ x: 280, y: 0 }, "Recovered") const recovered = makeStock({ x: 280, y: 0 }, "Recovered")
recovered.initialValue = 0 recovered.initialValue = 0
recovered.unit = "people"
const infection = makeFlow( const infection = makeFlow(
midpoint(susceptible.position, infected.position), midpoint(susceptible.position, infected.position),
"infection", "infection",

View File

@@ -76,6 +76,8 @@ export interface StockNode extends BaseNode {
name: string name: string
/** Initial accumulated quantity. Optional in the diagram phase; the simulator reads it. */ /** Initial accumulated quantity. Optional in the diagram phase; the simulator reads it. */
initialValue?: number initialValue?: number
/** Unit of the quantity, e.g. "°C", "people", "$" — shown beside the value (display only). */
unit?: string
} }
/** /**

View File

@@ -117,6 +117,17 @@ export const useModelStore = defineStore("model", () => {
else node.initialValue = value else node.initialValue = value
} }
/** Set (or clear) a Stock's display unit (e.g. "°C"). Empty clears it. No-op when unchanged. */
function setUnit(id: string, unit: string): void {
const node = findNode(id)
if (!node || node.kind !== "stock") return
const next = unit.trim() || undefined
if (node.unit === next) return
record()
if (next === undefined) delete node.unit
else node.unit = 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.
@@ -279,6 +290,7 @@ export const useModelStore = defineStore("model", () => {
moveNode, moveNode,
renameNode, renameNode,
setInitialValue, setInitialValue,
setUnit,
setRule, setRule,
setSimSpec, setSimSpec,
removeNode, removeNode,