Compare commits

...

7 Commits

Author SHA1 Message Date
Julien Calixte
d570425402 fix(editor): align the playhead with the chart's left gutter
valToPos returns CSS pixels relative to the plot area, but the overlay
sits on the canvas, so the playhead was short by the y-axis width and
started before x=0.
2026-06-20 15:01:21 +02:00
Julien Calixte
2c7ec6a6ec style: recolour the favicon to the #ff9ff3 primary 2026-06-20 14:58:34 +02:00
Julien Calixte
ef0cc2878b fix(editor): stop the t-readout wrapping so playback doesn't reflow
The fixed-width time readout wrapped to two lines for fractional times (dt 0.25
→ 't = 70.25'), growing the panel a row each frame and reflowing the whole
panel — the real flicker. Pin its width and forbid wrapping.
2026-06-20 14:56:42 +02:00
Julien Calixte
f38cf49f25 style: force the primary colour to #ff9ff3 across every theme
The default (no data-theme) resolves to DaisyUI's dark theme, whose
:root:not([data-theme]) selector outranks a custom light theme — so the
previous light-only override never reached buttons in dark mode. Force it with
an !important :root override that wins regardless of theme.
2026-06-20 14:53:41 +02:00
Julien Calixte
d5cda95c3e fix(editor): stop the behaviour chart flickering during playback
The playhead was a canvas line redrawn by redrawing the whole uPlot chart on
every frame, which flickers. Draw it as a DOM overlay that slides over the
canvas instead, so the canvas is only redrawn when the data actually changes.
2026-06-20 14:47:08 +02:00
Julien Calixte
dc30e5f890 style: set the primary theme colour to #2e86de 2026-06-20 14:47:08 +02:00
Julien Calixte
382fdddc68 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.
2026-06-20 14:47:08 +02:00
10 changed files with 111 additions and 44 deletions

View File

@@ -4,7 +4,7 @@
height="24" height="24"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="#16A34A" stroke="#ff9ff3"
stroke-width="2" stroke-width="2"
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"

Before

Width:  |  Height:  |  Size: 550 B

After

Width:  |  Height:  |  Size: 550 B

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

@@ -134,7 +134,9 @@ const chart = computed(() => {
aria-label="Playhead" aria-label="Playhead"
@input="sim.seek(Number(($event.target as HTMLInputElement).value))" @input="sim.seek(Number(($event.target as HTMLInputElement).value))"
/> />
<span class="w-14 text-right font-mono text-xs tabular-nums text-base-content/60"> <span
class="w-24 shrink-0 whitespace-nowrap text-right font-mono text-xs tabular-nums text-base-content/60"
>
t = {{ sim.currentTime ?? 0 }} t = {{ sim.currentTime ?? 0 }}
</span> </span>
</div> </div>

View File

@@ -43,6 +43,27 @@ let observer: ResizeObserver | undefined
/** Signature of the current plot's tracks; a change means the shape changed. */ /** Signature of the current plot's tracks; a change means the shape changed. */
let builtSig = "" let builtSig = ""
// Playhead as a DOM overlay (CSS px), not a canvas line: a moving line drawn by
// redrawing the whole uPlot canvas every frame flickers, so the line slides over
// the canvas instead and the canvas is only redrawn when the data changes.
const markerX = ref<number | null>(null)
const markerTop = ref(0)
const markerHeight = ref(0)
function syncMarker(): void {
if (!plot || props.marker === null) {
markerX.value = null
return
}
// bbox is in device pixels; the overlay is laid out in CSS pixels.
const ratio = window.devicePixelRatio || 1
// valToPos (CSS px) is relative to the plot area's left edge, but the overlay
// is positioned against the canvas, so add the left gutter (the y-axis width).
markerX.value = plot.bbox.left / ratio + plot.valToPos(props.marker, "x")
markerTop.value = plot.bbox.top / ratio
markerHeight.value = plot.bbox.height / ratio
}
/** Compact axis/legend numbers (7039.99 → "7040", 0.42 → "0.42", idle → "--"). */ /** Compact axis/legend numbers (7039.99 → "7040", 0.42 → "0.42", idle → "--"). */
function fmt(value: number | null): string { function fmt(value: number | null): string {
if (value === null) return "--" if (value === null) return "--"
@@ -76,26 +97,6 @@ function options(width: number): uPlot.Options {
height: props.height, height: props.height,
cursor: { y: false }, cursor: { y: false },
scales: { x: { time: false } }, scales: { x: { time: false } },
// Playhead: a vertical line at the current simulation time, so the chart and
// the animating canvas read off the same instant. Drawn after the series, and
// redrawn whenever `marker` changes (see the watch below).
hooks: {
draw: [
(u: uPlot): void => {
if (props.marker === null) return
const x = Math.round(u.valToPos(props.marker, "x", true))
const { ctx } = u
ctx.save()
ctx.beginPath()
ctx.lineWidth = 1
ctx.strokeStyle = themeColor("--color-primary", "#2563eb")
ctx.moveTo(x, u.bbox.top)
ctx.lineTo(x, u.bbox.top + u.bbox.height)
ctx.stroke()
ctx.restore()
},
],
},
series: [ series: [
{}, {},
...props.series.map( ...props.series.map(
@@ -123,17 +124,25 @@ function build(): void {
function render(): void { function render(): void {
if (!plot || trackSig() !== builtSig) build() if (!plot || trackSig() !== builtSig) build()
else plot.setData(props.data) else plot.setData(props.data)
syncMarker() // the plot area may have shifted; keep the overlay aligned
} }
onMounted(() => { onMounted(() => {
build() build()
observer = new ResizeObserver(() => { observer = new ResizeObserver(() => {
if (plot && root.value) plot.setSize({ width: root.value.clientWidth, height: props.height }) if (plot && root.value) {
plot.setSize({ width: root.value.clientWidth, height: props.height })
syncMarker()
}
}) })
if (root.value) observer.observe(root.value) if (root.value) observer.observe(root.value)
syncMarker()
// The canvas paints before the web font loads and won't repaint on its own, so // The canvas paints before the web font loads and won't repaint on its own, so
// axis labels would stick to the fallback. Redraw once the font is ready. // axis labels would stick to the fallback. Redraw once the font is ready.
document.fonts?.ready.then(() => plot?.redraw()) document.fonts?.ready.then(() => {
plot?.redraw()
syncMarker()
})
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
@@ -142,15 +151,18 @@ onBeforeUnmount(() => {
}) })
watch(() => props.data, render) watch(() => props.data, render)
// The playhead moves without the data changing, so nudge a redraw on its own. // The playhead just slides the overlay — no canvas redraw, so playback stays smooth.
watch( watch(() => props.marker, syncMarker)
() => props.marker,
() => plot?.redraw(),
)
</script> </script>
<template> <template>
<div ref="root" class="w-full" /> <div ref="root" class="relative w-full">
<div
v-if="markerX !== null"
class="pointer-events-none absolute w-px bg-primary"
:style="{ left: `${markerX}px`, top: `${markerTop}px`, height: `${markerHeight}px` }"
/>
</div>
</template> </template>
<style scoped> <style scoped>

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,

View File

@@ -9,7 +9,16 @@
@plugin "daisyui/theme" { @plugin "daisyui/theme" {
name: "light"; name: "light";
default: true; default: true;
--color-primary: #16a34a; --color-primary: #ff9ff3;
}
/* DaisyUI's default (no data-theme) resolves to its *dark* theme via
prefers-color-scheme, with a selector (`:root:not([data-theme])`) that outranks
a custom light theme's `:where(:root)` — so overriding only the light theme
never reached the buttons. Force the brand primary across every theme. */
:root {
--color-primary: #ff9ff3 !important;
--color-primary-content: #3d1f3a !important;
} }
@theme { @theme {