diff --git a/web/.gitignore b/web/.gitignore index cd68f14..b5cef9b 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -37,3 +37,4 @@ __screenshots__/ # Vite *.timestamp-*-*.mjs +public/__*.html diff --git a/web/index.html b/web/index.html index 32253a8..29ca3d4 100644 --- a/web/index.html +++ b/web/index.html @@ -1,10 +1,25 @@ - + spreadlab +
diff --git a/web/package-lock.json b/web/package-lock.json index 4caba8f..a804009 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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", diff --git a/web/package.json b/web/package.json index 496a26d..01d0409 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/src/App.vue b/web/src/App.vue index 342b1f1..be6b3cb 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -1,40 +1,51 @@ diff --git a/web/src/assets/base.css b/web/src/assets/base.css deleted file mode 100644 index 8816868..0000000 --- a/web/src/assets/base.css +++ /dev/null @@ -1,86 +0,0 @@ -/* color palette from */ -: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; -} diff --git a/web/src/assets/main.css b/web/src/assets/main.css index 36fb845..ca27bfc 100644 --- a/web/src/assets/main.css +++ b/web/src/assets/main.css @@ -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; } } diff --git a/web/src/components/AboutPopover.vue b/web/src/components/AboutPopover.vue new file mode 100644 index 0000000..f7974a9 --- /dev/null +++ b/web/src/components/AboutPopover.vue @@ -0,0 +1,111 @@ + + + + + diff --git a/web/src/components/AppBar.vue b/web/src/components/AppBar.vue new file mode 100644 index 0000000..c5da0c1 --- /dev/null +++ b/web/src/components/AppBar.vue @@ -0,0 +1,184 @@ + + + + + diff --git a/web/src/components/ComparisonTable.vue b/web/src/components/ComparisonTable.vue deleted file mode 100644 index eb88996..0000000 --- a/web/src/components/ComparisonTable.vue +++ /dev/null @@ -1,63 +0,0 @@ - - - - - diff --git a/web/src/components/ErrorBanner.vue b/web/src/components/ErrorBanner.vue new file mode 100644 index 0000000..75a2611 --- /dev/null +++ b/web/src/components/ErrorBanner.vue @@ -0,0 +1,61 @@ + + + + + diff --git a/web/src/components/FooterDisclaimer.vue b/web/src/components/FooterDisclaimer.vue new file mode 100644 index 0000000..9990312 --- /dev/null +++ b/web/src/components/FooterDisclaimer.vue @@ -0,0 +1,35 @@ + + + diff --git a/web/src/components/HeroHeadline.vue b/web/src/components/HeroHeadline.vue new file mode 100644 index 0000000..f0220d7 --- /dev/null +++ b/web/src/components/HeroHeadline.vue @@ -0,0 +1,137 @@ + + + + + diff --git a/web/src/components/LegendRow.vue b/web/src/components/LegendRow.vue new file mode 100644 index 0000000..21b5350 --- /dev/null +++ b/web/src/components/LegendRow.vue @@ -0,0 +1,76 @@ + + + + + diff --git a/web/src/components/NetworkView.vue b/web/src/components/NetworkView.vue new file mode 100644 index 0000000..6e9b5e8 --- /dev/null +++ b/web/src/components/NetworkView.vue @@ -0,0 +1,98 @@ + + + + + diff --git a/web/src/components/PanelGrid.vue b/web/src/components/PanelGrid.vue new file mode 100644 index 0000000..16ff991 --- /dev/null +++ b/web/src/components/PanelGrid.vue @@ -0,0 +1,81 @@ + + + + + diff --git a/web/src/components/ResultsTable.vue b/web/src/components/ResultsTable.vue new file mode 100644 index 0000000..19bc416 --- /dev/null +++ b/web/src/components/ResultsTable.vue @@ -0,0 +1,137 @@ + + + + + diff --git a/web/src/components/ScenarioPanel.vue b/web/src/components/ScenarioPanel.vue new file mode 100644 index 0000000..f91fbb9 --- /dev/null +++ b/web/src/components/ScenarioPanel.vue @@ -0,0 +1,234 @@ + + + + + diff --git a/web/src/components/ScenarioToolbar.vue b/web/src/components/ScenarioToolbar.vue new file mode 100644 index 0000000..3d34ef9 --- /dev/null +++ b/web/src/components/ScenarioToolbar.vue @@ -0,0 +1,114 @@ + + + + + diff --git a/web/src/components/__tests__/ComparisonTable.spec.ts b/web/src/components/__tests__/ComparisonTable.spec.ts deleted file mode 100644 index 810c3b4..0000000 --- a/web/src/components/__tests__/ComparisonTable.spec.ts +++ /dev/null @@ -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') - }) -}) diff --git a/web/src/components/__tests__/ResultsTable.spec.ts b/web/src/components/__tests__/ResultsTable.spec.ts new file mode 100644 index 0000000..17d3a6f --- /dev/null +++ b/web/src/components/__tests__/ResultsTable.spec.ts @@ -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 = { + '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') + }) +}) diff --git a/web/src/composables/useLayout.ts b/web/src/composables/useLayout.ts new file mode 100644 index 0000000..9954f40 --- /dev/null +++ b/web/src/composables/useLayout.ts @@ -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() + +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[] = 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>(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 +} diff --git a/web/src/composables/useTheme.ts b/web/src/composables/useTheme.ts new file mode 100644 index 0000000..2c90c72 --- /dev/null +++ b/web/src/composables/useTheme.ts @@ -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('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 } +} diff --git a/web/src/lib/__tests__/urlState.spec.ts b/web/src/lib/__tests__/urlState.spec.ts index dae83ce..ef2af93 100644 --- a/web/src/lib/__tests__/urlState.spec.ts +++ b/web/src/lib/__tests__/urlState.spec.ts @@ -9,6 +9,7 @@ const preset: StudyPreset = { narrative: 'n', disclaimerShort: 'short', disclaimerLong: 'long', + readingCaption: 'caption', toneThresholdPct: 30, base: { numStudents: 120, diff --git a/web/src/lib/accents.ts b/web/src/lib/accents.ts new file mode 100644 index 0000000..d034d5b --- /dev/null +++ b/web/src/lib/accents.ts @@ -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] +} diff --git a/web/src/lib/mulberry32.ts b/web/src/lib/mulberry32.ts new file mode 100644 index 0000000..8ca4850 --- /dev/null +++ b/web/src/lib/mulberry32.ts @@ -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 + } +} diff --git a/web/src/presets/deepfake-school.ts b/web/src/presets/deepfake-school.ts index 9eea8dc..3cb5386 100644 --- a/web/src/presets/deepfake-school.ts +++ b/web/src/presets/deepfake-school.ts @@ -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, diff --git a/web/src/presets/types.ts b/web/src/presets/types.ts index a21e953..3dc1182 100644 --- a/web/src/presets/types.ts +++ b/web/src/presets/types.ts @@ -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