The results panel floated over the canvas and hid the lower part of the diagram. Dock it beneath the canvas instead, and refit the view when it opens or closes so the whole diagram stays framed in the height that remains. The refit fires on Vue Flow's `dimensions` (post-measure), not on the toggle, so it frames against the settled size.
152 lines
5.8 KiB
Vue
152 lines
5.8 KiB
Vue
<script setup lang="ts">
|
|
/**
|
|
* Results panel (phase 2, ADR-0004) — what makes the Model *alive*: it runs the
|
|
* simulation and traces each Stock's value over time. Stocks are the system's
|
|
* memory, so their trajectories are the behaviour worth watching (the savings
|
|
* balance snowballs; the coffee settles toward room temperature).
|
|
*
|
|
* 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
|
|
* sim-ready, it shows what is missing instead of a plot — the gap list doubles as
|
|
* a checklist for bringing a diagram to life. The plot itself is a uPlot canvas
|
|
* (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 type uPlot from "uplot"
|
|
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 { useModelStore } from "@/store/model"
|
|
import { useSimulationStore } from "@/store/simulation"
|
|
|
|
defineEmits<{ close: [] }>()
|
|
|
|
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))
|
|
|
|
/** The current run window; falls back to the default until the Model carries one. */
|
|
const spec = computed<SimSpec>(() => store.model.sim ?? DEFAULT_SIM_SPEC)
|
|
|
|
function onSpec(key: keyof SimSpec, event: Event): void {
|
|
const n = Number((event.target as HTMLInputElement).value)
|
|
if (!Number.isFinite(n) || (key === "dt" && n <= 0)) return
|
|
store.setSimSpec({ ...spec.value, [key]: n })
|
|
}
|
|
|
|
/** Distinct, legible track colours; cycled if a Model has more Stocks than these. */
|
|
const COLORS = ["#2563eb", "#dc2626", "#16a34a", "#d97706", "#7c3aed", "#0891b2"]
|
|
|
|
// Stocks only — the system's memory, and so the trajectories worth watching. The
|
|
// run comes from the simulation store, so the chart and the canvas share one index.
|
|
const chart = computed(() => {
|
|
const run = sim.run
|
|
if (!run) return null
|
|
const stocks = store.model.nodes.filter((n): n is StockNode => n.kind === "stock")
|
|
if (stocks.length === 0 || run.times.length < 2) return null
|
|
|
|
const data = [run.times, ...stocks.map((s) => run.series.get(s.id) ?? [])] as uPlot.AlignedData
|
|
const series = stocks.map((s, i) => ({ label: s.name, stroke: COLORS[i % COLORS.length] }))
|
|
return { data, series, diverged: run.diverged }
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="shrink-0 border-t border-base-300 bg-base-100 p-3">
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-sm font-semibold">Behaviour over time</span>
|
|
<span class="truncate text-xs text-base-content/50">{{ store.model.name }}</span>
|
|
<div class="ml-auto flex items-center gap-2 text-xs text-base-content/60">
|
|
<label class="flex items-center gap-1">
|
|
from
|
|
<input
|
|
type="number"
|
|
class="input input-xs input-bordered w-16"
|
|
:value="spec.start"
|
|
@change="onSpec('start', $event)"
|
|
/>
|
|
</label>
|
|
<label class="flex items-center gap-1">
|
|
to
|
|
<input
|
|
type="number"
|
|
class="input input-xs input-bordered w-16"
|
|
:value="spec.stop"
|
|
@change="onSpec('stop', $event)"
|
|
/>
|
|
</label>
|
|
<label class="flex items-center gap-1">
|
|
step
|
|
<input
|
|
type="number"
|
|
step="any"
|
|
min="0"
|
|
class="input input-xs input-bordered w-16"
|
|
:value="spec.dt"
|
|
@change="onSpec('dt', $event)"
|
|
/>
|
|
</label>
|
|
<button
|
|
type="button"
|
|
class="btn btn-circle btn-ghost btn-xs"
|
|
aria-label="Close"
|
|
@click="$emit('close')"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Stopped early because values ran past what a float can hold. -->
|
|
<p v-if="chart?.diverged" class="mt-2 text-xs text-warning">
|
|
⚠ Values grew beyond what can be plotted and the run stopped early — try a smaller step, a
|
|
smaller factor, or a Balancing loop to rein it in.
|
|
</p>
|
|
|
|
<!-- Sim-ready: the plot, plus a transport that drives the playhead. Hover a
|
|
point to read values; press play to watch the canvas animate in step. -->
|
|
<template v-if="chart">
|
|
<SimChart class="mt-2" :data="chart.data" :series="chart.series" :marker="sim.currentTime" />
|
|
<div class="mt-2 flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
class="btn btn-primary btn-xs w-8"
|
|
:aria-label="sim.playing ? 'Pause' : 'Play'"
|
|
@click="sim.toggle()"
|
|
>
|
|
{{ sim.playing ? "❚❚" : "▶" }}
|
|
</button>
|
|
<input
|
|
type="range"
|
|
class="range range-xs flex-1"
|
|
min="0"
|
|
:max="Math.max(0, sim.frameCount - 1)"
|
|
:value="sim.playhead"
|
|
aria-label="Playhead"
|
|
@input="sim.seek(Number(($event.target as HTMLInputElement).value))"
|
|
/>
|
|
<span
|
|
class="w-24 shrink-0 whitespace-nowrap text-right font-mono text-xs tabular-nums text-base-content/60"
|
|
>
|
|
t = {{ sim.currentTime ?? 0 }}
|
|
</span>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Not sim-ready: what to fill in to bring it to life. -->
|
|
<div v-else class="mt-2 text-sm">
|
|
<p class="text-base-content/70">To bring this model to life, set:</p>
|
|
<ul class="mt-1 list-inside list-disc text-base-content/60">
|
|
<li v-for="(problem, i) in problems" :key="i">{{ problem }}</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</template>
|