web: UI polish pass (layout, seeds, reroll, fit)
- Graph layout: fix the reroll-vs-reload determinism bug (a reroll requested
the layout before the new edges arrived, caching a link-less uniform circle;
only cache once edges are present). Retune to a slightly-wide organic blob
in a 340x270 viewBox so it fills the square-ish cells without flat ovals.
- Reroll world: moved out of Advanced into a button in the WORLD card header;
any seed reroll (header or per-seed dice) now replays the spread from round 0
(pendingReplay flag in the store, watched by playback). freshSeed extracted
to lib/seed.ts.
- Seeds: separated from the real quantities under a 'Seeds' header ('labels for
a random world, not counts') with a # prefix so a value reads as an id.
- Fit: grid rows subtract the container padding so the 2x2 fits exactly and the
bottom card shadows are no longer clipped; the settings card absolutely fills
the sidebar so the graph grid defines the height and the card scrolls
internally instead of growing taller than the graphs.
This commit is contained in:
parent
6b2cbd5728
commit
318dca3423
8 changed files with 180 additions and 65 deletions
|
|
@ -163,15 +163,15 @@ onMounted(async () => {
|
|||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* The settings card fills the sidebar so its height matches the two-row
|
||||
graph grid; it scrolls internally only if its own content is taller. */
|
||||
/* The card absolutely fills the sidebar, so its own content never grows the
|
||||
row: the graph grid defines the height, the sidebar matches it, and the
|
||||
card scrolls internally if its content (e.g. Advanced expanded) is taller. */
|
||||
.sidebar :deep(.card) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -79,21 +79,6 @@ function setField(field: keyof Config, event: Event) {
|
|||
if (Number.isFinite(nextValue)) store.setBaseField(field, nextValue)
|
||||
}
|
||||
|
||||
// Seeds are just names for worlds; a small range keeps the fields and
|
||||
// shared URLs readable while still giving plenty of distinct worlds.
|
||||
function freshSeed(): number {
|
||||
const buffer = new Uint32Array(1)
|
||||
crypto.getRandomValues(buffer)
|
||||
return (buffer[0] ?? 0) % 10000
|
||||
}
|
||||
|
||||
function reroll(field: keyof Config) {
|
||||
store.setBaseField(field, freshSeed())
|
||||
}
|
||||
|
||||
function rerollWorld() {
|
||||
for (const { field } of seedFields) store.setBaseField(field, freshSeed())
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -120,6 +105,13 @@ function rerollWorld() {
|
|||
@change="setField(def.field, $event)"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="seeds-head">
|
||||
<span class="seeds-title">Seeds</span>
|
||||
<span class="seeds-note">labels for a random world, not counts</span>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<span
|
||||
v-for="def in seedFields"
|
||||
:key="def.field"
|
||||
|
|
@ -128,6 +120,8 @@ function rerollWorld() {
|
|||
>
|
||||
<label class="seed">
|
||||
<span class="fl">{{ def.label }}</span>
|
||||
<span class="seedval">
|
||||
<span class="hash" aria-hidden="true">#</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
|
|
@ -136,12 +130,13 @@ function rerollWorld() {
|
|||
:aria-invalid="offendingField === def.field || undefined"
|
||||
@change="setField(def.field, $event)"
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
<button
|
||||
class="dice"
|
||||
type="button"
|
||||
:aria-label="`Reroll ${def.label}`"
|
||||
@click="reroll(def.field)"
|
||||
@click="store.rerollSeed(def.field)"
|
||||
>
|
||||
<svg class="ic" viewBox="0 0 24 24">
|
||||
<rect x="4" y="4" width="16" height="16" rx="3" />
|
||||
|
|
@ -150,7 +145,6 @@ function rerollWorld() {
|
|||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<button class="reroll-world" type="button" @click="rerollWorld()">Reroll world</button>
|
||||
</details>
|
||||
</template>
|
||||
|
||||
|
|
@ -226,6 +220,44 @@ details[open] .chevron {
|
|||
min-width: 0;
|
||||
}
|
||||
|
||||
.seeds-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.seeds-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-4);
|
||||
}
|
||||
|
||||
.seeds-note {
|
||||
font-size: 11.5px;
|
||||
font-weight: 400;
|
||||
color: var(--ink-4);
|
||||
}
|
||||
|
||||
.seedval {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.hash {
|
||||
color: var(--ink-4);
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.seedval input[type='number'] {
|
||||
width: 52px;
|
||||
}
|
||||
|
||||
input[type='number'] {
|
||||
width: 72px;
|
||||
font: 600 13px/1.4 inherit;
|
||||
|
|
@ -258,23 +290,6 @@ input[type='number'] {
|
|||
height: 14px;
|
||||
}
|
||||
|
||||
.reroll-world {
|
||||
margin-top: 12px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: var(--ink-3);
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 5px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.reroll-world:hover {
|
||||
border-color: var(--ink-4);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* Single column when the controls container is narrow (sidebar / mobile). */
|
||||
@container controls (max-width: 380px) {
|
||||
.grid {
|
||||
|
|
|
|||
|
|
@ -33,7 +33,16 @@ const offendsProgramEffect = offends('programEffect')
|
|||
|
||||
<template>
|
||||
<section class="card ctl" aria-label="World settings">
|
||||
<div class="ctl-head">
|
||||
<h3>World</h3>
|
||||
<button class="reroll" type="button" title="Draw a new random world" @click="store.rerollSeeds()">
|
||||
<svg class="ic" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<rect x="4" y="4" width="16" height="16" rx="3" />
|
||||
<path d="M9 9h.01M15 15h.01M15 9h.01M9 15h.01" />
|
||||
</svg>
|
||||
Reroll world
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="store.state.validationError" class="invalid-note" role="alert">
|
||||
{{ store.state.validationError }}
|
||||
</p>
|
||||
|
|
@ -122,6 +131,13 @@ const offendsProgramEffect = offends('programEffect')
|
|||
container-name: controls;
|
||||
}
|
||||
|
||||
.ctl-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
|
|
@ -130,6 +146,35 @@ h3 {
|
|||
color: var(--ink-4);
|
||||
}
|
||||
|
||||
.reroll {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--ink-3);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 4px 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.reroll:hover {
|
||||
border-color: var(--ink-4);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.reroll svg.ic {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.group + .group {
|
||||
margin-top: 22px;
|
||||
border-top: 1px solid var(--border-soft);
|
||||
|
|
|
|||
|
|
@ -40,7 +40,12 @@ function syncRowHeight() {
|
|||
const element = gridElement.value
|
||||
if (!element) return
|
||||
if (oneScreen.matches) {
|
||||
const rowHeight = Math.max(150, Math.floor((element.clientHeight - ROW_GAP_PX) / 2))
|
||||
// clientHeight includes the container's padding (which gives the card
|
||||
// shadows room); subtract it so two rows fit the content area exactly.
|
||||
const style = getComputedStyle(element)
|
||||
const padV = parseFloat(style.paddingTop) + parseFloat(style.paddingBottom)
|
||||
const available = element.clientHeight - padV
|
||||
const rowHeight = Math.max(150, Math.floor((available - ROW_GAP_PX) / 2))
|
||||
element.style.setProperty('--row-h', `${rowHeight}px`)
|
||||
} else {
|
||||
element.style.removeProperty('--row-h')
|
||||
|
|
@ -166,9 +171,10 @@ onBeforeUnmount(() => {
|
|||
@media (min-width: 761px) {
|
||||
.panelgrid {
|
||||
overflow-y: auto;
|
||||
/* Reserve room so the scrollbar (overlay or classic) never sits over the
|
||||
right column of graphs when a 5th scenario makes the grid scroll. */
|
||||
padding-right: 14px;
|
||||
/* Room so the card shadows are not clipped at the scroll edges; the right
|
||||
also leaves the scrollbar gutter. syncRowHeight subtracts this padding
|
||||
so the 2x2 still fits exactly. */
|
||||
padding: 4px 14px 14px 6px;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ export interface LayoutPoint {
|
|||
y: number
|
||||
}
|
||||
|
||||
export const NETWORK_VIEW_WIDTH = 380
|
||||
export const NETWORK_VIEW_HEIGHT = 230
|
||||
export const NETWORK_VIEW_WIDTH = 340
|
||||
export const NETWORK_VIEW_HEIGHT = 270
|
||||
const LAYOUT_PADDING = 14
|
||||
const LAYOUT_TICKS = 300
|
||||
|
||||
|
|
@ -38,29 +38,31 @@ export function layoutForGraph(config: Config, edges: number[][]): LayoutPoint[]
|
|||
|
||||
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.
|
||||
// Seeded start positions in a balanced 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 }
|
||||
return { index, x: Math.cos(angle) * radius * 120, y: Math.sin(angle) * radius * 90 }
|
||||
})
|
||||
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).
|
||||
// A gentle horizontal pull with a stronger vertical one settles the cloud
|
||||
// into a slightly wide blob (a softer version of the original ellipse) while
|
||||
// staying organic: its hubs and clusters show, not a uniform disc. The
|
||||
// charge, link and collision forces do the structural work; fitting then
|
||||
// scales both axes uniformly, so the spacing stays organic (anisotropic
|
||||
// stretching would read 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('charge', forceManyBody().strength(-16))
|
||||
.force('x', forceX(0).strength(0.04))
|
||||
.force('y', forceY(0).strength(0.085))
|
||||
.force('collide', forceCollide(5.5))
|
||||
.stop()
|
||||
.tick(LAYOUT_TICKS)
|
||||
|
|
@ -82,6 +84,10 @@ export function layoutForGraph(config: Config, edges: number[][]): LayoutPoint[]
|
|||
x: offsetX + ((node.x ?? 0) - minX) * scale,
|
||||
y: offsetY + ((node.y ?? 0) - minY) * scale,
|
||||
}))
|
||||
layoutCache.set(cacheKey, points)
|
||||
// Only cache once the real edges are in. A reroll changes the config (and
|
||||
// this cache key) immediately, before the new edges arrive from the API;
|
||||
// computing then would cache a link-less, uniform-circle layout under this
|
||||
// key and never recompute it. Without edges, recompute next time instead.
|
||||
if (edges.length > 0) layoutCache.set(cacheKey, points)
|
||||
return points
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { watch } from 'vue'
|
||||
import { useSimStore, type SimStore } from './useSimStore'
|
||||
|
||||
// Playback (spec 5.4): one global round drives every panel, the chart
|
||||
|
|
@ -142,6 +143,21 @@ export function createPlayback(store: SimStore = useSimStore()) {
|
|||
play()
|
||||
}
|
||||
|
||||
// A reroll (rerollSeed / rerollSeeds) asks for a fresh playthrough: once its
|
||||
// debounced run lands (running -> idle), restart the spread from round 0.
|
||||
watch(
|
||||
() => state.runState,
|
||||
(now, before) => {
|
||||
if (!state.pendingReplay) return
|
||||
if (now === 'idle' && before === 'running') {
|
||||
state.pendingReplay = false
|
||||
replay()
|
||||
} else if (now === 'error') {
|
||||
state.pendingReplay = false
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
play,
|
||||
pause,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { ApiError, fetchDefaultConfig, runScenario } from '@/lib/api'
|
|||
import { clampConfig, clampConfigField } from '@/lib/bounds'
|
||||
import { roundPct } from '@/lib/format'
|
||||
import { graphKey } from '@/lib/graph'
|
||||
import { freshSeed } from '@/lib/seed'
|
||||
import { parseUrlState, serializeUrlState } from '@/lib/urlState'
|
||||
import { deepfakeSchoolPreset } from '@/presets/deepfake-school'
|
||||
import { MAX_PANELS, type PanelSpec, type StudyPreset } from '@/presets/types'
|
||||
|
|
@ -44,6 +45,7 @@ export interface SimState {
|
|||
validationError: string | null // 400 from the engine, shown inline under the controls
|
||||
urlStateInvalid: boolean // the opened link held malformed state; preset shown instead
|
||||
announcement: string // text for the single polite live region (spec section 8)
|
||||
pendingReplay: boolean // a reroll asked playback to restart from round 0 once the run lands
|
||||
}
|
||||
|
||||
function createPanelId(): string {
|
||||
|
|
@ -74,6 +76,7 @@ export function createSimStore(preset: StudyPreset = deepfakeSchoolPreset) {
|
|||
validationError: null,
|
||||
urlStateInvalid: false,
|
||||
announcement: '',
|
||||
pendingReplay: false,
|
||||
})
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
|
@ -195,6 +198,20 @@ export function createSimStore(preset: StudyPreset = deepfakeSchoolPreset) {
|
|||
scheduleRun()
|
||||
}
|
||||
|
||||
// Seeds name the random world. Rerolling one (or all) draws fresh values
|
||||
// and asks playback to replay the spread from round 0 once the rerun lands.
|
||||
const SEED_FIELDS = ['graphSeed', 'thresholdSeed', 'educationSeed'] as const
|
||||
|
||||
function rerollSeed(field: keyof Config) {
|
||||
state.pendingReplay = true
|
||||
setBaseField(field, freshSeed())
|
||||
}
|
||||
|
||||
function rerollSeeds() {
|
||||
state.pendingReplay = true
|
||||
for (const field of SEED_FIELDS) setBaseField(field, freshSeed())
|
||||
}
|
||||
|
||||
// Re-clamp the base config and every panel override; needed when
|
||||
// numStudents changes (the relational ceilings move) and when the
|
||||
// bounds first arrive while the URL may have carried wild values.
|
||||
|
|
@ -351,6 +368,8 @@ export function createSimStore(preset: StudyPreset = deepfakeSchoolPreset) {
|
|||
initialize,
|
||||
retry,
|
||||
setBaseField,
|
||||
rerollSeed,
|
||||
rerollSeeds,
|
||||
addPanel,
|
||||
duplicatePanel,
|
||||
removePanel,
|
||||
|
|
|
|||
8
web/src/lib/seed.ts
Normal file
8
web/src/lib/seed.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// Seeds are just names for a random world. A small range (0-9999) keeps the
|
||||
// fields and shared URLs readable while still giving plenty of distinct
|
||||
// worlds (amended 2026-06-10: a full uint32 made fields and URLs unreadable).
|
||||
export function freshSeed(): number {
|
||||
const buffer = new Uint32Array(1)
|
||||
crypto.getRandomValues(buffer)
|
||||
return (buffer[0] ?? 0) % 10000
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue