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
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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue