Compare commits

..

2 Commits

Author SHA1 Message Date
Julien Calixte
0c8a0c294f feat(editor): browse samples in a categorized modal gallery
Replace the cramped Samples dropdown with a modal that lays the gallery
out as a card grid, sectioned by tier, so the growing set stays scannable
and the learning order is visible at once.
2026-06-22 23:47:16 +02:00
Julien Calixte
37e3a1e89f feat(samples): tag each sample with a pedagogical category 2026-06-22 23:47:10 +02:00
3 changed files with 140 additions and 19 deletions

View File

@@ -29,7 +29,7 @@ import { useAutosave } from "@/composables/useAutosave"
import { usePlayback } from "@/composables/usePlayback"
import { parseModel, serializeModel } from "@/model/io"
import { project } from "@/model/projection"
import { type Sample, SAMPLES } from "@/model/samples"
import type { Sample } from "@/model/samples"
import { canConnect } from "@/model/validation"
import { useModelStore } from "@/store/model"
import { NODE_DND_MIME, type PlaceableKind } from "./palette-dnd"
@@ -38,6 +38,7 @@ import Inspector from "./Inspector.vue"
import LoopOverlay from "./LoopOverlay.vue"
import Palette from "./Palette.vue"
import ResultsPanel from "./ResultsPanel.vue"
import SampleBrowser from "./SampleBrowser.vue"
import InfoLinkEdge from "./edges/InfoLinkEdge.vue"
import PipeEdge from "./edges/PipeEdge.vue"
import CloudNode from "./nodes/CloudNode.vue"
@@ -55,6 +56,9 @@ const edges = computed(() => graph.value.edges)
// the Model and recomputes reactively while open.
const showResults = ref(false)
// The sample-browser modal (opened from the header "Samples" button).
const browserOpen = 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()
@@ -270,12 +274,12 @@ function onDragOver(event: DragEvent): void {
*/
function loadSample(sample: Sample): void {
if (store.nodeCount > 0 && !window.confirm(`Replace the current model with “${sample.title}”?`)) {
// Cancelled: leave the model untouched and the browser open to pick again.
return
}
fitAfterInit = true
store.setModel(sample.build())
// Close the DaisyUI dropdown (it stays open while the trigger keeps focus).
;(document.activeElement as HTMLElement | null)?.blur()
browserOpen.value = false
}
const fileInput = useTemplateRef<HTMLInputElement>("fileInput")
@@ -397,20 +401,7 @@ onBeforeUnmount(() => {
{{ store.nodeCount }} {{ store.nodeCount === 1 ? "element" : "elements" }}
</span>
<div class="ml-auto flex items-center gap-1">
<div class="dropdown dropdown-end">
<button tabindex="0" class="btn btn-ghost btn-sm">Samples</button>
<ul
tabindex="0"
class="dropdown-content menu z-40 mt-1 max-h-80 w-72 flex-nowrap gap-1 overflow-y-auto rounded-box border border-base-300 bg-base-100 p-2 shadow-lg"
>
<li v-for="sample in SAMPLES" :key="sample.title">
<button class="flex flex-col items-start gap-0.5" @click="loadSample(sample)">
<span class="font-medium">{{ sample.title }}</span>
<span class="text-xs text-base-content/60">{{ sample.blurb }}</span>
</button>
</li>
</ul>
</div>
<button class="btn btn-ghost btn-sm" @click="browserOpen = true">Samples</button>
<button
class="btn btn-primary btn-sm"
:class="{ 'btn-active': showResults }"
@@ -504,5 +495,7 @@ onBeforeUnmount(() => {
the canvas to the space above, where the diagram re-fits (see the
`dimensions` watcher in <script>). -->
<ResultsPanel v-if="showResults" @close="showResults = false" />
<SampleBrowser v-model:open="browserOpen" @select="loadSample" />
</div>
</template>

View File

@@ -0,0 +1,85 @@
<script setup lang="ts">
/**
* Sample browser — a modal gallery of the curated example models, laid out as a
* card grid sectioned by pedagogical tier (Primer → … → Mechanics & capstone).
* As the gallery grows this beats the old narrow dropdown: the whole set stays
* scannable in one scroll and the tiers show how the samples relate.
*
* Presentational only — it never touches the Model store. Picking a card emits
* `select`; the Editor owns the confirm/replace/fit logic (one place for it).
* Built on the native <dialog> so Esc, the backdrop click and focus-trapping come
* for free; `open` drives showModal()/close() and the native close event keeps the
* parent's state in sync.
*/
import { computed, useTemplateRef, watch } from "vue"
import { type Sample, SAMPLE_CATEGORIES, SAMPLES } from "@/model/samples"
const props = defineProps<{ open: boolean }>()
const emit = defineEmits<{ "update:open": [boolean]; select: [Sample] }>()
const dialog = useTemplateRef<HTMLDialogElement>("dialog")
// Mirror the `open` prop onto the native dialog (showModal gives us the focus trap
// and inert backdrop a plain div can't).
watch(
() => props.open,
(open) => {
const el = dialog.value
if (!el) return
if (open && !el.open) el.showModal()
else if (!open && el.open) el.close()
},
)
// Fired by Esc, the backdrop form, the ✕, or our own close() — fold them all into
// one update:open so the parent's state can't drift from the dialog's.
function onClose(): void {
if (props.open) emit("update:open", false)
}
// The categories in display order, each with its samples (SAMPLES is already in
// pedagogical order, so first-appearance order is preserved). Empty tiers drop out.
const sections = computed(() =>
SAMPLE_CATEGORIES.map((category) => ({
category,
samples: SAMPLES.filter((sample) => sample.category === category),
})).filter((section) => section.samples.length > 0),
)
</script>
<template>
<dialog ref="dialog" class="modal" @close="onClose">
<div class="modal-box w-11/12 max-w-3xl">
<div class="mb-4 flex items-center gap-2">
<h3 class="text-base font-semibold">Browse samples</h3>
<span class="text-xs text-base-content/50">{{ SAMPLES.length }} models</span>
<form method="dialog" class="ml-auto">
<button class="btn btn-circle btn-ghost btn-sm" aria-label="Close"></button>
</form>
</div>
<section v-for="section in sections" :key="section.category" class="mb-5 last:mb-0">
<h4 class="mb-2 text-xs font-semibold tracking-wide text-base-content/50 uppercase">
{{ section.category }}
</h4>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
<button
v-for="sample in section.samples"
:key="sample.title"
type="button"
class="flex flex-col gap-1 rounded-box border border-base-300 bg-base-100 p-3 text-left transition hover:border-primary hover:shadow-md"
@click="emit('select', sample)"
>
<span class="font-medium">{{ sample.title }}</span>
<span class="text-xs text-base-content/60">{{ sample.blurb }}</span>
</button>
</div>
</section>
</div>
<!-- Click outside the box to dismiss (submits the dialog fires @close). -->
<form method="dialog" class="modal-backdrop">
<button aria-label="Close">close</button>
</form>
</dialog>
</template>

View File

@@ -108,10 +108,31 @@ import {
type SimSpec,
} from "./types"
/** A loadable example: a title and one-line blurb for the menu, plus a builder. */
/**
* The pedagogical tier a sample belongs to. Tiers run simplest-first and double as
* the section order in the sample browser (see SAMPLE_CATEGORIES).
*/
export type SampleCategory =
| "Primer"
| "Classics"
| "System traps"
| "Dynamic edge"
| "Mechanics & capstone"
/** The categories in display order, simplest tier first. */
export const SAMPLE_CATEGORIES: SampleCategory[] = [
"Primer",
"Classics",
"System traps",
"Dynamic edge",
"Mechanics & capstone",
]
/** A loadable example: a title, one-line blurb and tier for the browser, plus a builder. */
export interface Sample {
title: string
blurb: string
category: SampleCategory
build: () => Model
}
@@ -1942,94 +1963,116 @@ function worldOnAWarmingPlanet(): Model {
/** The gallery, ordered simplest first. */
export const SAMPLES: Sample[] = [
{ title: "Bathtub", blurb: "A stock filled and drained — no feedback yet.", build: bathtub },
{
title: "Bathtub",
blurb: "A stock filled and drained — no feedback yet.",
category: "Primer",
build: bathtub,
},
{
title: "Savings account",
blurb: "Interest on a balance: a Reinforcing loop.",
category: "Primer",
build: savings,
},
{
title: "Coffee cooling",
blurb: "Settling toward room temperature: a Balancing loop.",
category: "Primer",
build: coffee,
},
{
title: "Population",
blurb: "Births and deaths: Reinforcing and Balancing together.",
category: "Primer",
build: population,
},
{
title: "Limits to growth",
blurb: "Growth into a ceiling: a Reinforcing and a Balancing loop on one Flow.",
category: "Classics",
build: limitsToGrowth,
},
{
title: "Predator and prey",
blurb: "Two coupled Stocks whose loops make them oscillate.",
category: "Classics",
build: predatorPrey,
},
{
title: "Epidemic",
blurb: "Susceptible → Infected → Recovered: a chain of Stocks, no clouds.",
category: "Classics",
build: epidemic,
},
{
title: "Tragedy of the commons",
blurb: "Two Reinforcing herds overgraze a renewable Stock, then starve with it: a system trap.",
category: "System traps",
build: tragedyOfTheCommons,
},
{
title: "Tragedy of the commons, fixed",
blurb: "Regulate the same renewable commons with a reserve: it holds, and the herds last.",
category: "System traps",
build: tragedyOfTheCommonsFixed,
},
{
title: "Escalation",
blurb: "An arms race: one Reinforcing loop spanning two Stocks.",
category: "System traps",
build: escalation,
},
{
title: "Success to the successful",
blurb:
"Two equal researchers, one a hair ahead: the field's attention locks onto the leader and the rival fades — a winner-take-all trap.",
category: "System traps",
build: successToTheSuccessful,
},
{
title: "Fixes that fail",
blurb: "Road building eases congestion (B) but induces the traffic that refills it (R).",
category: "System traps",
build: fixesThatFail,
},
{
title: "Drift to low performance",
blurb: "An eroding goal leaves steady decay no floor: both slide downhill.",
category: "System traps",
build: driftToLowPerformance,
},
{
title: "Overshoot and collapse",
blurb: "A fleet overfishes past the point of no return: the stock collapses for good.",
category: "Dynamic edge",
build: overshootAndCollapse,
},
{
title: "AI deskilling spiral",
blurb: "Leaning on AI to hold quality erodes the expertise that holds it: shifting the burden.",
category: "Dynamic edge",
build: aiDeskillingSpiral,
},
{
title: "Bathtub with an overflow",
blurb:
"A flat-out tap and a spillway: the excess spills to a second Stock and the level holds.",
category: "Mechanics & capstone",
build: bathtubOverflow,
},
{
title: "The cobra effect",
blurb:
"A bounty on dead cobras breeds a cobra farm; its glut spills into the wild, leaving four times the snakes: a perverse incentive.",
category: "Mechanics & capstone",
build: cobraEffect,
},
{
title: "World on a warming planet",
blurb:
"The Club of Rome's World3 in miniature, with a climate channel: growth overshoots a finite planet and the carbon it burns locks in the heat that finishes it.",
category: "Mechanics & capstone",
build: worldOnAWarmingPlanet,
},
]