Files
meadows/src/components/ResultsPanel.vue
Julien Calixte 59e3aafa63 feat(editor): dock the sim panel and refit the canvas to the rest
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.
2026-06-21 12:41:57 +02:00

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>