Merge branch 'pivot-busy-indicator'
Show a Calculating indicator in the app's status bar while the pivot rebuilds, since Perspective's own beaker is only rendered while the settings panel is open. Also make the light/dark toggle actually reach the pivot: the theme is a ViewerConfig field, not an attribute, and stored layouts were reasserting their author's theme on every restore. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
commit
b378ab7639
@ -803,7 +803,11 @@ Theme state lives in `ui/src/theme.jsx` — a React context (`ThemeContext`) wit
|
|||||||
- **Toggle:** `setDark(d => !d)` in `StatusBar.jsx`; effect writes `localStorage` and toggles the `.dark` class on `<html>`
|
- **Toggle:** `setDark(d => !d)` in `StatusBar.jsx`; effect writes `localStorage` and toggles the `.dark` class on `<html>`
|
||||||
- **CSS:** `ui/src/index.css` defines CSS custom properties under `:root` (light) and `.dark`. All Tailwind color overrides are written as `.dark .bg-white { ... }` etc. — no Tailwind dark-mode config needed
|
- **CSS:** `ui/src/index.css` defines CSS custom properties under `:root` (light) and `.dark`. All Tailwind color overrides are written as `.dark .bg-white { ... }` etc. — no Tailwind dark-mode config needed
|
||||||
- **Palette:** dark mode uses Perspective's "Pro Dark" colours (`--bg-primary: #242526`, panels `#2a2c2f`, gridlines `#3b3f46`, text `#c5c9d0`)
|
- **Palette:** dark mode uses Perspective's "Pro Dark" colours (`--bg-primary: #242526`, panels `#2a2c2f`, gridlines `#3b3f46`, text `#c5c9d0`)
|
||||||
- **Perspective viewer:** `Forecast.jsx` calls `viewer.setAttribute('theme', dark ? 'Pro Dark' : 'Pro Light')` both on initial load and in a `useEffect([dark, versionId])` so the viewer stays in sync when the toggle fires
|
- **Perspective viewer:** `Forecast.jsx` calls `viewer.restore({ theme: dark ? 'Pro Dark' : 'Pro Light' })` both on initial load and in a `useEffect([dark, versionId])`. A **partial** restore, so it does not rebuild the view the way `applyDepth`'s whole-config one does.
|
||||||
|
|
||||||
|
It used to be `setAttribute('theme', …)`, which in 5.4.0 does **nothing** — the theme is a `ViewerConfig` field (`ViewerConfig.theme`, a theme *name*), and the element observes no such attribute. The pivot simply stayed light whatever the toggle said.
|
||||||
|
|
||||||
|
Half the fix is in `cleanLayout`, which **drops `theme`**. `viewer.save()` includes it, so every stored layout had one baked in, and restoring one reasserted the author's theme and undid the toggle. It is the reader's choice, not a property of the layout — which also means a published layout cannot impose its theme on whoever applies it. `sameConfig()` ignores `theme` for the same reason: toggling is not a layout edit and must not light the dirty dot.
|
||||||
- **Consuming the theme:** `import useTheme from '../theme.jsx'` then `const { dark, setDark } = useTheme()`
|
- **Consuming the theme:** `import useTheme from '../theme.jsx'` then `const { dark, setDark } = useTheme()`
|
||||||
|
|
||||||
## After a change: restart, and Generate SQL
|
## After a change: restart, and Generate SQL
|
||||||
|
|||||||
@ -11,6 +11,10 @@ export default function StatusBar({ view, sources = [], sourceId, setSourceId, v
|
|||||||
const [info, setInfo] = useState(null)
|
const [info, setInfo] = useState(null)
|
||||||
const [showInfo, setShow] = useState(false)
|
const [showInfo, setShow] = useState(false)
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
|
// Forecast.jsx mirrors the pivot's own updating state onto this event. The
|
||||||
|
// viewer shows the same thing as a beaker in its settings panel, which is
|
||||||
|
// exactly where you cannot see it once the panel is closed.
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
const refreshInfo = useCallback(async () => {
|
const refreshInfo = useCallback(async () => {
|
||||||
if (!versionId || !showVersion) { setInfo(null); return }
|
if (!versionId || !showVersion) { setInfo(null); return }
|
||||||
@ -29,6 +33,12 @@ export default function StatusBar({ view, sources = [], sourceId, setSourceId, v
|
|||||||
return () => window.removeEventListener('pf-data-changed', onChange)
|
return () => window.removeEventListener('pf-data-changed', onChange)
|
||||||
}, [refreshInfo])
|
}, [refreshInfo])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onBusy = (e) => setBusy(!!e.detail?.busy)
|
||||||
|
window.addEventListener('pf-pivot-busy', onBusy)
|
||||||
|
return () => window.removeEventListener('pf-pivot-busy', onBusy)
|
||||||
|
}, [])
|
||||||
|
|
||||||
async function copyTable() {
|
async function copyTable() {
|
||||||
if (!info?.fc_table) return
|
if (!info?.fc_table) return
|
||||||
try {
|
try {
|
||||||
@ -127,6 +137,23 @@ export default function StatusBar({ view, sources = [], sourceId, setSourceId, v
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Left of the ml-auto block, so it sits beside the pivot's own figures
|
||||||
|
rather than out among the account controls. Occupies no space at all
|
||||||
|
when idle: a permanently reserved slot reads as a thing that is broken
|
||||||
|
when empty. */}
|
||||||
|
{busy && view === 'forecast' && (
|
||||||
|
<span
|
||||||
|
className="flex items-center gap-1.5 text-blue-600"
|
||||||
|
title="The pivot is rebuilding its view — sorting, filtering, or changing depth on a large grain takes a moment"
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||||
|
strokeWidth="3" strokeLinecap="round" className="animate-spin">
|
||||||
|
<path d="M12 3a9 9 0 1 0 9 9" />
|
||||||
|
</svg>
|
||||||
|
Calculating…
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
{user && (
|
{user && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@ -29,6 +29,13 @@ const LAYOUTS_KEY = (vid) => `pf_layouts_v${vid}` // legacy named list, migrate
|
|||||||
function cleanLayout(cfg, validCols) {
|
function cleanLayout(cfg, validCols) {
|
||||||
if (!cfg) return cfg
|
if (!cfg) return cfg
|
||||||
const c = { ...cfg }
|
const c = { ...cfg }
|
||||||
|
// The theme is the reader's own light/dark choice, not a property of the
|
||||||
|
// layout. viewer.save() includes it, so it ends up baked into every stored
|
||||||
|
// config -- and restoring one then snapped the pivot back to whatever the
|
||||||
|
// author had, silently undoing the toggle. Dropped on the way in, which makes
|
||||||
|
// the app's `dark` state the only thing that sets it, and means a published
|
||||||
|
// layout cannot impose its author's theme on everyone who applies it.
|
||||||
|
delete c.theme
|
||||||
// Dead expressions go before the axis filter, not after: dropping them from
|
// Dead expressions go before the axis filter, not after: dropping them from
|
||||||
// `expressions` is what makes `ok()` reject them everywhere else.
|
// `expressions` is what makes `ok()` reject them everywhere else.
|
||||||
if (DEAD_ORDER_EXPRS.some(n => c.expressions?.[n] !== undefined)) {
|
if (DEAD_ORDER_EXPRS.some(n => c.expressions?.[n] !== undefined)) {
|
||||||
@ -263,6 +270,9 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
const [undoingId, setUndoingId] = useState(null)
|
const [undoingId, setUndoingId] = useState(null)
|
||||||
|
|
||||||
const viewerRef = useRef(null)
|
const viewerRef = useRef(null)
|
||||||
|
// Depth, not a boolean: config updates overlap, and the first one to finish
|
||||||
|
// would otherwise clear the flag while the others are still rebuilding.
|
||||||
|
const busyRef = useRef(0)
|
||||||
const workerRef = useRef(null)
|
const workerRef = useRef(null)
|
||||||
const tableRef = useRef(null)
|
const tableRef = useRef(null)
|
||||||
const colMetaRef = useRef([])
|
const colMetaRef = useRef([])
|
||||||
@ -439,7 +449,16 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (viewerRef.current) {
|
if (viewerRef.current) {
|
||||||
viewerRef.current.setAttribute('theme', dark ? 'Pro Dark' : 'Pro Light')
|
// restore(), not setAttribute('theme'): the theme is a ViewerConfig field
|
||||||
|
// in 5.4.0 (ViewerConfig.theme, a theme *name* like "Pro Dark"), and the
|
||||||
|
// element observes no such attribute -- the attribute call this replaced
|
||||||
|
// did nothing at all, so the pivot simply stayed light.
|
||||||
|
//
|
||||||
|
// A partial update: everything else is left as it is, so this does not
|
||||||
|
// rebuild the view the way applyDepth's whole-config restore does.
|
||||||
|
// cleanLayout drops `theme` on the way in, so no layout restore puts it
|
||||||
|
// back afterwards.
|
||||||
|
viewerRef.current.restore({ theme: dark ? 'Pro Dark' : 'Pro Light' })
|
||||||
}
|
}
|
||||||
}, [dark, versionId])
|
}, [dark, versionId])
|
||||||
|
|
||||||
@ -784,7 +803,11 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
// differs on every refresh, so it is never a real difference.
|
// differs on every refresh, so it is never a real difference.
|
||||||
function sameConfig(a, b) {
|
function sameConfig(a, b) {
|
||||||
if (!a || !b) return false
|
if (!a || !b) return false
|
||||||
const strip = ({ table, ...rest }) => JSON.stringify(rest)
|
// table is the per-load table name, different on every refresh. theme is
|
||||||
|
// the reader's light/dark choice and is not part of the layout (see
|
||||||
|
// cleanLayout) -- without it here, toggling the theme would light the dirty
|
||||||
|
// dot and offer to save a layout nothing had edited.
|
||||||
|
const strip = ({ table, theme, ...rest }) => JSON.stringify(rest)
|
||||||
return strip(a) === strip(b)
|
return strip(a) === strip(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -909,7 +932,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
console.error('[setAutoPause]', err)
|
console.error('[setAutoPause]', err)
|
||||||
}
|
}
|
||||||
|
|
||||||
viewer.setAttribute('theme', dark ? 'Pro Dark' : 'Pro Light')
|
await viewer.restore({ theme: dark ? 'Pro Dark' : 'Pro Light' })
|
||||||
if (!hideSplitTotal()) setTimeout(hideSplitTotal, 400)
|
if (!hideSplitTotal()) setTimeout(hideSplitTotal, 400)
|
||||||
|
|
||||||
// restore last-used layout or build default
|
// restore last-used layout or build default
|
||||||
@ -1099,16 +1122,18 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
const levels = (cfg.split_by || []).length
|
const levels = (cfg.split_by || []).length
|
||||||
if (!levels) return
|
if (!levels) return
|
||||||
const depth = Math.max(0, Math.min(n, levels))
|
const depth = Math.max(0, Math.min(n, levels))
|
||||||
|
pivotBusy(true)
|
||||||
try {
|
try {
|
||||||
// A count of levels to show, exactly as group_by_depth is -- the engine
|
// A count of levels to show, exactly as group_by_depth is -- the engine
|
||||||
// treats both axes identically. See applyDepth for why 0 is floored to 1.
|
// treats both axes identically. See applyDepth for why 0 is floored to 1.
|
||||||
await viewer.restore({ ...cfg, split_by_depth: Math.max(1, depth) })
|
await viewer.restore({ ...cfg, split_by_depth: Math.max(1, depth) })
|
||||||
|
await viewer.flush()
|
||||||
setSplitDepth(depth)
|
setSplitDepth(depth)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[applySplitDepth]', err)
|
console.error('[applySplitDepth]', err)
|
||||||
flash(err.message || String(err), 'error')
|
flash(err.message || String(err), 'error')
|
||||||
return
|
return
|
||||||
}
|
} finally { pivotBusy(false) }
|
||||||
try {
|
try {
|
||||||
const cfg2 = await captureConfig()
|
const cfg2 = await captureConfig()
|
||||||
if (cfg2) await persistLayout(versionId, cfg2)
|
if (cfg2) await persistLayout(versionId, cfg2)
|
||||||
@ -1441,6 +1466,35 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
|
// Tell the app's status bar whether the pivot is rebuilding, so the wait is
|
||||||
|
// visible with the settings panel closed.
|
||||||
|
//
|
||||||
|
// Perspective shows this as the beaker in its own status bar, which is the
|
||||||
|
// honest signal -- it is driven by the session's update_count. It cannot be
|
||||||
|
// read from here, though: that bar lives in the viewer's shadow root, and the
|
||||||
|
// component carrying it is only *rendered* while settings are open once the
|
||||||
|
// table has loaded, so with the panel closed there is no element to watch. An
|
||||||
|
// earlier attempt did exactly that and only ever lit up when the panel was
|
||||||
|
// open, which is precisely when you do not need it.
|
||||||
|
//
|
||||||
|
// So it is raised by the callers that *start* a rebuild, and lowered once
|
||||||
|
// viewer.flush() says everything pending has rendered.
|
||||||
|
//
|
||||||
|
// Not from `perspective-config-update`, which was the obvious hook and is the
|
||||||
|
// wrong one: it is dispatched *after* the rebuild it describes. Measured on a
|
||||||
|
// depth change over a large grain, the event arrived seconds late and the
|
||||||
|
// flush that followed resolved in 6ms -- so the flag went up and down well
|
||||||
|
// after the wait it was meant to cover.
|
||||||
|
//
|
||||||
|
// Consequence: this covers what the toolbar drives -- depth on both axes, and
|
||||||
|
// applying a layout. Sorting or filtering from the viewer's own chrome still
|
||||||
|
// shows nothing, because the app never learns those have begun.
|
||||||
|
function pivotBusy(on) {
|
||||||
|
busyRef.current = Math.max(0, busyRef.current + (on ? 1 : -1))
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('pf-pivot-busy', { detail: { busy: busyRef.current > 0 } }))
|
||||||
|
}
|
||||||
|
|
||||||
// Find the grid wherever it is. Guessing the nesting was wrong once already --
|
// Find the grid wherever it is. Guessing the nesting was wrong once already --
|
||||||
// the plugin element's own shadow root is not where the cells live -- so locate
|
// the plugin element's own shadow root is not where the cells live -- so locate
|
||||||
// regular-table by walking through shadow roots, then inject into whatever root
|
// regular-table by walking through shadow roots, then inject into whatever root
|
||||||
@ -1506,6 +1560,8 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
// effect on its own. table is dropped: it is loaded by reference, and a stale
|
// effect on its own. table is dropped: it is loaded by reference, and a stale
|
||||||
// name in a restored config fails the lookup.
|
// name in a restored config fails the lookup.
|
||||||
const target = Math.max(1, d)
|
const target = Math.max(1, d)
|
||||||
|
pivotBusy(true)
|
||||||
|
try {
|
||||||
const { table: _t, ...cfg } = await viewer.save()
|
const { table: _t, ...cfg } = await viewer.save()
|
||||||
await viewer.restore({ ...cfg, group_by_depth: target })
|
await viewer.restore({ ...cfg, group_by_depth: target })
|
||||||
|
|
||||||
@ -1530,7 +1586,9 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
const plugin = await viewer.getPlugin()
|
const plugin = await viewer.getPlugin()
|
||||||
await plugin.draw(view)
|
await plugin.draw(view)
|
||||||
}
|
}
|
||||||
|
await viewer.flush()
|
||||||
setExpandDepth(d)
|
setExpandDepth(d)
|
||||||
|
} finally { pivotBusy(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function captureConfig() {
|
async function captureConfig() {
|
||||||
@ -1615,7 +1673,10 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
const validCols = new Set(tableRef.current ? Object.keys(await tableRef.current.schema()) : [])
|
const validCols = new Set(tableRef.current ? Object.keys(await tableRef.current.schema()) : [])
|
||||||
const cfg = cleanLayout(layout.config, validCols)
|
const cfg = cleanLayout(layout.config, validCols)
|
||||||
cfg.plugin_config = { ...(cfg.plugin_config || {}), edit_mode: 'SELECT_REGION' }
|
cfg.plugin_config = { ...(cfg.plugin_config || {}), edit_mode: 'SELECT_REGION' }
|
||||||
|
pivotBusy(true)
|
||||||
|
try {
|
||||||
await viewer.restore(cfg)
|
await viewer.restore(cfg)
|
||||||
|
await viewer.flush()
|
||||||
adoptSplit(cfg.split_full?.length ? cfg.split_full : cfg.split_by,
|
adoptSplit(cfg.split_full?.length ? cfg.split_full : cfg.split_by,
|
||||||
cfg.split_by_depth != null ? cfg.split_by_depth : null)
|
cfg.split_by_depth != null ? cfg.split_by_depth : null)
|
||||||
setGroupFull(cfg.group_by || [])
|
setGroupFull(cfg.group_by || [])
|
||||||
@ -1631,6 +1692,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
const merged = await captureConfig()
|
const merged = await captureConfig()
|
||||||
markClean(merged || cfg, layout.id)
|
markClean(merged || cfg, layout.id)
|
||||||
await persistLayout(versionId, merged || cfg)
|
await persistLayout(versionId, merged || cfg)
|
||||||
|
} finally { pivotBusy(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteLayout(layout) {
|
async function deleteLayout(layout) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user