web: panel management, slice 7 of M3
The kebab menu goes live: Edit and Rename open the PanelEditorPopover (rename focuses the label in its header), Duplicate copies with a ' copy' suffix and the next accent, Remove deletes immediately and is disabled on the last panel. Add scenario opens the new panel's editor right away, still capped at six. The editor offers the strategy select plus every Config field as a row against the base value: a differing value becomes an override (highlighted row, per-field reset, chips on the card), typing the base value back clears it, and runs touch only that panel through the debounce. Engine 400s appear inline in the popover with the offending field marked. Per-panel failures now leave a rose dot on the kebab while the panel keeps its last good result; the store tracks failed panel ids and clears them on the next successful run. Field and strategy labels moved to lib/fieldLabels so controls, editor, and table share one vocabulary.
This commit is contained in:
parent
fdc28ac686
commit
eb1f7427c4
9 changed files with 545 additions and 20 deletions
|
|
@ -1,6 +1,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useSimStore } from '@/composables/useSimStore'
|
import { useSimStore } from '@/composables/useSimStore'
|
||||||
|
import { CONFIG_FIELD_LABELS } from '@/lib/fieldLabels'
|
||||||
import type { Config } from '@/types/engine'
|
import type { Config } from '@/types/engine'
|
||||||
|
|
||||||
// The collapsible advanced grid (spec section 4): world-shaping numbers
|
// The collapsible advanced grid (spec section 4): world-shaping numbers
|
||||||
|
|
@ -17,10 +18,10 @@ interface NumberFieldDef {
|
||||||
}
|
}
|
||||||
|
|
||||||
const numberFields: NumberFieldDef[] = [
|
const numberFields: NumberFieldDef[] = [
|
||||||
{ field: 'numStudents', label: 'Students', min: 2, step: 1 },
|
{ field: 'numStudents', label: CONFIG_FIELD_LABELS.numStudents, min: 2, step: 1 },
|
||||||
{ field: 'edgesPerNode', label: 'Friends per student', min: 1, step: 1 },
|
{ field: 'edgesPerNode', label: CONFIG_FIELD_LABELS.edgesPerNode, min: 1, step: 1 },
|
||||||
{ field: 'triangleProb', label: 'Clique tendency', min: 0, step: 0.05 },
|
{ field: 'triangleProb', label: CONFIG_FIELD_LABELS.triangleProb, min: 0, step: 0.05 },
|
||||||
{ field: 'origin', label: 'First poster', min: 0, step: 1 },
|
{ field: 'origin', label: CONFIG_FIELD_LABELS.origin, min: 0, step: 1 },
|
||||||
]
|
]
|
||||||
|
|
||||||
interface SeedFieldDef {
|
interface SeedFieldDef {
|
||||||
|
|
@ -29,9 +30,9 @@ interface SeedFieldDef {
|
||||||
}
|
}
|
||||||
|
|
||||||
const seedFields: SeedFieldDef[] = [
|
const seedFields: SeedFieldDef[] = [
|
||||||
{ field: 'graphSeed', label: 'Friendship network' },
|
{ field: 'graphSeed', label: CONFIG_FIELD_LABELS.graphSeed },
|
||||||
{ field: 'thresholdSeed', label: 'Who resists' },
|
{ field: 'thresholdSeed', label: CONFIG_FIELD_LABELS.thresholdSeed },
|
||||||
{ field: 'educationSeed', label: 'Random picks' },
|
{ field: 'educationSeed', label: CONFIG_FIELD_LABELS.educationSeed },
|
||||||
]
|
]
|
||||||
|
|
||||||
// The engine's 400 message names the offending Go/JSON field; mark it.
|
// The engine's 400 message names the offending Go/JSON field; mark it.
|
||||||
|
|
|
||||||
297
web/src/components/PanelEditorPopover.vue
Normal file
297
web/src/components/PanelEditorPopover.vue
Normal file
|
|
@ -0,0 +1,297 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
|
import { useSimStore } from '@/composables/useSimStore'
|
||||||
|
import { CONFIG_FIELD_LABELS, STRATEGY_LABELS } from '@/lib/fieldLabels'
|
||||||
|
import type { PanelSpec } from '@/presets/types'
|
||||||
|
import type { Config } from '@/types/engine'
|
||||||
|
|
||||||
|
// The per-panel config diff editor (spec 5.7): the label is renamed
|
||||||
|
// inline in the header, the strategy is a select, and every Config field
|
||||||
|
// is a row showing the base value. Typing a different value creates an
|
||||||
|
// override (highlighted row, reset icon); typing the base value back
|
||||||
|
// removes it. Apply follows the store's debounced single-panel rerun.
|
||||||
|
|
||||||
|
const props = defineProps<{ panel: PanelSpec; focusLabel?: boolean }>()
|
||||||
|
const emit = defineEmits<{ close: [] }>()
|
||||||
|
|
||||||
|
const store = useSimStore()
|
||||||
|
|
||||||
|
const popover = ref<HTMLElement | null>(null)
|
||||||
|
const labelInput = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
const fieldNames = Object.keys(store.state.base) as (keyof Config)[]
|
||||||
|
|
||||||
|
const offendingField = computed(() => {
|
||||||
|
const validationError = store.state.validationError
|
||||||
|
if (!validationError) return null
|
||||||
|
return fieldNames.find((field) => validationError.includes(field)) ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
function isOverridden(field: keyof Config): boolean {
|
||||||
|
return field in props.panel.overrides
|
||||||
|
}
|
||||||
|
|
||||||
|
function effectiveValue(field: keyof Config): number {
|
||||||
|
return props.panel.overrides[field] ?? store.state.base[field]
|
||||||
|
}
|
||||||
|
|
||||||
|
function setOverride(field: keyof Config, event: Event) {
|
||||||
|
const nextValue = (event.target as HTMLInputElement).valueAsNumber
|
||||||
|
if (Number.isFinite(nextValue)) store.setPanelOverride(props.panel.id, field, nextValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
function rename(event: Event) {
|
||||||
|
const label = (event.target as HTMLInputElement).value.trim()
|
||||||
|
if (label) store.renamePanel(props.panel.id, label)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape') emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerDown(event: PointerEvent) {
|
||||||
|
if (popover.value?.contains(event.target as Node)) return
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.focusLabel) {
|
||||||
|
labelInput.value?.focus()
|
||||||
|
labelInput.value?.select()
|
||||||
|
} else {
|
||||||
|
popover.value?.querySelector('select')?.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="editor"
|
||||||
|
role="dialog"
|
||||||
|
:aria-label="`Edit scenario ${panel.label}`"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<div class="head">
|
||||||
|
<input
|
||||||
|
ref="labelInput"
|
||||||
|
class="label-input"
|
||||||
|
type="text"
|
||||||
|
:value="panel.label"
|
||||||
|
aria-label="Scenario name"
|
||||||
|
@change="rename"
|
||||||
|
/>
|
||||||
|
<button class="close" type="button" aria-label="Close editor" @click="emit('close')">
|
||||||
|
<svg class="ic" viewBox="0 0 24 24"><path d="M6 6l12 12M18 6L6 18" /></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="store.state.validationError" class="invalid-note" role="alert">
|
||||||
|
{{ store.state.validationError }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label class="row strategy">
|
||||||
|
<span class="rl">Strategy</span>
|
||||||
|
<select
|
||||||
|
:value="panel.strategy"
|
||||||
|
@change="store.setPanelStrategy(panel.id, ($event.target as HTMLSelectElement).value)"
|
||||||
|
>
|
||||||
|
<option v-for="(label, strategy) in STRATEGY_LABELS" :key="strategy" :value="strategy">
|
||||||
|
{{ label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="rows">
|
||||||
|
<div
|
||||||
|
v-for="field in fieldNames"
|
||||||
|
:key="field"
|
||||||
|
class="row"
|
||||||
|
:class="{ overridden: isOverridden(field), invalid: offendingField === field }"
|
||||||
|
>
|
||||||
|
<label class="rl" :for="`${panel.id}-${field}`">{{ CONFIG_FIELD_LABELS[field] }}</label>
|
||||||
|
<span class="base" :class="{ hidden: !isOverridden(field) }"
|
||||||
|
>base {{ store.state.base[field] }}</span
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
:id="`${panel.id}-${field}`"
|
||||||
|
type="number"
|
||||||
|
:value="effectiveValue(field)"
|
||||||
|
:aria-invalid="offendingField === field || undefined"
|
||||||
|
@change="setOverride(field, $event)"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
v-if="isOverridden(field)"
|
||||||
|
class="reset"
|
||||||
|
type="button"
|
||||||
|
:aria-label="`Reset ${CONFIG_FIELD_LABELS[field]} to the base value`"
|
||||||
|
@click="store.clearPanelOverride(panel.id, field)"
|
||||||
|
>
|
||||||
|
<svg class="ic" viewBox="0 0 24 24"><path d="M3 12a9 9 0 1 0 2.6-6.3M5 3v4h4" /></svg>
|
||||||
|
</button>
|
||||||
|
<span v-else class="reset-spacer" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.editor {
|
||||||
|
position: absolute;
|
||||||
|
top: 44px;
|
||||||
|
right: 12px;
|
||||||
|
z-index: 25;
|
||||||
|
width: min(300px, calc(100% - 24px));
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: var(--shadow-lift);
|
||||||
|
padding: 12px 14px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font: 600 13.5px/1.4 inherit;
|
||||||
|
font-family: inherit;
|
||||||
|
color: var(--ink);
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 5px 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--ink-3);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close:hover {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close svg.ic {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invalid-note {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--spread);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rows {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 5px 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row.strategy {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 5px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row.overridden {
|
||||||
|
background: var(--edu-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row.invalid {
|
||||||
|
outline: 1px solid var(--spread);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rl {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
color: var(--ink-3);
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--ink-4);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base.hidden {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
font: 600 12.5px/1.4 inherit;
|
||||||
|
font-family: inherit;
|
||||||
|
color: var(--ink-2);
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='number'] {
|
||||||
|
width: 72px;
|
||||||
|
font: 600 12.5px/1.4 inherit;
|
||||||
|
font-family: inherit;
|
||||||
|
color: var(--ink-2);
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reset {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--edu);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reset svg.ic {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reset-spacer {
|
||||||
|
width: 22px;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
107
web/src/components/PanelMenu.vue
Normal file
107
web/src/components/PanelMenu.vue
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
|
|
||||||
|
// The kebab dropdown (spec 5.7). Items come from the panel; the menu
|
||||||
|
// handles roving focus (arrows), Esc, and outside clicks. The opener
|
||||||
|
// restores focus on close.
|
||||||
|
|
||||||
|
export interface PanelMenuItem {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{ items: PanelMenuItem[]; anchor: HTMLElement | null }>()
|
||||||
|
const emit = defineEmits<{ select: [key: string]; close: [] }>()
|
||||||
|
|
||||||
|
const menu = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
function menuButtons(): HTMLButtonElement[] {
|
||||||
|
return Array.from(menu.value?.querySelectorAll('button:not(:disabled)') ?? [])
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
emit('close')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return
|
||||||
|
event.preventDefault()
|
||||||
|
const buttons = menuButtons()
|
||||||
|
if (buttons.length === 0) return
|
||||||
|
const activeIndex = buttons.findIndex((button) => button === document.activeElement)
|
||||||
|
const offset = event.key === 'ArrowDown' ? 1 : -1
|
||||||
|
const nextIndex = (activeIndex + offset + buttons.length) % buttons.length
|
||||||
|
buttons[nextIndex]?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerDown(event: PointerEvent) {
|
||||||
|
const target = event.target as Node
|
||||||
|
if (menu.value?.contains(target)) return
|
||||||
|
if (props.anchor?.contains(target)) return
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
menuButtons()[0]?.focus()
|
||||||
|
document.addEventListener('pointerdown', onPointerDown, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener('pointerdown', onPointerDown, true)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="menu" class="menu" role="menu" @keydown="onKeydown">
|
||||||
|
<button
|
||||||
|
v-for="item in items"
|
||||||
|
:key="item.key"
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
:disabled="item.disabled"
|
||||||
|
@click="emit('select', item.key)"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
right: 0;
|
||||||
|
z-index: 25;
|
||||||
|
min-width: 150px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: var(--shadow-lift);
|
||||||
|
padding: 5px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
text-align: left;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--ink-2);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover:not(:disabled) {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
color: var(--ink-4);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { STRATEGY_LABELS } from '@/lib/fieldLabels'
|
||||||
import { formatPct } from '@/lib/format'
|
import { formatPct } from '@/lib/format'
|
||||||
import type { PanelSpec } from '@/presets/types'
|
import type { PanelSpec } from '@/presets/types'
|
||||||
import type { Config, Result } from '@/types/engine'
|
import type { Config, Result } from '@/types/engine'
|
||||||
|
|
@ -13,12 +14,6 @@ const props = defineProps<{
|
||||||
base: Config
|
base: Config
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const strategyLabels: Record<string, string> = {
|
|
||||||
none: 'None',
|
|
||||||
random: 'Random',
|
|
||||||
'most-connected': 'Most connected',
|
|
||||||
}
|
|
||||||
|
|
||||||
function numStudentsFor(panel: PanelSpec): number {
|
function numStudentsFor(panel: PanelSpec): number {
|
||||||
return panel.overrides.numStudents ?? props.base.numStudents
|
return panel.overrides.numStudents ?? props.base.numStudents
|
||||||
}
|
}
|
||||||
|
|
@ -48,7 +43,7 @@ function numStudentsFor(panel: PanelSpec): number {
|
||||||
<tr v-for="panel in panels" :key="panel.id">
|
<tr v-for="panel in panels" :key="panel.id">
|
||||||
<th scope="row">{{ panel.label }}</th>
|
<th scope="row">{{ panel.label }}</th>
|
||||||
<template v-if="resultsByPanelId[panel.id]">
|
<template v-if="resultsByPanelId[panel.id]">
|
||||||
<td>{{ strategyLabels[panel.strategy] ?? panel.strategy }}</td>
|
<td>{{ STRATEGY_LABELS[panel.strategy] ?? panel.strategy }}</td>
|
||||||
<td>{{ resultsByPanelId[panel.id]!.educated.length }}</td>
|
<td>{{ resultsByPanelId[panel.id]!.educated.length }}</td>
|
||||||
<td>{{ resultsByPanelId[panel.id]!.numReached }} / {{ numStudentsFor(panel) }}</td>
|
<td>{{ resultsByPanelId[panel.id]!.numReached }} / {{ numStudentsFor(panel) }}</td>
|
||||||
<td>{{ formatPct(resultsByPanelId[panel.id]!.reachedPct) }}</td>
|
<td>{{ formatPct(resultsByPanelId[panel.id]!.reachedPct) }}</td>
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,59 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import NetworkView from './NetworkView.vue'
|
import NetworkView from './NetworkView.vue'
|
||||||
|
import PanelEditorPopover from './PanelEditorPopover.vue'
|
||||||
|
import PanelMenu, { type PanelMenuItem } from './PanelMenu.vue'
|
||||||
import { useSimStore } from '@/composables/useSimStore'
|
import { useSimStore } from '@/composables/useSimStore'
|
||||||
import { roundPct } from '@/lib/format'
|
import { roundPct } from '@/lib/format'
|
||||||
import { reachedCountAtRound } from '@/lib/reach'
|
import { reachedCountAtRound } from '@/lib/reach'
|
||||||
import type { PanelSpec } from '@/presets/types'
|
import { MAX_PANELS, type PanelSpec } from '@/presets/types'
|
||||||
|
|
||||||
const props = defineProps<{ panel: PanelSpec; accent: string }>()
|
const props = defineProps<{ panel: PanelSpec; accent: string }>()
|
||||||
|
|
||||||
const store = useSimStore()
|
const store = useSimStore()
|
||||||
|
|
||||||
|
const menuOpen = ref(false)
|
||||||
|
const focusLabelOnOpen = ref(false)
|
||||||
|
const kebabButton = ref<HTMLButtonElement | null>(null)
|
||||||
|
|
||||||
|
const editing = computed(() => store.state.editingPanelId === props.panel.id)
|
||||||
|
const failed = computed(() => store.state.failedPanelIds.has(props.panel.id))
|
||||||
|
|
||||||
|
const menuItems = computed<PanelMenuItem[]>(() => [
|
||||||
|
{ key: 'edit', label: 'Edit' },
|
||||||
|
{ key: 'rename', label: 'Rename' },
|
||||||
|
{ key: 'duplicate', label: 'Duplicate', disabled: store.state.panels.length >= MAX_PANELS },
|
||||||
|
{ key: 'remove', label: 'Remove', disabled: store.state.panels.length <= 1 },
|
||||||
|
])
|
||||||
|
|
||||||
|
function onMenuSelect(itemKey: string) {
|
||||||
|
menuOpen.value = false
|
||||||
|
switch (itemKey) {
|
||||||
|
case 'edit':
|
||||||
|
case 'rename':
|
||||||
|
focusLabelOnOpen.value = itemKey === 'rename'
|
||||||
|
store.state.editingPanelId = props.panel.id
|
||||||
|
break
|
||||||
|
case 'duplicate':
|
||||||
|
store.duplicatePanel(props.panel.id)
|
||||||
|
kebabButton.value?.focus()
|
||||||
|
break
|
||||||
|
case 'remove':
|
||||||
|
store.removePanel(props.panel.id)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMenu() {
|
||||||
|
menuOpen.value = false
|
||||||
|
kebabButton.value?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditor() {
|
||||||
|
store.state.editingPanelId = null
|
||||||
|
kebabButton.value?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
const result = computed(() => store.state.resultsByPanelId[props.panel.id])
|
const result = computed(() => store.state.resultsByPanelId[props.panel.id])
|
||||||
const numStudents = computed(() => store.effectiveConfig(props.panel).numStudents)
|
const numStudents = computed(() => store.effectiveConfig(props.panel).numStudents)
|
||||||
|
|
||||||
|
|
@ -50,10 +94,36 @@ const dimmed = computed(() => store.state.runState === 'running' && result.value
|
||||||
<div class="head">
|
<div class="head">
|
||||||
<span class="swatch" :style="{ background: accent }" aria-hidden="true" />
|
<span class="swatch" :style="{ background: accent }" aria-hidden="true" />
|
||||||
<span class="label">{{ panel.label }}</span>
|
<span class="label">{{ panel.label }}</span>
|
||||||
<button class="kebab" type="button" disabled title="Scenario menu arrives in a later slice">
|
<button
|
||||||
|
ref="kebabButton"
|
||||||
|
class="kebab"
|
||||||
|
type="button"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
:aria-expanded="menuOpen"
|
||||||
|
:aria-label="`Scenario menu for ${panel.label}`"
|
||||||
|
@click="menuOpen = !menuOpen"
|
||||||
|
>
|
||||||
<svg class="ic" viewBox="0 0 24 24"><path d="M12 6h.01M12 12h.01M12 18h.01" /></svg>
|
<svg class="ic" viewBox="0 0 24 24"><path d="M12 6h.01M12 12h.01M12 18h.01" /></svg>
|
||||||
|
<span
|
||||||
|
v-if="failed"
|
||||||
|
class="fail-dot"
|
||||||
|
:title="`The last run of ${panel.label} failed; showing its previous result`"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
<PanelMenu
|
||||||
|
v-if="menuOpen"
|
||||||
|
:items="menuItems"
|
||||||
|
:anchor="kebabButton"
|
||||||
|
@select="onMenuSelect"
|
||||||
|
@close="closeMenu"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<PanelEditorPopover
|
||||||
|
v-if="editing"
|
||||||
|
:panel="panel"
|
||||||
|
:focus-label="focusLabelOnOpen"
|
||||||
|
@close="closeEditor"
|
||||||
|
/>
|
||||||
<template v-if="result">
|
<template v-if="result">
|
||||||
<div class="pct" :class="tone">{{ roundPct(reachedPctNow) }}<small>%</small></div>
|
<div class="pct" :class="tone">{{ roundPct(reachedPctNow) }}<small>%</small></div>
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
|
|
@ -104,6 +174,7 @@ const dimmed = computed(() => store.state.runState === 'running' && result.value
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.swatch {
|
.swatch {
|
||||||
|
|
@ -134,10 +205,23 @@ const dimmed = computed(() => store.state.runState === 'running' && result.value
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.kebab:disabled {
|
.kebab:hover {
|
||||||
cursor: default;
|
background: var(--bg);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fail-dot {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
right: 2px;
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--spread);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pct {
|
.pct {
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,12 @@ const atPanelCap = computed(() => store.state.panels.length >= MAX_PANELS)
|
||||||
const refreshing = computed(
|
const refreshing = computed(
|
||||||
() => store.state.runState === 'running' && Object.keys(store.state.resultsByPanelId).length > 0,
|
() => store.state.runState === 'running' && Object.keys(store.state.resultsByPanelId).length > 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Spec 5.6: a new panel opens its editor right away.
|
||||||
|
function addScenario() {
|
||||||
|
const addedPanel = store.addPanel()
|
||||||
|
if (addedPanel) store.state.editingPanelId = addedPanel.id
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -27,7 +33,7 @@ const refreshing = computed(
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="atPanelCap"
|
:disabled="atPanelCap"
|
||||||
:title="atPanelCap ? 'Maximum 6 scenarios' : undefined"
|
:title="atPanelCap ? 'Maximum 6 scenarios' : undefined"
|
||||||
@click="store.addPanel()"
|
@click="addScenario()"
|
||||||
>
|
>
|
||||||
<svg class="ic" viewBox="0 0 24 24"><path d="M12 6v12M6 12h12" /></svg>
|
<svg class="ic" viewBox="0 0 24 24"><path d="M12 6v12M6 12h12" /></svg>
|
||||||
Add scenario
|
Add scenario
|
||||||
|
|
|
||||||
|
|
@ -152,11 +152,14 @@ describe('useSimStore runs', () => {
|
||||||
expect(store.state.errorMessage).toBe('fetch failed')
|
expect(store.state.errorMessage).toBe('fetch failed')
|
||||||
expect(store.state.validationError).toBeNull()
|
expect(store.state.validationError).toBeNull()
|
||||||
expect(store.state.resultsByPanelId).toEqual(goodResults)
|
expect(store.state.resultsByPanelId).toEqual(goodResults)
|
||||||
|
// Every panel in the failed run gets the rose kebab dot.
|
||||||
|
expect(store.state.failedPanelIds.size).toBe(3)
|
||||||
|
|
||||||
runScenarioMock.mockImplementation(async (request) => fakeResponse(request))
|
runScenarioMock.mockImplementation(async (request) => fakeResponse(request))
|
||||||
await store.retry()
|
await store.retry()
|
||||||
expect(store.state.runState).toBe('idle')
|
expect(store.state.runState).toBe('idle')
|
||||||
expect(store.state.errorMessage).toBeNull()
|
expect(store.state.errorMessage).toBeNull()
|
||||||
|
expect(store.state.failedPanelIds.size).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('routes a 400 to validationError instead of the banner', async () => {
|
it('routes a 400 to validationError instead of the banner', async () => {
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,8 @@ export interface SimState {
|
||||||
playing: boolean
|
playing: boolean
|
||||||
speed: PlaybackSpeed
|
speed: PlaybackSpeed
|
||||||
focusPanelId: string | null
|
focusPanelId: string | null
|
||||||
|
editingPanelId: string | null // panel whose editor popover is open
|
||||||
|
failedPanelIds: Set<string> // panels whose latest request failed (rose kebab dot)
|
||||||
hoveredNode: number | null
|
hoveredNode: number | null
|
||||||
runState: RunState
|
runState: RunState
|
||||||
errorMessage: string | null // network/server failure, shown in the ErrorBanner
|
errorMessage: string | null // network/server failure, shown in the ErrorBanner
|
||||||
|
|
@ -54,6 +56,8 @@ export function createSimStore(preset: StudyPreset = deepfakeSchoolPreset) {
|
||||||
playing: false,
|
playing: false,
|
||||||
speed: 1,
|
speed: 1,
|
||||||
focusPanelId: null,
|
focusPanelId: null,
|
||||||
|
editingPanelId: null,
|
||||||
|
failedPanelIds: new Set<string>(),
|
||||||
hoveredNode: null,
|
hoveredNode: null,
|
||||||
runState: 'idle',
|
runState: 'idle',
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
|
|
@ -135,6 +139,10 @@ export function createSimStore(preset: StudyPreset = deepfakeSchoolPreset) {
|
||||||
// Keep the last good results untouched; only the latest run may
|
// Keep the last good results untouched; only the latest run may
|
||||||
// surface its error.
|
// surface its error.
|
||||||
if (!isLatestRun) return
|
if (!isLatestRun) return
|
||||||
|
settled.forEach((outcome, index) => {
|
||||||
|
const panel = targets[index]
|
||||||
|
if (panel && outcome.status === 'rejected') state.failedPanelIds.add(panel.id)
|
||||||
|
})
|
||||||
const reason: unknown = firstFailure.reason
|
const reason: unknown = firstFailure.reason
|
||||||
state.runState = 'error'
|
state.runState = 'error'
|
||||||
if (reason instanceof ApiError && reason.status === 400) {
|
if (reason instanceof ApiError && reason.status === 400) {
|
||||||
|
|
@ -153,6 +161,7 @@ export function createSimStore(preset: StudyPreset = deepfakeSchoolPreset) {
|
||||||
if (outcome?.status !== 'fulfilled') return
|
if (outcome?.status !== 'fulfilled') return
|
||||||
state.resultsByPanelId[panel.id] = outcome.value.result
|
state.resultsByPanelId[panel.id] = outcome.value.result
|
||||||
state.edgesByGraphHash[graphKey(outcome.value.config)] = outcome.value.edges
|
state.edgesByGraphHash[graphKey(outcome.value.config)] = outcome.value.edges
|
||||||
|
state.failedPanelIds.delete(panel.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!isLatestRun) return
|
if (!isLatestRun) return
|
||||||
|
|
@ -213,7 +222,9 @@ export function createSimStore(preset: StudyPreset = deepfakeSchoolPreset) {
|
||||||
if (index < 0) return
|
if (index < 0) return
|
||||||
state.panels.splice(index, 1)
|
state.panels.splice(index, 1)
|
||||||
delete state.resultsByPanelId[panelId]
|
delete state.resultsByPanelId[panelId]
|
||||||
|
state.failedPanelIds.delete(panelId)
|
||||||
if (state.focusPanelId === panelId) state.focusPanelId = null
|
if (state.focusPanelId === panelId) state.focusPanelId = null
|
||||||
|
if (state.editingPanelId === panelId) state.editingPanelId = null
|
||||||
syncToUrl()
|
syncToUrl()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
21
web/src/lib/fieldLabels.ts
Normal file
21
web/src/lib/fieldLabels.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import type { Config, Strategy } from '@/types/engine'
|
||||||
|
|
||||||
|
// One human name per Config field, shared by the controls card and the
|
||||||
|
// panel editor so the same lever never appears under two names.
|
||||||
|
export const CONFIG_FIELD_LABELS: Record<keyof Config, string> = {
|
||||||
|
numStudents: 'Students',
|
||||||
|
edgesPerNode: 'Friends per student',
|
||||||
|
triangleProb: 'Clique tendency',
|
||||||
|
forwardProb: 'Chance to forward',
|
||||||
|
numEducated: 'Education budget',
|
||||||
|
origin: 'First poster',
|
||||||
|
graphSeed: 'Friendship network',
|
||||||
|
thresholdSeed: 'Who resists',
|
||||||
|
educationSeed: 'Random picks',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const STRATEGY_LABELS: Record<Strategy, string> = {
|
||||||
|
none: 'None',
|
||||||
|
random: 'Random',
|
||||||
|
'most-connected': 'Most connected',
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue