web: export menu, slice 11 of M3
The toolbar's Export button becomes a real menu: Data (JSON) downloads
{ base, panels, results } exactly as held in the store, Per-node CSV
writes one row per panel and node (educated flag, reachedAtRound with
-1 kept), and Picture (PNG) rasterizes each panel's live SVG with the
current theme's token values resolved, drawn side by side with labels
and the live percentage at the current round, plus legend and the
disclaimer footer line, at 2x. Items disable while a run is in flight
so exports always capture settled state.
This commit is contained in:
parent
3cd48ca7ea
commit
c5cbfd3eea
5 changed files with 136 additions and 20 deletions
111
web/src/components/ExportMenu.vue
Normal file
111
web/src/components/ExportMenu.vue
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import PanelMenu, { type PanelMenuItem } from './PanelMenu.vue'
|
||||
import { useSimStore } from '@/composables/useSimStore'
|
||||
import { exportCsv, exportJson, exportPng } from '@/lib/export'
|
||||
|
||||
// The toolbar's Export dropdown (spec 5.9). Items disable while a run is
|
||||
// in flight so an export always captures settled state.
|
||||
|
||||
const store = useSimStore()
|
||||
|
||||
const open = ref(false)
|
||||
const exporting = ref(false)
|
||||
const button = ref<HTMLButtonElement | null>(null)
|
||||
|
||||
const busy = computed(
|
||||
() =>
|
||||
store.state.runState === 'running' ||
|
||||
exporting.value ||
|
||||
Object.keys(store.state.resultsByPanelId).length === 0,
|
||||
)
|
||||
|
||||
const items = computed<PanelMenuItem[]>(() => [
|
||||
{ key: 'json', label: 'Data (JSON)', disabled: busy.value },
|
||||
{ key: 'csv', label: 'Per-node CSV', disabled: busy.value },
|
||||
{ key: 'png', label: 'Picture (PNG)', disabled: busy.value },
|
||||
])
|
||||
|
||||
async function onSelect(itemKey: string) {
|
||||
open.value = false
|
||||
button.value?.focus()
|
||||
if (busy.value) return
|
||||
if (itemKey === 'json') {
|
||||
exportJson(store)
|
||||
} else if (itemKey === 'csv') {
|
||||
exportCsv(store)
|
||||
} else if (itemKey === 'png') {
|
||||
exporting.value = true
|
||||
try {
|
||||
await exportPng(store)
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
open.value = false
|
||||
button.value?.focus()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="anchor">
|
||||
<button
|
||||
ref="button"
|
||||
class="btn"
|
||||
type="button"
|
||||
aria-haspopup="menu"
|
||||
:aria-expanded="open"
|
||||
aria-label="Export"
|
||||
@click="open = !open"
|
||||
>
|
||||
<svg class="ic" viewBox="0 0 24 24"><path d="M12 4v11m0 0l-4-4m4 4l4-4M5 20h14" /></svg>
|
||||
<span class="btn-text">Export</span>
|
||||
</button>
|
||||
<PanelMenu v-if="open" :items="items" :anchor="button" @select="onSelect" @close="closeMenu" />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.anchor {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.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 {
|
||||
border-color: var(--ink-4);
|
||||
}
|
||||
|
||||
.btn svg.ic {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.btn {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -28,7 +28,11 @@ const numStudents = computed(() =>
|
|||
)
|
||||
const reachedCount = computed(() =>
|
||||
result.value
|
||||
? reachedCountDisplayed(result.value.reachedAtRound, store.state.round, store.state.roundProgress)
|
||||
? reachedCountDisplayed(
|
||||
result.value.reachedAtRound,
|
||||
store.state.round,
|
||||
store.state.roundProgress,
|
||||
)
|
||||
: 0,
|
||||
)
|
||||
const reachedPctNow = computed(() => (reachedCount.value / Math.max(numStudents.value, 1)) * 100)
|
||||
|
|
|
|||
|
|
@ -67,7 +67,11 @@ const numStudents = computed(() => store.effectiveConfig(props.panel).numStudent
|
|||
const reachedCount = computed(() => {
|
||||
const panelResult = result.value
|
||||
return panelResult
|
||||
? reachedCountDisplayed(panelResult.reachedAtRound, store.state.round, store.state.roundProgress)
|
||||
? reachedCountDisplayed(
|
||||
panelResult.reachedAtRound,
|
||||
store.state.round,
|
||||
store.state.roundProgress,
|
||||
)
|
||||
: 0
|
||||
})
|
||||
const reachedPctNow = computed(() => (reachedCount.value / numStudents.value) * 100)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import ExportMenu from './ExportMenu.vue'
|
||||
import { useSimStore } from '@/composables/useSimStore'
|
||||
import { MAX_PANELS } from '@/presets/types'
|
||||
|
||||
|
|
@ -24,16 +25,7 @@ function addScenario() {
|
|||
<h2>Scenarios</h2>
|
||||
<span v-if="refreshing" class="spinner" role="status" aria-label="Updating scenarios" />
|
||||
<span class="grow" />
|
||||
<button
|
||||
class="btn export"
|
||||
type="button"
|
||||
disabled
|
||||
title="Export arrives in a later slice"
|
||||
aria-label="Export"
|
||||
>
|
||||
<svg class="ic" viewBox="0 0 24 24"><path d="M12 4v11m0 0l-4-4m4 4l4-4M5 20h14" /></svg>
|
||||
<span class="btn-text">Export</span>
|
||||
</button>
|
||||
<ExportMenu />
|
||||
<button
|
||||
class="btn primary"
|
||||
type="button"
|
||||
|
|
@ -123,14 +115,10 @@ h2 {
|
|||
margin-top: 16px;
|
||||
}
|
||||
|
||||
/* Keep the toolbar one row down to 320 px: Export collapses to an icon
|
||||
and the buttons grow to comfortable touch targets. */
|
||||
/* Keep the toolbar one row down to 320 px with comfortable touch
|
||||
targets; ExportMenu collapses itself to an icon. */
|
||||
.btn {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.btn.export .btn-text {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -81,7 +81,10 @@ function resolveThemeTokens(): Record<string, string> {
|
|||
return tokens
|
||||
}
|
||||
|
||||
function svgToImage(svgElement: SVGSVGElement, tokens: Record<string, string>): Promise<HTMLImageElement> {
|
||||
function svgToImage(
|
||||
svgElement: SVGSVGElement,
|
||||
tokens: Record<string, string>,
|
||||
): Promise<HTMLImageElement> {
|
||||
const clone = svgElement.cloneNode(true) as SVGSVGElement
|
||||
clone.setAttribute('width', `${PANEL_WIDTH}`)
|
||||
clone.setAttribute('height', `${PANEL_HEIGHT}`)
|
||||
|
|
@ -114,7 +117,13 @@ export async function exportPng(store: SimStore): Promise<void> {
|
|||
const panelCount = panelsWithSvg.length
|
||||
const width = 2 * PAGE_PADDING + panelCount * PANEL_WIDTH + (panelCount - 1) * PANEL_GAP
|
||||
const height =
|
||||
PAGE_PADDING + HEADER_HEIGHT + PANEL_HEIGHT + 20 + LEGEND_HEIGHT + FOOTER_HEIGHT + PAGE_PADDING / 2
|
||||
PAGE_PADDING +
|
||||
HEADER_HEIGHT +
|
||||
PANEL_HEIGHT +
|
||||
20 +
|
||||
LEGEND_HEIGHT +
|
||||
FOOTER_HEIGHT +
|
||||
PAGE_PADDING / 2
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue