web: the dashboard page, slice 3 of M3
The milestone 2 parity page becomes the spec's dashboard: app bar with wordmark, disclaimer badge and About popover, theme toggle (persisted, defaults to prefers-color-scheme, applied pre-mount to avoid a flash), hero with the preset narrative and live toned percentages, scenario toolbar, panel cards with skeleton loading and dim-while-refreshing, legend, and footer. ComparisonTable evolves into the collapsed ResultsTable (test moved along, plus a formatPct rounding case). NetworkView renders the real topology with the spec's shape encodings. The layout is seeded d3-force (mulberry32 random source, seeded initial positions, 300 synchronous ticks, cached per graph hash); anisotropic forceX/forceY pulls settle the cloud into the wide card shape so the fit stays uniform-scale and the spacing organic. Justin asked for a taller network area (380x230) and a reading caption under the legend; the caption is study copy, so StudyPreset gains a readingCaption field. Slices still to come keep their controls visibly parked: Export and the panel kebab render disabled until slices 11 and 7.
This commit is contained in:
parent
2f21248c20
commit
71baf10e73
28 changed files with 1766 additions and 300 deletions
1
web/.gitignore
vendored
1
web/.gitignore
vendored
|
|
@ -37,3 +37,4 @@ __screenshots__/
|
||||||
|
|
||||||
# Vite
|
# Vite
|
||||||
*.timestamp-*-*.mjs
|
*.timestamp-*-*.mjs
|
||||||
|
public/__*.html
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,25 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<link rel="icon" href="/favicon.ico">
|
<link rel="icon" href="/favicon.ico">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>spreadlab</title>
|
<title>spreadlab</title>
|
||||||
|
<script>
|
||||||
|
// Apply the persisted (or preferred) theme before the app mounts so
|
||||||
|
// dark mode never flashes light. Kept in sync with useTheme.ts.
|
||||||
|
try {
|
||||||
|
var storedTheme = localStorage.getItem('spreadlab-theme')
|
||||||
|
if (
|
||||||
|
storedTheme === 'dark' ||
|
||||||
|
(storedTheme !== 'light' && matchMedia('(prefers-color-scheme: dark)').matches)
|
||||||
|
) {
|
||||||
|
document.documentElement.classList.add('dark')
|
||||||
|
}
|
||||||
|
} catch (storageError) {
|
||||||
|
/* default to light */
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|
|
||||||
126
web/package-lock.json
generated
126
web/package-lock.json
generated
|
|
@ -8,10 +8,14 @@
|
||||||
"name": "web",
|
"name": "web",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fontsource-variable/inter": "^5.2.8",
|
||||||
|
"d3-force": "^3.0.0",
|
||||||
|
"d3-quadtree": "^3.0.1",
|
||||||
"vue": "^3.5.32"
|
"vue": "^3.5.32"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tsconfig/node24": "^24.0.4",
|
"@tsconfig/node24": "^24.0.4",
|
||||||
|
"@types/d3-force": "^3.0.10",
|
||||||
"@types/jsdom": "^28.0.1",
|
"@types/jsdom": "^28.0.1",
|
||||||
"@types/node": "^24.12.2",
|
"@types/node": "^24.12.2",
|
||||||
"@vitejs/plugin-vue": "^6.0.6",
|
"@vitejs/plugin-vue": "^6.0.6",
|
||||||
|
|
@ -740,35 +744,10 @@
|
||||||
"node": ">=20.19.0"
|
"node": ">=20.19.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/core": {
|
|
||||||
"version": "1.10.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
|
||||||
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"@emnapi/wasi-threads": "1.2.1",
|
|
||||||
"tslib": "^2.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@emnapi/runtime": {
|
|
||||||
"version": "1.10.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
|
||||||
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"tslib": "^2.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@emnapi/wasi-threads": {
|
"node_modules/@emnapi/wasi-threads": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
|
||||||
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
|
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
|
|
@ -888,6 +867,15 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fontsource-variable/inter": {
|
||||||
|
"version": "5.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.2.8.tgz",
|
||||||
|
"integrity": "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==",
|
||||||
|
"license": "OFL-1.1",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@humanfs/core": {
|
"node_modules/@humanfs/core": {
|
||||||
"version": "0.19.2",
|
"version": "0.19.2",
|
||||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
||||||
|
|
@ -1659,6 +1647,40 @@
|
||||||
"node": "^20.19.0 || >=22.12.0"
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": {
|
||||||
|
"version": "1.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||||
|
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/wasi-threads": "1.2.1",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||||
|
"version": "1.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||||
|
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz",
|
||||||
|
|
@ -1736,6 +1758,13 @@
|
||||||
"assertion-error": "^2.0.1"
|
"assertion-error": "^2.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/d3-force": {
|
||||||
|
"version": "3.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz",
|
||||||
|
"integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/deep-eql": {
|
"node_modules/@types/deep-eql": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||||
|
|
@ -2888,6 +2917,47 @@
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-dispatch": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-force": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-dispatch": "1 - 3",
|
||||||
|
"d3-quadtree": "1 - 3",
|
||||||
|
"d3-timer": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-quadtree": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-timer": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/data-urls": {
|
"node_modules/data-urls": {
|
||||||
"version": "7.0.0",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,14 @@
|
||||||
"format": "prettier --write --experimental-cli src/"
|
"format": "prettier --write --experimental-cli src/"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fontsource-variable/inter": "^5.2.8",
|
||||||
|
"d3-force": "^3.0.0",
|
||||||
|
"d3-quadtree": "^3.0.1",
|
||||||
"vue": "^3.5.32"
|
"vue": "^3.5.32"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tsconfig/node24": "^24.0.4",
|
"@tsconfig/node24": "^24.0.4",
|
||||||
|
"@types/d3-force": "^3.0.10",
|
||||||
"@types/jsdom": "^28.0.1",
|
"@types/jsdom": "^28.0.1",
|
||||||
"@types/node": "^24.12.2",
|
"@types/node": "^24.12.2",
|
||||||
"@vitejs/plugin-vue": "^6.0.6",
|
"@vitejs/plugin-vue": "^6.0.6",
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1,51 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue'
|
import { onMounted } from 'vue'
|
||||||
import { fetchDefaultConfig, runComparison } from '@/lib/api'
|
import AppBar from '@/components/AppBar.vue'
|
||||||
import type { ComparisonResponse } from '@/types/api'
|
import ErrorBanner from '@/components/ErrorBanner.vue'
|
||||||
import ComparisonTable from '@/components/ComparisonTable.vue'
|
import FooterDisclaimer from '@/components/FooterDisclaimer.vue'
|
||||||
|
import HeroHeadline from '@/components/HeroHeadline.vue'
|
||||||
|
import LegendRow from '@/components/LegendRow.vue'
|
||||||
|
import PanelGrid from '@/components/PanelGrid.vue'
|
||||||
|
import ResultsTable from '@/components/ResultsTable.vue'
|
||||||
|
import ScenarioToolbar from '@/components/ScenarioToolbar.vue'
|
||||||
|
import { useSimStore } from '@/composables/useSimStore'
|
||||||
|
import { useTheme } from '@/composables/useTheme'
|
||||||
|
|
||||||
const comparison = ref<ComparisonResponse | null>(null)
|
const store = useSimStore()
|
||||||
const error = ref<string | null>(null)
|
useTheme() // resolve and apply the theme before first paint of the app
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(() => {
|
||||||
try {
|
void store.initialize()
|
||||||
const config = await fetchDefaultConfig()
|
|
||||||
comparison.value = await runComparison(config)
|
|
||||||
} catch (cause) {
|
|
||||||
error.value = cause instanceof Error ? cause.message : String(cause)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main>
|
<AppBar />
|
||||||
<h1>spreadlab</h1>
|
<main class="page">
|
||||||
<p>
|
<HeroHeadline />
|
||||||
How a non-consensual deepfake spreads through a simulated school year
|
<ScenarioToolbar />
|
||||||
group, and what an education program changes. Illustrative, not
|
<ErrorBanner />
|
||||||
validated.
|
<PanelGrid />
|
||||||
</p>
|
<LegendRow />
|
||||||
<p v-if="error" role="alert">Could not reach the spreadlab API: {{ error }}</p>
|
<ResultsTable
|
||||||
<ComparisonTable v-else-if="comparison" :comparison="comparison" />
|
:panels="store.state.panels"
|
||||||
<p v-else>Running scenarios…</p>
|
:results-by-panel-id="store.state.resultsByPanelId"
|
||||||
|
:base="store.state.base"
|
||||||
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
<FooterDisclaimer />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
main {
|
.page {
|
||||||
max-width: 48rem;
|
max-width: 1240px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 2rem 1rem;
|
padding: 36px 28px 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.page {
|
||||||
|
padding: 22px 16px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,86 +0,0 @@
|
||||||
/* color palette from <https://github.com/vuejs/theme> */
|
|
||||||
:root {
|
|
||||||
--vt-c-white: #ffffff;
|
|
||||||
--vt-c-white-soft: #f8f8f8;
|
|
||||||
--vt-c-white-mute: #f2f2f2;
|
|
||||||
|
|
||||||
--vt-c-black: #181818;
|
|
||||||
--vt-c-black-soft: #222222;
|
|
||||||
--vt-c-black-mute: #282828;
|
|
||||||
|
|
||||||
--vt-c-indigo: #2c3e50;
|
|
||||||
|
|
||||||
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
|
|
||||||
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
|
|
||||||
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
|
|
||||||
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
|
|
||||||
|
|
||||||
--vt-c-text-light-1: var(--vt-c-indigo);
|
|
||||||
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
|
|
||||||
--vt-c-text-dark-1: var(--vt-c-white);
|
|
||||||
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* semantic color variables for this project */
|
|
||||||
:root {
|
|
||||||
--color-background: var(--vt-c-white);
|
|
||||||
--color-background-soft: var(--vt-c-white-soft);
|
|
||||||
--color-background-mute: var(--vt-c-white-mute);
|
|
||||||
|
|
||||||
--color-border: var(--vt-c-divider-light-2);
|
|
||||||
--color-border-hover: var(--vt-c-divider-light-1);
|
|
||||||
|
|
||||||
--color-heading: var(--vt-c-text-light-1);
|
|
||||||
--color-text: var(--vt-c-text-light-1);
|
|
||||||
|
|
||||||
--section-gap: 160px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root {
|
|
||||||
--color-background: var(--vt-c-black);
|
|
||||||
--color-background-soft: var(--vt-c-black-soft);
|
|
||||||
--color-background-mute: var(--vt-c-black-mute);
|
|
||||||
|
|
||||||
--color-border: var(--vt-c-divider-dark-2);
|
|
||||||
--color-border-hover: var(--vt-c-divider-dark-1);
|
|
||||||
|
|
||||||
--color-heading: var(--vt-c-text-dark-1);
|
|
||||||
--color-text: var(--vt-c-text-dark-2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
*,
|
|
||||||
*::before,
|
|
||||||
*::after {
|
|
||||||
box-sizing: border-box;
|
|
||||||
margin: 0;
|
|
||||||
font-weight: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
min-height: 100vh;
|
|
||||||
color: var(--color-text);
|
|
||||||
background: var(--color-background);
|
|
||||||
transition:
|
|
||||||
color 0.5s,
|
|
||||||
background-color 0.5s;
|
|
||||||
line-height: 1.6;
|
|
||||||
font-family:
|
|
||||||
Inter,
|
|
||||||
-apple-system,
|
|
||||||
BlinkMacSystemFont,
|
|
||||||
'Segoe UI',
|
|
||||||
Roboto,
|
|
||||||
Oxygen,
|
|
||||||
Ubuntu,
|
|
||||||
Cantarell,
|
|
||||||
'Fira Sans',
|
|
||||||
'Droid Sans',
|
|
||||||
'Helvetica Neue',
|
|
||||||
sans-serif;
|
|
||||||
font-size: 15px;
|
|
||||||
text-rendering: optimizeLegibility;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
}
|
|
||||||
|
|
@ -1,35 +1,106 @@
|
||||||
@import './base.css';
|
@import '@fontsource-variable/inter';
|
||||||
|
|
||||||
#app {
|
/* Visual tokens (spec section 6), one set per theme. Node semantics are
|
||||||
max-width: 1280px;
|
identical in both themes. */
|
||||||
margin: 0 auto;
|
:root {
|
||||||
padding: 2rem;
|
--bg: #f8fafc;
|
||||||
font-weight: normal;
|
--surface: #ffffff;
|
||||||
|
--border: #e2e8f0;
|
||||||
|
--border-soft: #eef2f7;
|
||||||
|
--edge: #e6e9ef;
|
||||||
|
--unreached: #d8dfe9;
|
||||||
|
--spread: #f43f5e;
|
||||||
|
--spread-soft: #fff1f2;
|
||||||
|
--edu: #14b8a6;
|
||||||
|
--edu-soft: #f0fdfa;
|
||||||
|
--ink: #0f172a;
|
||||||
|
--ink-2: #334155;
|
||||||
|
--ink-3: #64748b;
|
||||||
|
--ink-4: #94a3b8;
|
||||||
|
--shadow: 0 1px 2px rgba(15, 23, 42, 0.05), 0 4px 16px -8px rgba(15, 23, 42, 0.08);
|
||||||
|
--shadow-lift: 0 2px 4px rgba(15, 23, 42, 0.06), 0 12px 32px -12px rgba(15, 23, 42, 0.16);
|
||||||
|
--radius: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
a,
|
:root.dark {
|
||||||
.green {
|
--bg: #0b1120;
|
||||||
text-decoration: none;
|
--surface: #111a2e;
|
||||||
color: hsla(160, 100%, 37%, 1);
|
--border: #1e293b;
|
||||||
transition: 0.4s;
|
--border-soft: #1a2438;
|
||||||
padding: 3px;
|
--edge: #243349;
|
||||||
|
--unreached: #3b4a61;
|
||||||
|
--spread: #fb7185;
|
||||||
|
--spread-soft: rgba(251, 113, 133, 0.08);
|
||||||
|
--edu: #2dd4bf;
|
||||||
|
--edu-soft: rgba(45, 212, 191, 0.08);
|
||||||
|
--ink: #f1f5f9;
|
||||||
|
--ink-2: #cbd5e1;
|
||||||
|
--ink-3: #94a3b8;
|
||||||
|
--ink-4: #64748b;
|
||||||
|
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 8px 24px -12px rgba(0, 0, 0, 0.5);
|
||||||
|
--shadow-lift: 0 2px 4px rgba(0, 0, 0, 0.5), 0 16px 40px -16px rgba(0, 0, 0, 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (hover: hover) {
|
* {
|
||||||
a:hover {
|
box-sizing: border-box;
|
||||||
background-color: hsla(160, 100%, 37%, 0.2);
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
body {
|
body {
|
||||||
display: flex;
|
background: var(--bg);
|
||||||
place-items: center;
|
color: var(--ink);
|
||||||
|
font:
|
||||||
|
15px/1.5 'Inter Variable',
|
||||||
|
Inter,
|
||||||
|
system-ui,
|
||||||
|
sans-serif;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
#app {
|
/* Accessibility acceptance criterion: a visible 2 px ink outline with 2 px
|
||||||
display: grid;
|
offset on every focusable element, both themes. */
|
||||||
grid-template-columns: 1fr 1fr;
|
:focus-visible {
|
||||||
padding: 0 2rem;
|
outline: 2px solid var(--ink);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visually-hidden {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0 0 0 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shared icon sizing for the inline stroke icons. */
|
||||||
|
svg.ic {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
stroke: currentColor;
|
||||||
|
fill: none;
|
||||||
|
stroke-width: 1.8;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
111
web/src/components/AboutPopover.vue
Normal file
111
web/src/components/AboutPopover.vue
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
|
import { useSimStore } from '@/composables/useSimStore'
|
||||||
|
|
||||||
|
// Small dialog under the disclaimer badge (spec 5.12): what the model is,
|
||||||
|
// what it is not, and a link to the source. Esc, outside click, or the
|
||||||
|
// close button dismiss it; the opener restores focus on close.
|
||||||
|
|
||||||
|
const props = defineProps<{ anchor: HTMLElement | null }>()
|
||||||
|
const emit = defineEmits<{ close: [] }>()
|
||||||
|
|
||||||
|
const { preset } = useSimStore()
|
||||||
|
const popover = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
function onKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape') emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerDown(event: PointerEvent) {
|
||||||
|
const target = event.target as Node
|
||||||
|
if (popover.value?.contains(target)) return
|
||||||
|
if (props.anchor?.contains(target)) return // the badge itself toggles
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
popover.value?.focus()
|
||||||
|
document.addEventListener('keydown', onKeydown)
|
||||||
|
document.addEventListener('pointerdown', onPointerDown, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener('keydown', onKeydown)
|
||||||
|
document.removeEventListener('pointerdown', onPointerDown, true)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="popover" class="about" role="dialog" aria-label="About this model" tabindex="-1">
|
||||||
|
<h3>About this model</h3>
|
||||||
|
<p>{{ preset.disclaimerLong }}</p>
|
||||||
|
<p>
|
||||||
|
<a href="https://github.com/JustinZeus/spreadlab" target="_blank" rel="noopener">
|
||||||
|
Proposal and source on GitHub
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<button class="close" type="button" aria-label="Close" @click="emit('close')">
|
||||||
|
<svg class="ic" viewBox="0 0 24 24"><path d="M6 6l12 12M18 6L6 18" /></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.about {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
left: 0;
|
||||||
|
z-index: 30;
|
||||||
|
width: min(420px, calc(100vw - 32px));
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: var(--shadow-lift);
|
||||||
|
padding: 16px 40px 16px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ink-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 13.5px;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--ink-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--edu);
|
||||||
|
font-weight: 550;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--ink-3);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close:hover {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close svg.ic {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
184
web/src/components/AppBar.vue
Normal file
184
web/src/components/AppBar.vue
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { nextTick, ref } from 'vue'
|
||||||
|
import AboutPopover from './AboutPopover.vue'
|
||||||
|
import { useSimStore } from '@/composables/useSimStore'
|
||||||
|
import { useTheme } from '@/composables/useTheme'
|
||||||
|
|
||||||
|
const { preset } = useSimStore()
|
||||||
|
const { theme, toggleTheme } = useTheme()
|
||||||
|
|
||||||
|
const aboutOpen = ref(false)
|
||||||
|
const badgeButton = ref<HTMLButtonElement | null>(null)
|
||||||
|
|
||||||
|
function closeAbout() {
|
||||||
|
aboutOpen.value = false
|
||||||
|
void nextTick(() => badgeButton.value?.focus())
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<header class="appbar">
|
||||||
|
<div class="in">
|
||||||
|
<span class="wordmark">
|
||||||
|
<span class="glyph" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24">
|
||||||
|
<circle cx="6" cy="6" r="2.2" />
|
||||||
|
<circle cx="18" cy="9" r="2.2" />
|
||||||
|
<circle cx="10" cy="18" r="2.2" />
|
||||||
|
<path d="M8 7l8 1.6M16.5 10.8l-5 5.4M7 8l2.4 8" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
spreadlab
|
||||||
|
</span>
|
||||||
|
<span class="badge-anchor">
|
||||||
|
<button
|
||||||
|
ref="badgeButton"
|
||||||
|
class="badge"
|
||||||
|
type="button"
|
||||||
|
:aria-expanded="aboutOpen"
|
||||||
|
@click="aboutOpen = !aboutOpen"
|
||||||
|
>
|
||||||
|
<svg class="ic" viewBox="0 0 24 24">
|
||||||
|
<circle cx="12" cy="12" r="9" />
|
||||||
|
<path d="M12 11v5M12 8v.01" />
|
||||||
|
</svg>
|
||||||
|
{{ preset.disclaimerShort }}
|
||||||
|
</button>
|
||||||
|
<AboutPopover v-if="aboutOpen" :anchor="badgeButton" @close="closeAbout" />
|
||||||
|
</span>
|
||||||
|
<span class="grow" />
|
||||||
|
<button
|
||||||
|
class="iconbtn"
|
||||||
|
type="button"
|
||||||
|
:aria-label="theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'"
|
||||||
|
@click="toggleTheme"
|
||||||
|
>
|
||||||
|
<svg v-if="theme === 'dark'" class="ic" viewBox="0 0 24 24">
|
||||||
|
<circle cx="12" cy="12" r="4" />
|
||||||
|
<path
|
||||||
|
d="M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6l1.5 1.5M16.9 16.9l1.5 1.5M5.6 18.4l1.5-1.5M16.9 7.1l1.5-1.5"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<svg v-else class="ic" viewBox="0 0 24 24">
|
||||||
|
<path d="M20 14.5A8 8 0 1 1 9.5 4 6.5 6.5 0 0 0 20 14.5z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
class="iconbtn"
|
||||||
|
href="https://github.com/JustinZeus/spreadlab"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
aria-label="Source code on GitHub"
|
||||||
|
>
|
||||||
|
<svg class="ic" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
d="M9 19c-4.6 1.4-4.6-2.5-6.5-3m13 5v-3.5c0-1 .1-1.4-.5-2 2.8-.3 5.5-1.4 5.5-6a4.6 4.6 0 0 0-1.3-3.2 4.2 4.2 0 0 0-.1-3.2s-1.1-.3-3.5 1.3a12.3 12.3 0 0 0-6.2 0C7.1 2.8 6 3.1 6 3.1a4.2 4.2 0 0 0-.1 3.2A4.6 4.6 0 0 0 4.6 9.5c0 4.6 2.7 5.7 5.5 6-.6.6-.6 1.2-.5 2V21"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.appbar {
|
||||||
|
background: var(--surface);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.in {
|
||||||
|
max-width: 1240px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 28px;
|
||||||
|
height: 56px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wordmark {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
font-weight: 650;
|
||||||
|
font-size: 15.5px;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glyph {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: linear-gradient(135deg, var(--edu), #0d9488);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glyph svg {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
stroke: #fff;
|
||||||
|
fill: none;
|
||||||
|
stroke-width: 2.2;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-anchor {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--ink-3);
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 4px 12px 4px 9px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge svg.ic {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grow {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconbtn {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--ink-3);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconbtn:hover {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.in {
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-anchor {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import type { ComparisonResponse } from '@/types/api'
|
|
||||||
|
|
||||||
defineProps<{ comparison: ComparisonResponse }>()
|
|
||||||
|
|
||||||
const strategyLabels: Record<string, string> = {
|
|
||||||
none: 'No program',
|
|
||||||
random: 'Educate at random',
|
|
||||||
'most-connected': 'Educate the most connected',
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<table>
|
|
||||||
<caption>
|
|
||||||
Same education budget ({{ comparison.config.numEducated }} of
|
|
||||||
{{ comparison.config.numStudents }} students), different targeting
|
|
||||||
</caption>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th scope="col">Strategy</th>
|
|
||||||
<th scope="col">Educated</th>
|
|
||||||
<th scope="col">Reached</th>
|
|
||||||
<th scope="col">Share of school</th>
|
|
||||||
<th scope="col">Rounds</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="result in comparison.results" :key="result.strategy">
|
|
||||||
<th scope="row">{{ strategyLabels[result.strategy] ?? result.strategy }}</th>
|
|
||||||
<td>{{ result.educated.length }}</td>
|
|
||||||
<td>{{ result.numReached }} / {{ comparison.config.numStudents }}</td>
|
|
||||||
<td>{{ Math.round(result.reachedPct) }}%</td>
|
|
||||||
<td>{{ result.numRounds }}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
table {
|
|
||||||
border-collapse: collapse;
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
caption {
|
|
||||||
caption-side: top;
|
|
||||||
text-align: left;
|
|
||||||
padding-bottom: 0.75rem;
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
th,
|
|
||||||
td {
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
padding: 0.5rem 0.9rem;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
thead th {
|
|
||||||
background: var(--color-background-soft);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
61
web/src/components/ErrorBanner.vue
Normal file
61
web/src/components/ErrorBanner.vue
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useSimStore } from '@/composables/useSimStore'
|
||||||
|
|
||||||
|
// Failed refreshes and invalid shared links (spec 5.1/5.2). Engine 400s do
|
||||||
|
// not land here; they render inline under the controls card. The last good
|
||||||
|
// results stay on screen behind this banner.
|
||||||
|
|
||||||
|
const store = useSimStore()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="store.state.urlStateInvalid" class="banner" role="alert">
|
||||||
|
<svg class="ic" viewBox="0 0 24 24">
|
||||||
|
<circle cx="12" cy="12" r="9" />
|
||||||
|
<path d="M12 11v5M12 8v.01" />
|
||||||
|
</svg>
|
||||||
|
Link contained an invalid configuration, showing defaults
|
||||||
|
</div>
|
||||||
|
<div v-if="store.state.errorMessage" class="banner" role="alert">
|
||||||
|
<svg class="ic" viewBox="0 0 24 24">
|
||||||
|
<circle cx="12" cy="12" r="9" />
|
||||||
|
<path d="M12 11v5M12 8v.01" />
|
||||||
|
</svg>
|
||||||
|
Could not update: {{ store.state.errorMessage }}
|
||||||
|
<button class="retry" type="button" @click="store.retry()">Retry</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--spread);
|
||||||
|
background: var(--spread-soft);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--spread) 25%, transparent);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg.ic {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retry {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--spread);
|
||||||
|
background: none;
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
35
web/src/components/FooterDisclaimer.vue
Normal file
35
web/src/components/FooterDisclaimer.vue
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
<template>
|
||||||
|
<footer class="footer">
|
||||||
|
<div class="in">
|
||||||
|
<span>
|
||||||
|
<em>Illustrative (not validated output)</em> · an agent‑based toy model, parameters chosen
|
||||||
|
for clarity
|
||||||
|
</span>
|
||||||
|
<span>spreadlab · AGPL</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.footer .in {
|
||||||
|
max-width: 1240px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 28px 48px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--ink-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
em {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.footer .in {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 0 16px 110px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
137
web/src/components/HeroHeadline.vue
Normal file
137
web/src/components/HeroHeadline.vue
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import AboutPopover from './AboutPopover.vue'
|
||||||
|
import { useSimStore } from '@/composables/useSimStore'
|
||||||
|
import { formatPct, roundPct } from '@/lib/format'
|
||||||
|
|
||||||
|
// The grant story in one paragraph. {n} placeholders in the preset
|
||||||
|
// narrative become each panel's live reached%, toned good or bad against
|
||||||
|
// the preset threshold; the message must land without interaction.
|
||||||
|
|
||||||
|
const { preset, state } = useSimStore()
|
||||||
|
|
||||||
|
interface NarrativeSegment {
|
||||||
|
kind: 'text' | 'pct'
|
||||||
|
text: string
|
||||||
|
tone?: 'good' | 'bad' | 'pending'
|
||||||
|
}
|
||||||
|
|
||||||
|
const segments = computed<NarrativeSegment[]>(() =>
|
||||||
|
preset.narrative.split(/(\{\d+\})/).map((part) => {
|
||||||
|
const placeholder = /^\{(\d+)\}$/.exec(part)
|
||||||
|
if (!placeholder) return { kind: 'text', text: part }
|
||||||
|
const panel = state.panels[Number(placeholder[1])]
|
||||||
|
const result = panel ? state.resultsByPanelId[panel.id] : undefined
|
||||||
|
if (!result) return { kind: 'pct', text: '…', tone: 'pending' }
|
||||||
|
return {
|
||||||
|
kind: 'pct',
|
||||||
|
text: formatPct(result.reachedPct),
|
||||||
|
tone: roundPct(result.reachedPct) <= preset.toneThresholdPct ? 'good' : 'bad',
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const aboutOpen = ref(false)
|
||||||
|
const noteButton = ref<HTMLButtonElement | null>(null)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="hero">
|
||||||
|
<h1>{{ preset.headline }}</h1>
|
||||||
|
<p>
|
||||||
|
<template v-for="(segment, index) in segments" :key="index">
|
||||||
|
<b v-if="segment.kind === 'pct'" :class="segment.tone">{{ segment.text }}</b>
|
||||||
|
<template v-else>{{ segment.text }}</template>
|
||||||
|
</template>
|
||||||
|
</p>
|
||||||
|
<span class="mobile-note">
|
||||||
|
<button
|
||||||
|
ref="noteButton"
|
||||||
|
type="button"
|
||||||
|
class="note-button"
|
||||||
|
:aria-expanded="aboutOpen"
|
||||||
|
@click="aboutOpen = !aboutOpen"
|
||||||
|
>
|
||||||
|
<svg class="ic" viewBox="0 0 24 24">
|
||||||
|
<circle cx="12" cy="12" r="9" />
|
||||||
|
<path d="M12 11v5M12 8v.01" />
|
||||||
|
</svg>
|
||||||
|
{{ preset.disclaimerShort }}
|
||||||
|
</button>
|
||||||
|
<AboutPopover v-if="aboutOpen" :anchor="noteButton" @close="aboutOpen = false" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.hero {
|
||||||
|
max-width: 760px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 30px;
|
||||||
|
line-height: 1.18;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.022em;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 16.5px;
|
||||||
|
color: var(--ink-3);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
p b {
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
p b.bad {
|
||||||
|
color: var(--spread);
|
||||||
|
}
|
||||||
|
|
||||||
|
p b.good {
|
||||||
|
color: var(--edu);
|
||||||
|
}
|
||||||
|
|
||||||
|
p b.pending {
|
||||||
|
color: var(--ink-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-note {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
h1 {
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-note {
|
||||||
|
display: block;
|
||||||
|
position: relative;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--ink-4);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-button svg.ic {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
76
web/src/components/LegendRow.vue
Normal file
76
web/src/components/LegendRow.vue
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useSimStore } from '@/composables/useSimStore'
|
||||||
|
|
||||||
|
const { preset } = useSimStore()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="legend-block">
|
||||||
|
<div class="legend">
|
||||||
|
<span class="k"><span class="lg spread" aria-hidden="true" /> Forwarded the fake</span>
|
||||||
|
<span class="k"><span class="lg educated" aria-hidden="true" /> Educated to refuse</span>
|
||||||
|
<span class="k"><span class="lg unreached" aria-hidden="true" /> Not reached</span>
|
||||||
|
<span class="k"><span class="lg origin" aria-hidden="true" /> Origin</span>
|
||||||
|
</div>
|
||||||
|
<p class="caption">{{ preset.readingCaption }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.legend-block {
|
||||||
|
margin: 14px 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend {
|
||||||
|
display: flex;
|
||||||
|
gap: 22px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--ink-3);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.k {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lg {
|
||||||
|
width: 11px;
|
||||||
|
height: 11px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lg.spread {
|
||||||
|
background: var(--spread);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lg.educated {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border: 2.5px solid var(--edu);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lg.unreached {
|
||||||
|
background: var(--unreached);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lg.origin {
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border: 2px solid var(--spread);
|
||||||
|
}
|
||||||
|
|
||||||
|
.caption {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--ink-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.legend {
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
98
web/src/components/NetworkView.vue
Normal file
98
web/src/components/NetworkView.vue
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { layoutForGraph, NETWORK_VIEW_HEIGHT, NETWORK_VIEW_WIDTH } from '@/composables/useLayout'
|
||||||
|
import { useSimStore } from '@/composables/useSimStore'
|
||||||
|
import { graphKey } from '@/lib/graph'
|
||||||
|
import type { PanelSpec } from '@/presets/types'
|
||||||
|
|
||||||
|
// One panel's network picture (spec section 6). Shape carries meaning,
|
||||||
|
// color reinforces it: forwarded discs, educated donuts, small unreached
|
||||||
|
// dots, and a halo ring on the origin. The SVG is decorative for screen
|
||||||
|
// readers; the panel text carries the result.
|
||||||
|
|
||||||
|
const props = defineProps<{ panel: PanelSpec }>()
|
||||||
|
|
||||||
|
const store = useSimStore()
|
||||||
|
|
||||||
|
const effectiveConfig = computed(() => store.effectiveConfig(props.panel))
|
||||||
|
const edges = computed(() => store.state.edgesByGraphHash[graphKey(effectiveConfig.value)] ?? [])
|
||||||
|
const result = computed(() => store.state.resultsByPanelId[props.panel.id])
|
||||||
|
const layout = computed(() => layoutForGraph(effectiveConfig.value, edges.value))
|
||||||
|
|
||||||
|
interface RenderedNode {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
reached: boolean
|
||||||
|
educated: boolean
|
||||||
|
isOrigin: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodes = computed<RenderedNode[]>(() => {
|
||||||
|
const panelResult = result.value
|
||||||
|
if (!panelResult) return []
|
||||||
|
const educatedNodes = new Set(panelResult.educated)
|
||||||
|
return layout.value.map((point, nodeIndex) => {
|
||||||
|
const reachedAtRound = panelResult.reachedAtRound[nodeIndex] ?? -1
|
||||||
|
return {
|
||||||
|
x: point.x,
|
||||||
|
y: point.y,
|
||||||
|
reached: reachedAtRound >= 0 && reachedAtRound <= store.state.round,
|
||||||
|
educated: educatedNodes.has(nodeIndex),
|
||||||
|
isOrigin: nodeIndex === effectiveConfig.value.origin,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function edgeEnd(edge: number[], side: 0 | 1) {
|
||||||
|
return layout.value[edge[side] ?? 0] ?? { x: 0, y: 0 }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<svg class="net" :viewBox="`0 0 ${NETWORK_VIEW_WIDTH} ${NETWORK_VIEW_HEIGHT}`" aria-hidden="true">
|
||||||
|
<line
|
||||||
|
v-for="(edge, edgeIndex) in edges"
|
||||||
|
:key="edgeIndex"
|
||||||
|
:x1="edgeEnd(edge, 0).x.toFixed(1)"
|
||||||
|
:y1="edgeEnd(edge, 0).y.toFixed(1)"
|
||||||
|
:x2="edgeEnd(edge, 1).x.toFixed(1)"
|
||||||
|
:y2="edgeEnd(edge, 1).y.toFixed(1)"
|
||||||
|
stroke="var(--edge)"
|
||||||
|
stroke-width="1"
|
||||||
|
/>
|
||||||
|
<g v-for="(node, nodeIndex) in nodes" :key="nodeIndex">
|
||||||
|
<template v-if="node.isOrigin">
|
||||||
|
<circle
|
||||||
|
:cx="node.x"
|
||||||
|
:cy="node.y"
|
||||||
|
r="7.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--spread)"
|
||||||
|
stroke-width="1.4"
|
||||||
|
opacity="0.75"
|
||||||
|
/>
|
||||||
|
<circle :cx="node.x" :cy="node.y" r="4" fill="var(--spread)" />
|
||||||
|
</template>
|
||||||
|
<circle
|
||||||
|
v-else-if="node.educated"
|
||||||
|
:cx="node.x"
|
||||||
|
:cy="node.y"
|
||||||
|
r="3.8"
|
||||||
|
:fill="node.reached ? 'var(--spread)' : 'var(--surface)'"
|
||||||
|
stroke="var(--edu)"
|
||||||
|
stroke-width="2.2"
|
||||||
|
/>
|
||||||
|
<circle v-else-if="node.reached" :cx="node.x" :cy="node.y" r="4" fill="var(--spread)" />
|
||||||
|
<circle v-else :cx="node.x" :cy="node.y" r="3" fill="var(--unreached)" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.net {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
81
web/src/components/PanelGrid.vue
Normal file
81
web/src/components/PanelGrid.vue
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import ScenarioPanel from './ScenarioPanel.vue'
|
||||||
|
import { useSimStore } from '@/composables/useSimStore'
|
||||||
|
import { accentForPanel } from '@/lib/accents'
|
||||||
|
|
||||||
|
const store = useSimStore()
|
||||||
|
|
||||||
|
// Initial-load failure (spec 5.1): nothing rendered yet, so the panel area
|
||||||
|
// itself carries the message; nothing else is dimmed.
|
||||||
|
const unreachable = computed(
|
||||||
|
() =>
|
||||||
|
store.state.runState === 'error' &&
|
||||||
|
store.state.errorMessage !== null &&
|
||||||
|
Object.keys(store.state.resultsByPanelId).length === 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
const apiBaseUrl = `${window.location.origin}/api`
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="unreachable" class="unreachable" role="alert">
|
||||||
|
<p>
|
||||||
|
Could not reach the spreadlab API at <code>{{ apiBaseUrl }}</code>
|
||||||
|
</p>
|
||||||
|
<button class="retry" type="button" @click="store.retry()">Retry</button>
|
||||||
|
</div>
|
||||||
|
<div v-else class="panels">
|
||||||
|
<ScenarioPanel
|
||||||
|
v-for="(panel, panelIndex) in store.state.panels"
|
||||||
|
:key="panel.id"
|
||||||
|
:panel="panel"
|
||||||
|
:accent="accentForPanel(panelIndex)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.panels {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unreachable {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 64px 24px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--ink-3);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.unreachable code {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--ink-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.retry {
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--surface);
|
||||||
|
background: var(--ink);
|
||||||
|
border: 1px solid var(--ink);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.panels {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
137
web/src/components/ResultsTable.vue
Normal file
137
web/src/components/ResultsTable.vue
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { formatPct } from '@/lib/format'
|
||||||
|
import type { PanelSpec } from '@/presets/types'
|
||||||
|
import type { Config, Result } from '@/types/engine'
|
||||||
|
|
||||||
|
// The collapsed, accessible counterpart of the panel cards (spec section
|
||||||
|
// 8): live per-panel numbers as a real table behind a disclosure. Evolved
|
||||||
|
// from the milestone 2 ComparisonTable.
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
panels: PanelSpec[]
|
||||||
|
resultsByPanelId: Record<string, Result>
|
||||||
|
base: Config
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const strategyLabels: Record<string, string> = {
|
||||||
|
none: 'None',
|
||||||
|
random: 'Random',
|
||||||
|
'most-connected': 'Most connected',
|
||||||
|
}
|
||||||
|
|
||||||
|
function numStudentsFor(panel: PanelSpec): number {
|
||||||
|
return panel.overrides.numStudents ?? props.base.numStudents
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<details class="results">
|
||||||
|
<summary>
|
||||||
|
<svg class="ic chevron" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 6l6 6-6 6" /></svg>
|
||||||
|
Data table
|
||||||
|
</summary>
|
||||||
|
<table>
|
||||||
|
<caption>
|
||||||
|
Per-scenario results for the current configuration
|
||||||
|
</caption>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Scenario</th>
|
||||||
|
<th scope="col">Strategy</th>
|
||||||
|
<th scope="col">Educated</th>
|
||||||
|
<th scope="col">Reached</th>
|
||||||
|
<th scope="col">Share of school</th>
|
||||||
|
<th scope="col">Rounds</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="panel in panels" :key="panel.id">
|
||||||
|
<th scope="row">{{ panel.label }}</th>
|
||||||
|
<template v-if="resultsByPanelId[panel.id]">
|
||||||
|
<td>{{ strategyLabels[panel.strategy] ?? panel.strategy }}</td>
|
||||||
|
<td>{{ resultsByPanelId[panel.id]!.educated.length }}</td>
|
||||||
|
<td>{{ resultsByPanelId[panel.id]!.numReached }} / {{ numStudentsFor(panel) }}</td>
|
||||||
|
<td>{{ formatPct(resultsByPanelId[panel.id]!.reachedPct) }}</td>
|
||||||
|
<td>{{ resultsByPanelId[panel.id]!.numRounds }}</td>
|
||||||
|
</template>
|
||||||
|
<td v-else colspan="5">Running…</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</details>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.results {
|
||||||
|
margin-top: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
summary {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink-3);
|
||||||
|
cursor: pointer;
|
||||||
|
list-style: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevron {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
transition: transform 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
details[open] .chevron {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: 12px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 13.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
caption {
|
||||||
|
caption-side: top;
|
||||||
|
text-align: left;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--ink-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
padding: 7px 12px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
thead th {
|
||||||
|
background: var(--bg);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ink-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody th {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
color: var(--ink-2);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
234
web/src/components/ScenarioPanel.vue
Normal file
234
web/src/components/ScenarioPanel.vue
Normal file
|
|
@ -0,0 +1,234 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import NetworkView from './NetworkView.vue'
|
||||||
|
import { useSimStore } from '@/composables/useSimStore'
|
||||||
|
import { formatPct, roundPct } from '@/lib/format'
|
||||||
|
import type { PanelSpec } from '@/presets/types'
|
||||||
|
|
||||||
|
const props = defineProps<{ panel: PanelSpec; accent: string }>()
|
||||||
|
|
||||||
|
const store = useSimStore()
|
||||||
|
|
||||||
|
const result = computed(() => store.state.resultsByPanelId[props.panel.id])
|
||||||
|
const numStudents = computed(() => store.effectiveConfig(props.panel).numStudents)
|
||||||
|
const tone = computed(() =>
|
||||||
|
result.value && roundPct(result.value.reachedPct) <= store.preset.toneThresholdPct
|
||||||
|
? 'good'
|
||||||
|
: 'bad',
|
||||||
|
)
|
||||||
|
|
||||||
|
interface PanelChip {
|
||||||
|
key: string
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const chips = computed<PanelChip[]>(() => {
|
||||||
|
const chipList: PanelChip[] = []
|
||||||
|
if (props.panel.strategy !== 'none') {
|
||||||
|
chipList.push({ key: 'strategy', text: `strategy · ${props.panel.strategy}` })
|
||||||
|
}
|
||||||
|
for (const [field, value] of Object.entries(props.panel.overrides)) {
|
||||||
|
chipList.push({ key: field, text: `${field} · ${value}` })
|
||||||
|
}
|
||||||
|
return chipList
|
||||||
|
})
|
||||||
|
|
||||||
|
// Refreshes over existing results dim the card; a panel without any result
|
||||||
|
// yet renders as a skeleton so the layout never jumps.
|
||||||
|
const dimmed = computed(() => store.state.runState === 'running' && result.value !== undefined)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="panel" :class="{ dimmed }" :aria-label="panel.label">
|
||||||
|
<div class="head">
|
||||||
|
<span class="swatch" :style="{ background: accent }" aria-hidden="true" />
|
||||||
|
<span class="label">{{ panel.label }}</span>
|
||||||
|
<button class="kebab" type="button" disabled title="Scenario menu arrives in a later slice">
|
||||||
|
<svg class="ic" viewBox="0 0 24 24"><path d="M12 6h.01M12 12h.01M12 18h.01" /></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<template v-if="result">
|
||||||
|
<div class="pct" :class="tone">
|
||||||
|
{{ roundPct(result.reachedPct) }}<small>%</small>
|
||||||
|
<span class="visually-hidden">{{ formatPct(result.reachedPct) }} reached</span>
|
||||||
|
</div>
|
||||||
|
<div class="meta">
|
||||||
|
<span>{{ result.numReached }} of {{ numStudents }} reached</span>
|
||||||
|
<span class="sep" aria-hidden="true">•</span>
|
||||||
|
<span>{{ result.numRounds }} rounds</span>
|
||||||
|
<span class="sep" aria-hidden="true">•</span>
|
||||||
|
<span>{{ result.educated.length }} educated</span>
|
||||||
|
</div>
|
||||||
|
<div class="chips">
|
||||||
|
<span v-for="chip in chips" :key="chip.key" class="chip">{{ chip.text }}</span>
|
||||||
|
</div>
|
||||||
|
<NetworkView :panel="panel" />
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="skeleton" aria-hidden="true">
|
||||||
|
<div class="bone pct-bone" />
|
||||||
|
<div class="bone meta-bone" />
|
||||||
|
<div class="bone net-bone" />
|
||||||
|
</div>
|
||||||
|
<span class="visually-hidden">Running scenario…</span>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.panel {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 18px 20px 10px;
|
||||||
|
position: relative;
|
||||||
|
transition:
|
||||||
|
box-shadow 0.15s,
|
||||||
|
opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel:hover {
|
||||||
|
box-shadow: var(--shadow-lift);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel.dimmed {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatch {
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border-radius: 3px;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink-2);
|
||||||
|
letter-spacing: -0.005em;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kebab {
|
||||||
|
color: var(--ink-4);
|
||||||
|
margin-right: -6px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kebab:disabled {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pct {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 40px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pct small {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink-4);
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pct.bad {
|
||||||
|
color: var(--spread);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pct.good {
|
||||||
|
color: var(--edu);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
margin-top: 7px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--ink-4);
|
||||||
|
display: flex;
|
||||||
|
gap: 5px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta span {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta .sep {
|
||||||
|
color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chips {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 9px;
|
||||||
|
min-height: 22px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 550;
|
||||||
|
color: var(--ink-3);
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
border-radius: 7px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Skeleton card (spec 5.1): same vertical rhythm as a loaded panel. */
|
||||||
|
.skeleton .bone {
|
||||||
|
border-radius: 8px;
|
||||||
|
background: linear-gradient(
|
||||||
|
100deg,
|
||||||
|
var(--border-soft) 40%,
|
||||||
|
var(--bg) 50%,
|
||||||
|
var(--border-soft) 60%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.4s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pct-bone {
|
||||||
|
width: 96px;
|
||||||
|
height: 40px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-bone {
|
||||||
|
width: 70%;
|
||||||
|
height: 13px;
|
||||||
|
margin-top: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.net-bone {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 2 / 1;
|
||||||
|
margin-top: 35px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
to {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
114
web/src/components/ScenarioToolbar.vue
Normal file
114
web/src/components/ScenarioToolbar.vue
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useSimStore } from '@/composables/useSimStore'
|
||||||
|
import { MAX_PANELS } from '@/presets/types'
|
||||||
|
|
||||||
|
const store = useSimStore()
|
||||||
|
|
||||||
|
const atPanelCap = computed(() => store.state.panels.length >= MAX_PANELS)
|
||||||
|
// A refresh is in flight over existing results; the initial load shows
|
||||||
|
// skeleton cards instead of this spinner.
|
||||||
|
const refreshing = computed(
|
||||||
|
() => store.state.runState === 'running' && Object.keys(store.state.resultsByPanelId).length > 0,
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="toolbar">
|
||||||
|
<h2>Scenarios</h2>
|
||||||
|
<span v-if="refreshing" class="spinner" role="status" aria-label="Updating scenarios" />
|
||||||
|
<span class="grow" />
|
||||||
|
<button class="btn" type="button" disabled title="Export arrives in a later slice">
|
||||||
|
<svg class="ic" viewBox="0 0 24 24"><path d="M12 4v11m0 0l-4-4m4 4l4-4M5 20h14" /></svg>
|
||||||
|
Export
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn primary"
|
||||||
|
type="button"
|
||||||
|
:disabled="atPanelCap"
|
||||||
|
:title="atPanelCap ? 'Maximum 6 scenarios' : undefined"
|
||||||
|
@click="store.addPanel()"
|
||||||
|
>
|
||||||
|
<svg class="ic" viewBox="0 0 24 24"><path d="M12 6v12M6 12h12" /></svg>
|
||||||
|
Add scenario
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin: 30px 0 14px;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ink-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grow {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
border-top-color: var(--ink-3);
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--ink-2);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover:not(:disabled) {
|
||||||
|
border-color: var(--ink-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.primary {
|
||||||
|
background: var(--ink);
|
||||||
|
color: var(--surface);
|
||||||
|
border-color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn svg.ic {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.toolbar {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import { mount } from '@vue/test-utils'
|
|
||||||
import ComparisonTable from '../ComparisonTable.vue'
|
|
||||||
import type { ComparisonResponse } from '@/types/api'
|
|
||||||
|
|
||||||
// A small hand-made comparison; the real numbers come from the API and are
|
|
||||||
// pinned by the Go tests. Here we only care that the table renders them.
|
|
||||||
const comparison: ComparisonResponse = {
|
|
||||||
config: {
|
|
||||||
numStudents: 10,
|
|
||||||
edgesPerNode: 2,
|
|
||||||
triangleProb: 0.4,
|
|
||||||
forwardProb: 0.5,
|
|
||||||
numEducated: 3,
|
|
||||||
origin: 0,
|
|
||||||
graphSeed: 1,
|
|
||||||
thresholdSeed: 2,
|
|
||||||
educationSeed: 3,
|
|
||||||
},
|
|
||||||
results: [
|
|
||||||
{
|
|
||||||
strategy: 'none',
|
|
||||||
educated: [],
|
|
||||||
reachedAtRound: [0, 1, 1, 2, 2, 2, 3, -1, -1, -1],
|
|
||||||
numReached: 7,
|
|
||||||
numRounds: 4,
|
|
||||||
reachedPct: 70,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
strategy: 'random',
|
|
||||||
educated: [2, 5, 8],
|
|
||||||
reachedAtRound: [0, 1, 1, 2, -1, -1, -1, -1, -1, -1],
|
|
||||||
numReached: 4,
|
|
||||||
numRounds: 3,
|
|
||||||
reachedPct: 40,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
strategy: 'most-connected',
|
|
||||||
educated: [1, 2, 3],
|
|
||||||
reachedAtRound: [0, 1, -1, -1, -1, -1, -1, -1, -1, -1],
|
|
||||||
numReached: 2,
|
|
||||||
numRounds: 2,
|
|
||||||
reachedPct: 20,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('ComparisonTable', () => {
|
|
||||||
it('renders one row per strategy with its reach', () => {
|
|
||||||
const wrapper = mount(ComparisonTable, { props: { comparison } })
|
|
||||||
|
|
||||||
const rows = wrapper.findAll('tbody tr')
|
|
||||||
expect(rows).toHaveLength(3)
|
|
||||||
expect(rows[0]!.text()).toContain('No program')
|
|
||||||
expect(rows[0]!.text()).toContain('7 / 10')
|
|
||||||
expect(rows[2]!.text()).toContain('Educate the most connected')
|
|
||||||
expect(rows[2]!.text()).toContain('20%')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('falls back to the raw strategy name for unknown strategies', () => {
|
|
||||||
const unknown: ComparisonResponse = {
|
|
||||||
config: comparison.config,
|
|
||||||
results: [{ ...comparison.results[0]!, strategy: 'telepathy' }],
|
|
||||||
}
|
|
||||||
const wrapper = mount(ComparisonTable, { props: { comparison: unknown } })
|
|
||||||
expect(wrapper.find('tbody th').text()).toBe('telepathy')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
89
web/src/components/__tests__/ResultsTable.spec.ts
Normal file
89
web/src/components/__tests__/ResultsTable.spec.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import ResultsTable from '../ResultsTable.vue'
|
||||||
|
import type { PanelSpec } from '@/presets/types'
|
||||||
|
import type { Config, Result } from '@/types/engine'
|
||||||
|
|
||||||
|
// Hand-made data; the real numbers come from the API and are pinned by the
|
||||||
|
// Go tests. Here we only care that the table renders them accessibly.
|
||||||
|
|
||||||
|
const base: Config = {
|
||||||
|
numStudents: 10,
|
||||||
|
edgesPerNode: 2,
|
||||||
|
triangleProb: 0.4,
|
||||||
|
forwardProb: 0.5,
|
||||||
|
numEducated: 3,
|
||||||
|
origin: 0,
|
||||||
|
graphSeed: 1,
|
||||||
|
thresholdSeed: 2,
|
||||||
|
educationSeed: 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
const panels: PanelSpec[] = [
|
||||||
|
{ id: 'panel-none', label: 'No program', strategy: 'none', overrides: {} },
|
||||||
|
{ id: 'panel-random', label: 'Random picks', strategy: 'random', overrides: {} },
|
||||||
|
{
|
||||||
|
id: 'panel-big',
|
||||||
|
label: 'Bigger school',
|
||||||
|
strategy: 'most-connected',
|
||||||
|
overrides: { numStudents: 40 },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const resultsByPanelId: Record<string, Result> = {
|
||||||
|
'panel-none': {
|
||||||
|
strategy: 'none',
|
||||||
|
educated: [],
|
||||||
|
reachedAtRound: [0, 1, 1, 2, 2, 2, 3, -1, -1, -1],
|
||||||
|
numReached: 7,
|
||||||
|
numRounds: 4,
|
||||||
|
reachedPct: 70,
|
||||||
|
},
|
||||||
|
'panel-random': {
|
||||||
|
strategy: 'random',
|
||||||
|
educated: [2, 5, 8],
|
||||||
|
reachedAtRound: [0, 1, 1, 2, -1, -1, -1, -1, -1, -1],
|
||||||
|
numReached: 4,
|
||||||
|
numRounds: 3,
|
||||||
|
reachedPct: 40,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ResultsTable', () => {
|
||||||
|
it('renders one row per panel with its reach, honoring overrides', () => {
|
||||||
|
const wrapper = mount(ResultsTable, { props: { panels, resultsByPanelId, base } })
|
||||||
|
|
||||||
|
const rows = wrapper.findAll('tbody tr')
|
||||||
|
expect(rows).toHaveLength(3)
|
||||||
|
expect(rows[0]!.text()).toContain('No program')
|
||||||
|
expect(rows[0]!.text()).toContain('7 / 10')
|
||||||
|
expect(rows[0]!.text()).toContain('70%')
|
||||||
|
expect(rows[1]!.text()).toContain('Random')
|
||||||
|
// The third panel has no result yet and overrides numStudents.
|
||||||
|
expect(rows[2]!.text()).toContain('Running')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rounds the share with the shared formatPct', () => {
|
||||||
|
const halfPctResults = {
|
||||||
|
'panel-none': { ...resultsByPanelId['panel-none']!, reachedPct: 82.5 },
|
||||||
|
}
|
||||||
|
const wrapper = mount(ResultsTable, {
|
||||||
|
props: { panels: panels.slice(0, 1), resultsByPanelId: halfPctResults, base },
|
||||||
|
})
|
||||||
|
expect(wrapper.find('tbody tr').text()).toContain('83%')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to the raw strategy name for unknown strategies', () => {
|
||||||
|
const oddPanels: PanelSpec[] = [
|
||||||
|
{ id: 'panel-odd', label: 'Odd', strategy: 'telepathy', overrides: {} },
|
||||||
|
]
|
||||||
|
const wrapper = mount(ResultsTable, {
|
||||||
|
props: {
|
||||||
|
panels: oddPanels,
|
||||||
|
resultsByPanelId: { 'panel-odd': resultsByPanelId['panel-none']! },
|
||||||
|
base,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(wrapper.findAll('tbody td')[0]!.text()).toBe('telepathy')
|
||||||
|
})
|
||||||
|
})
|
||||||
87
web/src/composables/useLayout.ts
Normal file
87
web/src/composables/useLayout.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
import { forceCollide, forceLink, forceManyBody, forceSimulation, forceX, forceY } from 'd3-force'
|
||||||
|
import type { SimulationLinkDatum, SimulationNodeDatum } from 'd3-force'
|
||||||
|
import { graphKey } from '@/lib/graph'
|
||||||
|
import { mulberry32 } from '@/lib/mulberry32'
|
||||||
|
import type { Config } from '@/types/engine'
|
||||||
|
|
||||||
|
// Seeded force layout (spec section 6): d3-force run synchronously for a
|
||||||
|
// fixed number of ticks with a mulberry32 random source and seeded initial
|
||||||
|
// positions, so identical configs give identical pictures on every machine
|
||||||
|
// (the shared-URL guarantee). Layouts are cached per graph hash; panels
|
||||||
|
// that share the base graph share one layout and their differences read as
|
||||||
|
// the experiment, not as noise.
|
||||||
|
|
||||||
|
export interface LayoutPoint {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NETWORK_VIEW_WIDTH = 380
|
||||||
|
export const NETWORK_VIEW_HEIGHT = 230
|
||||||
|
const LAYOUT_PADDING = 14
|
||||||
|
const LAYOUT_TICKS = 300
|
||||||
|
|
||||||
|
interface LayoutNode extends SimulationNodeDatum {
|
||||||
|
index: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const layoutCache = new Map<string, LayoutPoint[]>()
|
||||||
|
|
||||||
|
export function clearLayoutCache() {
|
||||||
|
layoutCache.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function layoutForGraph(config: Config, edges: number[][]): LayoutPoint[] {
|
||||||
|
const cacheKey = graphKey(config)
|
||||||
|
const cached = layoutCache.get(cacheKey)
|
||||||
|
if (cached) return cached
|
||||||
|
|
||||||
|
const random = mulberry32(Number(config.graphSeed) >>> 0)
|
||||||
|
const nodes: LayoutNode[] = Array.from({ length: config.numStudents }, (_, index) => {
|
||||||
|
// Seeded start positions in a wide disc; the forces do the rest.
|
||||||
|
const angle = random() * Math.PI * 2
|
||||||
|
const radius = Math.sqrt(random())
|
||||||
|
return { index, x: Math.cos(angle) * radius * 150, y: Math.sin(angle) * radius * 60 }
|
||||||
|
})
|
||||||
|
const links: SimulationLinkDatum<LayoutNode>[] = edges.map((edge) => ({
|
||||||
|
source: edge[0] ?? 0,
|
||||||
|
target: edge[1] ?? 0,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// The vertical pull is stronger than the horizontal one, so the cloud
|
||||||
|
// settles into the panel's wide ellipse by itself; fitting then scales
|
||||||
|
// both axes uniformly, which keeps the spacing organic (anisotropic
|
||||||
|
// stretching reads as line patterns).
|
||||||
|
forceSimulation(nodes)
|
||||||
|
.randomSource(random)
|
||||||
|
.force(
|
||||||
|
'link',
|
||||||
|
forceLink<LayoutNode, SimulationLinkDatum<LayoutNode>>(links).distance(16).strength(0.3),
|
||||||
|
)
|
||||||
|
.force('charge', forceManyBody().strength(-15))
|
||||||
|
.force('x', forceX(0).strength(0.028))
|
||||||
|
.force('y', forceY(0).strength(0.22))
|
||||||
|
.force('collide', forceCollide(5.5))
|
||||||
|
.stop()
|
||||||
|
.tick(LAYOUT_TICKS)
|
||||||
|
|
||||||
|
const xs = nodes.map((node) => node.x ?? 0)
|
||||||
|
const ys = nodes.map((node) => node.y ?? 0)
|
||||||
|
const minX = Math.min(...xs)
|
||||||
|
const maxX = Math.max(...xs)
|
||||||
|
const minY = Math.min(...ys)
|
||||||
|
const maxY = Math.max(...ys)
|
||||||
|
const scale = Math.min(
|
||||||
|
(NETWORK_VIEW_WIDTH - 2 * LAYOUT_PADDING) / Math.max(maxX - minX, 1),
|
||||||
|
(NETWORK_VIEW_HEIGHT - 2 * LAYOUT_PADDING) / Math.max(maxY - minY, 1),
|
||||||
|
)
|
||||||
|
const offsetX = (NETWORK_VIEW_WIDTH - (maxX - minX) * scale) / 2
|
||||||
|
const offsetY = (NETWORK_VIEW_HEIGHT - (maxY - minY) * scale) / 2
|
||||||
|
|
||||||
|
const points = nodes.map((node) => ({
|
||||||
|
x: offsetX + ((node.x ?? 0) - minX) * scale,
|
||||||
|
y: offsetY + ((node.y ?? 0) - minY) * scale,
|
||||||
|
}))
|
||||||
|
layoutCache.set(cacheKey, points)
|
||||||
|
return points
|
||||||
|
}
|
||||||
35
web/src/composables/useTheme.ts
Normal file
35
web/src/composables/useTheme.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
// Theme handling (spec 5.11): default follows prefers-color-scheme, the
|
||||||
|
// toggle overrides and persists. Theme is never URL state. index.html
|
||||||
|
// applies the class before the app mounts to avoid a flash; this module is
|
||||||
|
// the runtime source of truth.
|
||||||
|
|
||||||
|
export type ThemeName = 'light' | 'dark'
|
||||||
|
|
||||||
|
const THEME_STORAGE_KEY = 'spreadlab-theme'
|
||||||
|
|
||||||
|
const theme = ref<ThemeName>('light')
|
||||||
|
let initialized = false
|
||||||
|
|
||||||
|
function applyTheme(nextTheme: ThemeName) {
|
||||||
|
theme.value = nextTheme
|
||||||
|
document.documentElement.classList.toggle('dark', nextTheme === 'dark')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
if (!initialized) {
|
||||||
|
initialized = true
|
||||||
|
const stored = localStorage.getItem(THEME_STORAGE_KEY)
|
||||||
|
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
applyTheme(stored === 'dark' || stored === 'light' ? stored : prefersDark ? 'dark' : 'light')
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTheme() {
|
||||||
|
const nextTheme: ThemeName = theme.value === 'dark' ? 'light' : 'dark'
|
||||||
|
localStorage.setItem(THEME_STORAGE_KEY, nextTheme)
|
||||||
|
applyTheme(nextTheme)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { theme, toggleTheme }
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@ const preset: StudyPreset = {
|
||||||
narrative: 'n',
|
narrative: 'n',
|
||||||
disclaimerShort: 'short',
|
disclaimerShort: 'short',
|
||||||
disclaimerLong: 'long',
|
disclaimerLong: 'long',
|
||||||
|
readingCaption: 'caption',
|
||||||
toneThresholdPct: 30,
|
toneThresholdPct: 30,
|
||||||
base: {
|
base: {
|
||||||
numStudents: 120,
|
numStudents: 120,
|
||||||
|
|
|
||||||
15
web/src/lib/accents.ts
Normal file
15
web/src/lib/accents.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
// Panel accent palette (spec section 6): categorical and deliberately
|
||||||
|
// distinct from the rose/teal node semantics. Assigned by panel position,
|
||||||
|
// recycled on remove.
|
||||||
|
export const PANEL_ACCENTS = [
|
||||||
|
'#6366F1',
|
||||||
|
'#F59E0B',
|
||||||
|
'#0EA5E9',
|
||||||
|
'#8B5CF6',
|
||||||
|
'#EC4899',
|
||||||
|
'#84CC16',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export function accentForPanel(panelIndex: number): string {
|
||||||
|
return PANEL_ACCENTS[panelIndex % PANEL_ACCENTS.length] ?? PANEL_ACCENTS[0]
|
||||||
|
}
|
||||||
12
web/src/lib/mulberry32.ts
Normal file
12
web/src/lib/mulberry32.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
// Deterministic 32-bit PRNG (spec section 6). The force layout must come
|
||||||
|
// out identical on every machine so a shared URL reproduces the exact
|
||||||
|
// picture; Math.random would break that guarantee.
|
||||||
|
export function mulberry32(seed: number): () => number {
|
||||||
|
let stateWord = seed | 0
|
||||||
|
return () => {
|
||||||
|
stateWord = (stateWord + 0x6d2b79f5) | 0
|
||||||
|
let mixed = Math.imul(stateWord ^ (stateWord >>> 15), 1 | stateWord)
|
||||||
|
mixed = (mixed + Math.imul(mixed ^ (mixed >>> 7), 61 | mixed)) ^ mixed
|
||||||
|
return ((mixed ^ (mixed >>> 14)) >>> 0) / 4294967296
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,9 @@ export const deepfakeSchoolPreset: StudyPreset = {
|
||||||
'illustration, not fitted to data, so it is not a validated prediction ' +
|
'illustration, not fitted to data, so it is not a validated prediction ' +
|
||||||
'of any real school. Use it to build intuition about who to educate, ' +
|
'of any real school. Use it to build intuition about who to educate, ' +
|
||||||
'not to forecast outcomes.',
|
'not to forecast outcomes.',
|
||||||
|
readingCaption:
|
||||||
|
'Each dot is one student in the same simulated year group. Every scenario ' +
|
||||||
|
'runs the identical school; only the education program differs.',
|
||||||
toneThresholdPct: 30,
|
toneThresholdPct: 30,
|
||||||
base: {
|
base: {
|
||||||
numStudents: 120,
|
numStudents: 120,
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ export interface StudyPreset {
|
||||||
narrative: string
|
narrative: string
|
||||||
disclaimerShort: string // badge text
|
disclaimerShort: string // badge text
|
||||||
disclaimerLong: string // About popover body
|
disclaimerLong: string // About popover body
|
||||||
|
readingCaption: string // one quiet line under the legend: how to read the dots
|
||||||
toneThresholdPct: number // reached% <= threshold renders "good" (teal)
|
toneThresholdPct: number // reached% <= threshold renders "good" (teal)
|
||||||
base: Config // the default world
|
base: Config // the default world
|
||||||
panels: PanelSpec[] // initial panels
|
panels: PanelSpec[] // initial panels
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue