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
|
||||
*.timestamp-*-*.mjs
|
||||
public/__*.html
|
||||
|
|
|
|||
|
|
@ -1,10 +1,25 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
|
|
|||
126
web/package-lock.json
generated
126
web/package-lock.json
generated
|
|
@ -8,10 +8,14 @@
|
|||
"name": "web",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"d3-force": "^3.0.0",
|
||||
"d3-quadtree": "^3.0.1",
|
||||
"vue": "^3.5.32"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node24": "^24.0.4",
|
||||
"@types/d3-force": "^3.0.10",
|
||||
"@types/jsdom": "^28.0.1",
|
||||
"@types/node": "^24.12.2",
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
|
|
@ -740,35 +744,10 @@
|
|||
"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": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
||||
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
|
||||
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"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": {
|
||||
"version": "0.19.2",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
||||
|
|
@ -1659,6 +1647,40 @@
|
|||
"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": {
|
||||
"version": "1.0.3",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "4.0.2",
|
||||
"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==",
|
||||
"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": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
|
||||
|
|
|
|||
|
|
@ -16,10 +16,14 @@
|
|||
"format": "prettier --write --experimental-cli src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"d3-force": "^3.0.0",
|
||||
"d3-quadtree": "^3.0.1",
|
||||
"vue": "^3.5.32"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node24": "^24.0.4",
|
||||
"@types/d3-force": "^3.0.10",
|
||||
"@types/jsdom": "^28.0.1",
|
||||
"@types/node": "^24.12.2",
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
|
|
|
|||
|
|
@ -1,40 +1,51 @@
|
|||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { fetchDefaultConfig, runComparison } from '@/lib/api'
|
||||
import type { ComparisonResponse } from '@/types/api'
|
||||
import ComparisonTable from '@/components/ComparisonTable.vue'
|
||||
import { onMounted } from 'vue'
|
||||
import AppBar from '@/components/AppBar.vue'
|
||||
import ErrorBanner from '@/components/ErrorBanner.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 error = ref<string | null>(null)
|
||||
const store = useSimStore()
|
||||
useTheme() // resolve and apply the theme before first paint of the app
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const config = await fetchDefaultConfig()
|
||||
comparison.value = await runComparison(config)
|
||||
} catch (cause) {
|
||||
error.value = cause instanceof Error ? cause.message : String(cause)
|
||||
}
|
||||
onMounted(() => {
|
||||
void store.initialize()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main>
|
||||
<h1>spreadlab</h1>
|
||||
<p>
|
||||
How a non-consensual deepfake spreads through a simulated school year
|
||||
group, and what an education program changes. Illustrative, not
|
||||
validated.
|
||||
</p>
|
||||
<p v-if="error" role="alert">Could not reach the spreadlab API: {{ error }}</p>
|
||||
<ComparisonTable v-else-if="comparison" :comparison="comparison" />
|
||||
<p v-else>Running scenarios…</p>
|
||||
<AppBar />
|
||||
<main class="page">
|
||||
<HeroHeadline />
|
||||
<ScenarioToolbar />
|
||||
<ErrorBanner />
|
||||
<PanelGrid />
|
||||
<LegendRow />
|
||||
<ResultsTable
|
||||
:panels="store.state.panels"
|
||||
:results-by-panel-id="store.state.resultsByPanelId"
|
||||
:base="store.state.base"
|
||||
/>
|
||||
</main>
|
||||
<FooterDisclaimer />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
main {
|
||||
max-width: 48rem;
|
||||
.page {
|
||||
max-width: 1240px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1rem;
|
||||
padding: 36px 28px 30px;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.page {
|
||||
padding: 22px 16px;
|
||||
}
|
||||
}
|
||||
</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 {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
font-weight: normal;
|
||||
/* Visual tokens (spec section 6), one set per theme. Node semantics are
|
||||
identical in both themes. */
|
||||
:root {
|
||||
--bg: #f8fafc;
|
||||
--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,
|
||||
.green {
|
||||
text-decoration: none;
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
transition: 0.4s;
|
||||
padding: 3px;
|
||||
:root.dark {
|
||||
--bg: #0b1120;
|
||||
--surface: #111a2e;
|
||||
--border: #1e293b;
|
||||
--border-soft: #1a2438;
|
||||
--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 {
|
||||
background-color: hsla(160, 100%, 37%, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
body {
|
||||
display: flex;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
#app {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
padding: 0 2rem;
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font:
|
||||
15px/1.5 'Inter Variable',
|
||||
Inter,
|
||||
system-ui,
|
||||
sans-serif;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Accessibility acceptance criterion: a visible 2 px ink outline with 2 px
|
||||
offset on every focusable element, both themes. */
|
||||
:focus-visible {
|
||||
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',
|
||||
disclaimerShort: 'short',
|
||||
disclaimerLong: 'long',
|
||||
readingCaption: 'caption',
|
||||
toneThresholdPct: 30,
|
||||
base: {
|
||||
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 ' +
|
||||
'of any real school. Use it to build intuition about who to educate, ' +
|
||||
'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,
|
||||
base: {
|
||||
numStudents: 120,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ export interface StudyPreset {
|
|||
narrative: string
|
||||
disclaimerShort: string // badge text
|
||||
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)
|
||||
base: Config // the default world
|
||||
panels: PanelSpec[] // initial panels
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue