feat(editor): animate the canvas in step with the simulation
A simulation store owns one run and one playhead, so the chart and the canvas read off the same instant. While the panel is open, Stocks show a live value and a fill gauge, Flows and Converters their current value; a play/pause + scrubber drives the playhead (a wall-clock-paced clock in usePlayback), and a marker tracks it on the chart.
This commit is contained in:
@@ -26,6 +26,7 @@ import {
|
|||||||
} from "@vue-flow/core"
|
} from "@vue-flow/core"
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef } from "vue"
|
import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef } from "vue"
|
||||||
import { useAutosave } from "@/composables/useAutosave"
|
import { useAutosave } from "@/composables/useAutosave"
|
||||||
|
import { usePlayback } from "@/composables/usePlayback"
|
||||||
import { parseModel, serializeModel } from "@/model/io"
|
import { parseModel, serializeModel } from "@/model/io"
|
||||||
import { project } from "@/model/projection"
|
import { project } from "@/model/projection"
|
||||||
import { type Sample, SAMPLES } from "@/model/samples"
|
import { type Sample, SAMPLES } from "@/model/samples"
|
||||||
@@ -54,6 +55,10 @@ const edges = computed(() => graph.value.edges)
|
|||||||
// the Model and recomputes reactively while open.
|
// the Model and recomputes reactively while open.
|
||||||
const showResults = ref(false)
|
const showResults = ref(false)
|
||||||
|
|
||||||
|
// The playback clock that advances the playhead while a run is playing (mounted
|
||||||
|
// once here; the simulation store holds the state it drives).
|
||||||
|
usePlayback()
|
||||||
|
|
||||||
// Explicit shared id: useVueFlow() runs here in the parent setup, before
|
// Explicit shared id: useVueFlow() runs here in the parent setup, before
|
||||||
// <VueFlow> mounts. Pinning both to the same id guarantees they resolve to one
|
// <VueFlow> mounts. Pinning both to the same id guarantees they resolve to one
|
||||||
// store instance, so the event hooks below actually fire.
|
// store instance, so the event hooks below actually fire.
|
||||||
|
|||||||
@@ -8,17 +8,27 @@
|
|||||||
* It reads the source-of-truth Model straight from the store and recomputes
|
* It reads the source-of-truth Model straight from the store and recomputes
|
||||||
* reactively, so editing a rule and reopening re-runs. When the Model is not yet
|
* reactively, so editing a rule and reopening re-runs. When the Model is not yet
|
||||||
* sim-ready, it shows what is missing instead of a plot — the gap list doubles as
|
* sim-ready, it shows what is missing instead of a plot — the gap list doubles as
|
||||||
* a checklist for bringing a diagram to life. No charting dependency: the plot is
|
* a checklist for bringing a diagram to life. The plot itself is a uPlot canvas
|
||||||
* a hand-built SVG (the same lean-substrate choice as the rest of the editor).
|
* (see SimChart) — a small, fast time-series library that earns its keep with a
|
||||||
|
* real time axis and hover-to-read values the hand-built SVG couldn't give.
|
||||||
*/
|
*/
|
||||||
import { computed } from "vue"
|
import type uPlot from "uplot"
|
||||||
import { checkSimReady, simulate } from "@/model/simulation"
|
import { computed, onBeforeUnmount, onMounted } from "vue"
|
||||||
|
import SimChart from "./SimChart.vue"
|
||||||
|
import { checkSimReady } from "@/model/simulation"
|
||||||
import { DEFAULT_SIM_SPEC, type SimSpec, type StockNode } from "@/model/types"
|
import { DEFAULT_SIM_SPEC, type SimSpec, type StockNode } from "@/model/types"
|
||||||
import { useModelStore } from "@/store/model"
|
import { useModelStore } from "@/store/model"
|
||||||
|
import { useSimulationStore } from "@/store/simulation"
|
||||||
|
|
||||||
defineEmits<{ close: [] }>()
|
defineEmits<{ close: [] }>()
|
||||||
|
|
||||||
const store = useModelStore()
|
const store = useModelStore()
|
||||||
|
const sim = useSimulationStore()
|
||||||
|
|
||||||
|
// While the panel is open the canvas shows live values; closing it returns the
|
||||||
|
// canvas to a plain diagram.
|
||||||
|
onMounted(() => sim.enable())
|
||||||
|
onBeforeUnmount(() => sim.disable())
|
||||||
|
|
||||||
const problems = computed(() => checkSimReady(store.model))
|
const problems = computed(() => checkSimReady(store.model))
|
||||||
|
|
||||||
@@ -34,48 +44,18 @@ function onSpec(key: keyof SimSpec, event: Event): void {
|
|||||||
/** Distinct, legible track colours; cycled if a Model has more Stocks than these. */
|
/** Distinct, legible track colours; cycled if a Model has more Stocks than these. */
|
||||||
const COLORS = ["#2563eb", "#dc2626", "#16a34a", "#d97706", "#7c3aed", "#0891b2"]
|
const COLORS = ["#2563eb", "#dc2626", "#16a34a", "#d97706", "#7c3aed", "#0891b2"]
|
||||||
|
|
||||||
// SVG canvas in its own coordinate space; it stretches to the panel width, and
|
// Stocks only — the system's memory, and so the trajectories worth watching. The
|
||||||
// non-scaling strokes keep lines crisp under that non-uniform scale.
|
// run comes from the simulation store, so the chart and the canvas share one index.
|
||||||
const W = 600
|
|
||||||
const H = 200
|
|
||||||
const PAD = 12
|
|
||||||
|
|
||||||
const chart = computed(() => {
|
const chart = computed(() => {
|
||||||
if (problems.value.length > 0) return null
|
const run = sim.run
|
||||||
const run = simulate(store.model)
|
if (!run) return null
|
||||||
const stocks = store.model.nodes.filter((n): n is StockNode => n.kind === "stock")
|
const stocks = store.model.nodes.filter((n): n is StockNode => n.kind === "stock")
|
||||||
const all = stocks.flatMap((s) => run.series.get(s.id) ?? [])
|
if (stocks.length === 0 || run.times.length < 2) return null
|
||||||
if (all.length === 0 || run.times.length < 2) return null
|
|
||||||
|
|
||||||
let min = Math.min(...all)
|
const data = [run.times, ...stocks.map((s) => run.series.get(s.id) ?? [])] as uPlot.AlignedData
|
||||||
let max = Math.max(...all)
|
const series = stocks.map((s, i) => ({ label: s.name, stroke: COLORS[i % COLORS.length] }))
|
||||||
if (min === max) {
|
return { data, series, diverged: run.diverged }
|
||||||
// A flat trajectory: pad so the line lands mid-panel instead of on an edge.
|
|
||||||
min -= 1
|
|
||||||
max += 1
|
|
||||||
}
|
|
||||||
const n = run.times.length
|
|
||||||
const x = (i: number) => PAD + (i / (n - 1)) * (W - 2 * PAD)
|
|
||||||
const y = (v: number) => PAD + (1 - (v - min) / (max - min)) * (H - 2 * PAD)
|
|
||||||
|
|
||||||
const lines = stocks.map((s, i) => {
|
|
||||||
const values = run.series.get(s.id) ?? []
|
|
||||||
return {
|
|
||||||
id: s.id,
|
|
||||||
name: s.name,
|
|
||||||
color: COLORS[i % COLORS.length],
|
|
||||||
points: values.map((v, j) => `${x(j).toFixed(1)},${y(v).toFixed(1)}`).join(" "),
|
|
||||||
last: values[values.length - 1] ?? 0,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return { lines, min, max, t0: run.times[0], t1: run.times[n - 1], diverged: run.diverged }
|
|
||||||
})
|
})
|
||||||
|
|
||||||
/** Compact numbers for axis ticks and the legend (e.g. 7039.99 → "7040"). */
|
|
||||||
function fmt(value: number): string {
|
|
||||||
return Math.abs(value) >= 100 ? value.toFixed(0) : value.toFixed(2)
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -132,38 +112,33 @@ function fmt(value: number): string {
|
|||||||
smaller factor, or a Balancing loop to rein it in.
|
smaller factor, or a Balancing loop to rein it in.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Sim-ready: the plot, with a legend of final values. -->
|
<!-- Sim-ready: the plot, plus a transport that drives the playhead. Hover a
|
||||||
<div v-if="chart" class="mt-2 flex items-stretch gap-3">
|
point to read values; press play to watch the canvas animate in step. -->
|
||||||
<svg
|
<template v-if="chart">
|
||||||
:viewBox="`0 0 ${W} ${H}`"
|
<SimChart class="mt-2" :data="chart.data" :series="chart.series" :marker="sim.currentTime" />
|
||||||
preserveAspectRatio="none"
|
<div class="mt-2 flex items-center gap-2">
|
||||||
class="h-40 flex-1 rounded-md bg-base-200/40"
|
<button
|
||||||
role="img"
|
type="button"
|
||||||
aria-label="Stock values over time"
|
class="btn btn-primary btn-xs w-8"
|
||||||
>
|
:aria-label="sim.playing ? 'Pause' : 'Play'"
|
||||||
<text :x="PAD" :y="PAD" class="fill-base-content/40 text-[10px]">{{ fmt(chart.max) }}</text>
|
@click="sim.toggle()"
|
||||||
<text :x="PAD" :y="H - PAD / 2" class="fill-base-content/40 text-[10px]">
|
>
|
||||||
{{ fmt(chart.min) }}
|
{{ sim.playing ? "❚❚" : "▶" }}
|
||||||
</text>
|
</button>
|
||||||
<polyline
|
<input
|
||||||
v-for="line in chart.lines"
|
type="range"
|
||||||
:key="line.id"
|
class="range range-xs flex-1"
|
||||||
:points="line.points"
|
min="0"
|
||||||
fill="none"
|
:max="Math.max(0, sim.frameCount - 1)"
|
||||||
:stroke="line.color"
|
:value="sim.playhead"
|
||||||
stroke-width="2"
|
aria-label="Playhead"
|
||||||
stroke-linejoin="round"
|
@input="sim.seek(Number(($event.target as HTMLInputElement).value))"
|
||||||
vector-effect="non-scaling-stroke"
|
|
||||||
/>
|
/>
|
||||||
</svg>
|
<span class="w-14 text-right font-mono text-xs tabular-nums text-base-content/60">
|
||||||
<ul class="flex w-44 flex-col gap-1 self-center text-xs">
|
t = {{ sim.currentTime ?? 0 }}
|
||||||
<li v-for="line in chart.lines" :key="line.id" class="flex items-center gap-2">
|
</span>
|
||||||
<span class="size-2.5 shrink-0 rounded-full" :style="{ backgroundColor: line.color }" />
|
</div>
|
||||||
<span class="truncate">{{ line.name }}</span>
|
</template>
|
||||||
<span class="ml-auto font-mono text-base-content/60">{{ fmt(line.last) }}</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Not sim-ready: what to fill in to bring it to life. -->
|
<!-- Not sim-ready: what to fill in to bring it to life. -->
|
||||||
<div v-else class="mt-2 text-sm">
|
<div v-else class="mt-2 text-sm">
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { computed } from "vue"
|
|||||||
import { useNodeLoopRing } from "@/composables/useLoopHighlight"
|
import { useNodeLoopRing } from "@/composables/useLoopHighlight"
|
||||||
import { HANDLE_IN, HANDLE_OUT, type NodeData } from "@/model/projection"
|
import { HANDLE_IN, HANDLE_OUT, type NodeData } from "@/model/projection"
|
||||||
import type { ConverterNode } from "@/model/types"
|
import type { ConverterNode } from "@/model/types"
|
||||||
|
import { useSimulationStore } from "@/store/simulation"
|
||||||
|
import { formatValue } from "./format"
|
||||||
import NodeLabel from "./NodeLabel.vue"
|
import NodeLabel from "./NodeLabel.vue"
|
||||||
|
|
||||||
const props = defineProps<NodeProps<NodeData>>()
|
const props = defineProps<NodeProps<NodeData>>()
|
||||||
@@ -16,6 +18,9 @@ const props = defineProps<NodeProps<NodeData>>()
|
|||||||
// The projection guarantees a converter-typed node here.
|
// The projection guarantees a converter-typed node here.
|
||||||
const converter = computed(() => props.data.node as ConverterNode)
|
const converter = computed(() => props.data.node as ConverterNode)
|
||||||
const loopRing = useNodeLoopRing(props.id)
|
const loopRing = useNodeLoopRing(props.id)
|
||||||
|
|
||||||
|
const sim = useSimulationStore()
|
||||||
|
const value = computed(() => sim.valueAt(props.id))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -25,6 +30,12 @@ const loopRing = useNodeLoopRing(props.id)
|
|||||||
:class="[props.selected ? 'border-primary' : 'border-base-300', loopRing]"
|
:class="[props.selected ? 'border-primary' : 'border-base-300', loopRing]"
|
||||||
>
|
>
|
||||||
<Handle :id="HANDLE_IN" type="target" :position="Position.Left" />
|
<Handle :id="HANDLE_IN" type="target" :position="Position.Left" />
|
||||||
|
<span
|
||||||
|
v-if="value !== null"
|
||||||
|
class="pointer-events-none absolute inset-0 flex items-center justify-center font-mono text-xs tabular-nums text-base-content/70"
|
||||||
|
>
|
||||||
|
{{ formatValue(value) }}
|
||||||
|
</span>
|
||||||
<Handle :id="HANDLE_OUT" type="source" :position="Position.Right" />
|
<Handle :id="HANDLE_OUT" type="source" :position="Position.Right" />
|
||||||
</div>
|
</div>
|
||||||
<NodeLabel :node-id="props.id" :name="converter.name" />
|
<NodeLabel :node-id="props.id" :name="converter.name" />
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { computed } from "vue"
|
|||||||
import { useNodeLoopRing } from "@/composables/useLoopHighlight"
|
import { useNodeLoopRing } from "@/composables/useLoopHighlight"
|
||||||
import { HANDLE_IN, HANDLE_OUT, type NodeData } from "@/model/projection"
|
import { HANDLE_IN, HANDLE_OUT, type NodeData } from "@/model/projection"
|
||||||
import type { FlowNode } from "@/model/types"
|
import type { FlowNode } from "@/model/types"
|
||||||
|
import { useSimulationStore } from "@/store/simulation"
|
||||||
|
import { formatValue } from "./format"
|
||||||
import NodeLabel from "./NodeLabel.vue"
|
import NodeLabel from "./NodeLabel.vue"
|
||||||
|
|
||||||
const props = defineProps<NodeProps<NodeData>>()
|
const props = defineProps<NodeProps<NodeData>>()
|
||||||
@@ -18,6 +20,9 @@ const props = defineProps<NodeProps<NodeData>>()
|
|||||||
// The projection guarantees a flow-typed node here.
|
// The projection guarantees a flow-typed node here.
|
||||||
const flow = computed(() => props.data.node as FlowNode)
|
const flow = computed(() => props.data.node as FlowNode)
|
||||||
const loopRing = useNodeLoopRing(props.id)
|
const loopRing = useNodeLoopRing(props.id)
|
||||||
|
|
||||||
|
const sim = useSimulationStore()
|
||||||
|
const value = computed(() => sim.valueAt(props.id))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -40,5 +45,8 @@ const loopRing = useNodeLoopRing(props.id)
|
|||||||
<Handle :id="HANDLE_OUT" type="source" :position="Position.Right" />
|
<Handle :id="HANDLE_OUT" type="source" :position="Position.Right" />
|
||||||
</div>
|
</div>
|
||||||
<NodeLabel :node-id="props.id" :name="flow.name" />
|
<NodeLabel :node-id="props.id" :name="flow.name" />
|
||||||
|
<div v-if="value !== null" class="font-mono text-xs tabular-nums text-base-content/70">
|
||||||
|
{{ formatValue(value) }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { computed } from "vue"
|
|||||||
import { useNodeLoopRing } from "@/composables/useLoopHighlight"
|
import { useNodeLoopRing } from "@/composables/useLoopHighlight"
|
||||||
import { HANDLE_IN, HANDLE_OUT, type NodeData } from "@/model/projection"
|
import { HANDLE_IN, HANDLE_OUT, type NodeData } from "@/model/projection"
|
||||||
import type { StockNode } from "@/model/types"
|
import type { StockNode } from "@/model/types"
|
||||||
|
import { useSimulationStore } from "@/store/simulation"
|
||||||
|
import { formatValue } from "./format"
|
||||||
import NodeLabel from "./NodeLabel.vue"
|
import NodeLabel from "./NodeLabel.vue"
|
||||||
|
|
||||||
const props = defineProps<NodeProps<NodeData>>()
|
const props = defineProps<NodeProps<NodeData>>()
|
||||||
@@ -16,15 +18,36 @@ const props = defineProps<NodeProps<NodeData>>()
|
|||||||
// The projection guarantees a stock-typed node here.
|
// The projection guarantees a stock-typed node here.
|
||||||
const stock = computed(() => props.data.node as StockNode)
|
const stock = computed(() => props.data.node as StockNode)
|
||||||
const loopRing = useNodeLoopRing(props.id)
|
const loopRing = useNodeLoopRing(props.id)
|
||||||
|
|
||||||
|
// Live simulation value at the playhead (null when no run is engaged). A Stock is
|
||||||
|
// the system's memory, so it also shows a fill gauge — its level over its peak.
|
||||||
|
const sim = useSimulationStore()
|
||||||
|
const value = computed(() => sim.valueAt(props.id))
|
||||||
|
const fill = computed(() => sim.fill(props.id))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="min-w-24 rounded-md border-2 bg-base-100 px-4 py-3 text-center shadow-sm transition-colors"
|
class="relative min-w-24 rounded-md border-2 bg-base-100 px-4 py-3 text-center shadow-sm transition-colors"
|
||||||
:class="[props.selected ? 'border-primary' : 'border-base-300', loopRing]"
|
:class="[props.selected ? 'border-primary' : 'border-base-300', loopRing]"
|
||||||
>
|
>
|
||||||
|
<!-- Fill gauge: a bottom-anchored bar, behind the label, inset within the border. -->
|
||||||
|
<div
|
||||||
|
v-if="value !== null"
|
||||||
|
class="pointer-events-none absolute inset-[2px] z-0 flex flex-col justify-end"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="rounded-b bg-primary/15 transition-[height]"
|
||||||
|
:style="{ height: fill * 100 + '%' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<Handle :id="HANDLE_IN" type="target" :position="Position.Left" />
|
<Handle :id="HANDLE_IN" type="target" :position="Position.Left" />
|
||||||
<NodeLabel :node-id="props.id" :name="stock.name" />
|
<div class="relative z-10">
|
||||||
|
<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">
|
||||||
|
{{ formatValue(value) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<Handle :id="HANDLE_OUT" type="source" :position="Position.Right" />
|
<Handle :id="HANDLE_OUT" type="source" :position="Position.Right" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
4
src/components/nodes/format.ts
Normal file
4
src/components/nodes/format.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
/** Compact value for the live readout on a node (7039.99 → "7040", 0.42 → "0.42"). */
|
||||||
|
export function formatValue(value: number): string {
|
||||||
|
return Math.abs(value) >= 100 ? value.toFixed(0) : value.toFixed(2)
|
||||||
|
}
|
||||||
49
src/composables/usePlayback.ts
Normal file
49
src/composables/usePlayback.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* Playback clock — the wall-clock that advances the simulation playhead while
|
||||||
|
* `playing`. It lives in a composable (mounted once, in the Editor) because it
|
||||||
|
* needs `requestAnimationFrame` and component teardown; the simulation store
|
||||||
|
* stays pure state + actions.
|
||||||
|
*
|
||||||
|
* A run plays over a fixed wall-clock window regardless of how many samples it
|
||||||
|
* has, so a 40-step model and a 480-step one feel the same speed. Elapsed time is
|
||||||
|
* accumulated and drained in whole frames, so a slow tab catches up rather than
|
||||||
|
* drifting.
|
||||||
|
*/
|
||||||
|
import { onBeforeUnmount, watch } from "vue"
|
||||||
|
import { useSimulationStore } from "@/store/simulation"
|
||||||
|
|
||||||
|
/** Target wall-clock duration for a whole run, in milliseconds. */
|
||||||
|
const RUN_DURATION_MS = 6000
|
||||||
|
|
||||||
|
export function usePlayback(): void {
|
||||||
|
const sim = useSimulationStore()
|
||||||
|
let raf = 0
|
||||||
|
let last = 0
|
||||||
|
let carry = 0
|
||||||
|
|
||||||
|
function frame(now: number): void {
|
||||||
|
if (!sim.playing) return
|
||||||
|
carry += now - last
|
||||||
|
last = now
|
||||||
|
const msPerFrame = Math.max(16, RUN_DURATION_MS / Math.max(1, sim.frameCount))
|
||||||
|
while (carry >= msPerFrame && sim.playing) {
|
||||||
|
carry -= msPerFrame
|
||||||
|
sim.tick()
|
||||||
|
}
|
||||||
|
raf = requestAnimationFrame(frame)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => sim.playing,
|
||||||
|
(playing) => {
|
||||||
|
cancelAnimationFrame(raf)
|
||||||
|
if (playing) {
|
||||||
|
last = performance.now()
|
||||||
|
carry = 0
|
||||||
|
raf = requestAnimationFrame(frame)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
onBeforeUnmount(() => cancelAnimationFrame(raf))
|
||||||
|
}
|
||||||
140
src/store/simulation.ts
Normal file
140
src/store/simulation.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
/**
|
||||||
|
* Simulation store (phase 2) — the single source of *playback* truth, the way
|
||||||
|
* the model store is the single source of structural truth. It owns one run and
|
||||||
|
* one playhead, so the behaviour chart and the canvas animate off the same index:
|
||||||
|
* drag the scrubber and every Stock on the canvas jumps to that instant.
|
||||||
|
*
|
||||||
|
* The run is derived from the Model (recomputed when the Model changes, like the
|
||||||
|
* loop detector), so editing a rule and reopening re-runs. Playback itself — the
|
||||||
|
* clock that advances the playhead — lives in a composable (`usePlayback`), since
|
||||||
|
* timers need component lifecycle; this store only holds state and pure actions.
|
||||||
|
*/
|
||||||
|
import { defineStore } from "pinia"
|
||||||
|
import { computed, ref, watch } from "vue"
|
||||||
|
import { checkSimReady, type Run, simulate } from "@/model/simulation"
|
||||||
|
import { useModelStore } from "./model"
|
||||||
|
|
||||||
|
export const useSimulationStore = defineStore("simulation", () => {
|
||||||
|
const modelStore = useModelStore()
|
||||||
|
|
||||||
|
/** A Model is runnable only once every Stock has a value and every rate a rule. */
|
||||||
|
const ready = computed(() => checkSimReady(modelStore.model).length === 0)
|
||||||
|
|
||||||
|
/** The current run, or null when the Model isn't runnable. Recomputes on edit. */
|
||||||
|
const run = computed<Run | null>(() => {
|
||||||
|
if (!ready.value) return null
|
||||||
|
try {
|
||||||
|
return simulate(modelStore.model)
|
||||||
|
} catch {
|
||||||
|
// checkSimReady already rejects algebraic loops; this is belt-and-braces.
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const frameCount = computed(() => run.value?.times.length ?? 0)
|
||||||
|
|
||||||
|
/** Whether the canvas should show live values: the panel is open and a run exists. */
|
||||||
|
const enabled = ref(false)
|
||||||
|
const playing = ref(false)
|
||||||
|
const playhead = ref(0)
|
||||||
|
|
||||||
|
const active = computed(() => enabled.value && frameCount.value > 0)
|
||||||
|
const atEnd = computed(() => playhead.value >= frameCount.value - 1)
|
||||||
|
const currentTime = computed(() => run.value?.times[playhead.value] ?? null)
|
||||||
|
|
||||||
|
/** Per-element peak magnitude across the run, for the Stock fill gauge. */
|
||||||
|
const peaks = computed(() => {
|
||||||
|
const map = new Map<string, number>()
|
||||||
|
const r = run.value
|
||||||
|
if (r) {
|
||||||
|
for (const [id, series] of r.series) {
|
||||||
|
let max = 0
|
||||||
|
for (const v of series) if (Number.isFinite(v)) max = Math.max(max, Math.abs(v))
|
||||||
|
map.set(id, max)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
// Keep the playhead in range when the run shrinks (e.g. a shorter stop time).
|
||||||
|
watch(frameCount, (n) => {
|
||||||
|
if (playhead.value > n - 1) playhead.value = Math.max(0, n - 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
/** A node's value at the current playhead, or null when there's nothing to show. */
|
||||||
|
function valueAt(id: string): number | null {
|
||||||
|
if (!active.value) return null
|
||||||
|
const value = run.value?.series.get(id)?.[playhead.value]
|
||||||
|
return value === undefined || !Number.isFinite(value) ? null : value
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A Stock's fill level in 0…1 (value over its peak), for the gauge. */
|
||||||
|
function fill(id: string): number {
|
||||||
|
const value = valueAt(id)
|
||||||
|
const peak = peaks.value.get(id) ?? 0
|
||||||
|
if (value === null || peak <= 0) return 0
|
||||||
|
return Math.min(1, Math.max(0, value / peak))
|
||||||
|
}
|
||||||
|
|
||||||
|
function seek(index: number): void {
|
||||||
|
playhead.value = Math.min(Math.max(0, Math.round(index)), Math.max(0, frameCount.value - 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
function play(): void {
|
||||||
|
if (frameCount.value === 0) return
|
||||||
|
if (atEnd.value) playhead.value = 0 // replay from the start
|
||||||
|
playing.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function pause(): void {
|
||||||
|
playing.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle(): void {
|
||||||
|
if (playing.value) pause()
|
||||||
|
else play()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Advance one frame; stop at the end. Called by the playback clock. */
|
||||||
|
function tick(): void {
|
||||||
|
if (atEnd.value) {
|
||||||
|
playing.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
playhead.value++
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Engage playback (panel opened): show values, start from the beginning. */
|
||||||
|
function enable(): void {
|
||||||
|
enabled.value = true
|
||||||
|
playhead.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Disengage (panel closed): the canvas returns to a plain diagram. */
|
||||||
|
function disable(): void {
|
||||||
|
enabled.value = false
|
||||||
|
playing.value = false
|
||||||
|
playhead.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
run,
|
||||||
|
ready,
|
||||||
|
frameCount,
|
||||||
|
enabled,
|
||||||
|
playing,
|
||||||
|
playhead,
|
||||||
|
active,
|
||||||
|
atEnd,
|
||||||
|
currentTime,
|
||||||
|
valueAt,
|
||||||
|
fill,
|
||||||
|
seek,
|
||||||
|
play,
|
||||||
|
pause,
|
||||||
|
toggle,
|
||||||
|
tick,
|
||||||
|
enable,
|
||||||
|
disable,
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user