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:
@@ -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>
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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 &&
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user