mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Implement fit-mode rendering contract with container sizing and pane re-fit
This commit is contained in:
@@ -1,16 +1,114 @@
|
||||
.preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.chart {
|
||||
.header {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
min-height: 100%;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-3);
|
||||
height: 40px;
|
||||
padding: 0 var(--space-4);
|
||||
border-bottom: var(--border-width) solid var(--border);
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: var(--space-5);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Segmented control — the four fit modes (spec §04). */
|
||||
.fit {
|
||||
display: inline-flex;
|
||||
border: var(--border-width) solid var(--border-strong);
|
||||
}
|
||||
|
||||
.fitOption {
|
||||
appearance: none;
|
||||
border: none;
|
||||
background: var(--bg);
|
||||
color: var(--text-secondary);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--dur-fast) var(--ease),
|
||||
color var(--dur-fast) var(--ease);
|
||||
}
|
||||
|
||||
.fitOption + .fitOption {
|
||||
border-left: var(--border-width) solid var(--border-strong);
|
||||
}
|
||||
|
||||
.fitOption:hover {
|
||||
background: var(--layer-01);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.fitActive,
|
||||
.fitActive:hover {
|
||||
background: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
}
|
||||
|
||||
/*
|
||||
* Chart sizing. The host (passed to vega-embed) is branded `.vega-embed`
|
||||
* (display:inline-block) at runtime; that shrink-wraps it, which is why
|
||||
* width:"container" collapsed before. The frame carries the fit class and the
|
||||
* two-class selectors below out-specify `.vega-embed` to give the host a
|
||||
* definite box for the responsive modes. `box-sizing:border-box` keeps the
|
||||
* chart inside the body padding rather than overflowing it.
|
||||
*/
|
||||
.frame {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.host {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Original — natural size; the body scrolls if the chart is larger than the pane. */
|
||||
.fitOriginal {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Width — host spans the pane width; height stays natural. */
|
||||
.fitWidth {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
.fitWidth .host {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Height — host spans the pane height; width stays natural. */
|
||||
.fitHeight {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
.fitHeight .host {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Full — host fills the pane in both dimensions. */
|
||||
.fitFull {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.fitFull .host {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.error {
|
||||
|
||||
@@ -1,40 +1,90 @@
|
||||
/**
|
||||
* Live Preview — the right pane (spec §04).
|
||||
*
|
||||
* Renders the active snippet's current buffer as a Vega-Lite chart, debounced so
|
||||
* typing stays smooth. The pipeline is: buffer text → JSON.parse →
|
||||
* prepareSpecForRender (copy, pure) → renderSpec (vega-embed). A render-
|
||||
* generation token guards against a slow render resolving after a newer one.
|
||||
* Renders the active snippet's currently-shown spec (draft or published, per the
|
||||
* editor view) as a Vega-Lite chart, debounced so typing stays smooth. The
|
||||
* pipeline is: shown text → JSON.parse → prepareSpecForRender (copy, pure, fit
|
||||
* mode applied) → renderSpec (vega-embed). A render-generation token guards
|
||||
* against a slow render resolving after a newer one.
|
||||
*
|
||||
* M1 scope: inline-data specs, Original sizing, basic error text. Fit modes (M2)
|
||||
* and dataset reference resolution (M3) plug into prepareSpecForRender without
|
||||
* changing this component.
|
||||
* The pane header carries the Fit control (4 sizing modes, §04). Render errors
|
||||
* are published to the shared PreviewStore so the editor pane mirrors them
|
||||
* (§03E); the preview shows the same message in place of the chart.
|
||||
*
|
||||
* M2 scope: inline-data specs, all four fit modes. Dataset reference resolution
|
||||
* (M3) plugs into prepareSpecForRender without changing this component.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { VisualizationSpec } from 'vega-embed';
|
||||
import type { FitMode } from '@core/rendering';
|
||||
import { prepareSpecForRender } from '@core/rendering';
|
||||
import { chartConfigFor } from '@core/vega-themes';
|
||||
import { renderSpec, type RenderHandle } from '../services/chart-renderer';
|
||||
import { useAppStore } from '../stores/AppStore';
|
||||
import { useSnippetStore } from '../stores/SnippetStore';
|
||||
import { usePreviewStore } from '../stores/PreviewStore';
|
||||
import { selectShownText, useSnippetStore } from '../stores/SnippetStore';
|
||||
import styles from './LivePreview.module.css';
|
||||
|
||||
/** Render debounce (ms). Becomes the configurable `renderDebounce` setting in M5. */
|
||||
const RENDER_DEBOUNCE_MS = 300;
|
||||
|
||||
/** The four fit modes in display order (spec §04 → Fit / Sizing Modes). */
|
||||
const FIT_MODES: ReadonlyArray<{ mode: FitMode; label: string }> = [
|
||||
{ mode: 'default', label: 'Original' },
|
||||
{ mode: 'width', label: 'Width' },
|
||||
{ mode: 'height', label: 'Height' },
|
||||
{ mode: 'full', label: 'Full' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Sizing class for the chart frame per fit mode. The frame is React-owned, so
|
||||
* these classes drive how the host element (which vega-embed brands with its own
|
||||
* `display:inline-block`) is sized: the responsive modes give the host a
|
||||
* definite width/height for Vega's `"container"` measurement (`containerSize()`
|
||||
* reads `host.clientWidth/Height`), while Original lets it shrink to natural size.
|
||||
*/
|
||||
const FIT_CLASS: Record<FitMode, string> = {
|
||||
default: styles.fitOriginal,
|
||||
width: styles.fitWidth,
|
||||
height: styles.fitHeight,
|
||||
full: styles.fitFull,
|
||||
};
|
||||
|
||||
function FitControl() {
|
||||
const fitMode = useAppStore((s) => s.previewFitMode);
|
||||
const setFitMode = useAppStore((s) => s.setPreviewFitMode);
|
||||
return (
|
||||
<div className={styles.fit} role="group" aria-label="Fit chart to pane">
|
||||
{FIT_MODES.map(({ mode, label }) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
className={`${styles.fitOption} ${mode === fitMode ? styles.fitActive : ''}`}
|
||||
aria-pressed={mode === fitMode}
|
||||
onClick={() => setFitMode(mode)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LivePreview() {
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const handleRef = useRef<RenderHandle | null>(null);
|
||||
const generationRef = useRef(0);
|
||||
const draftText = useSnippetStore((s) => s.draftText);
|
||||
const shownText = useSnippetStore(selectShownText);
|
||||
const fitMode = useAppStore((s) => s.previewFitMode);
|
||||
const uiTheme = useAppStore((s) => s.uiTheme);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const error = usePreviewStore((s) => s.error);
|
||||
const setError = usePreviewStore((s) => s.setError);
|
||||
|
||||
useEffect(() => {
|
||||
const node = hostRef.current;
|
||||
if (!node) return;
|
||||
const text = draftText.trim();
|
||||
const text = shownText.trim();
|
||||
|
||||
// The debounced body is async; wrap in a void IIFE so the timer callback
|
||||
// returns void (it handles its own errors internally — nothing awaits it).
|
||||
@@ -58,7 +108,7 @@ export function LivePreview() {
|
||||
|
||||
const mine = ++generationRef.current;
|
||||
try {
|
||||
const prepared = prepareSpecForRender(parsed, { fitMode: 'default' });
|
||||
const prepared = prepareSpecForRender(parsed, { fitMode });
|
||||
const config = chartConfigFor(uiTheme);
|
||||
handleRef.current?.destroy();
|
||||
handleRef.current = null;
|
||||
@@ -67,9 +117,9 @@ export function LivePreview() {
|
||||
// TODO: a superseded render's destroy() calls node.replaceChildren(),
|
||||
// which can blank the live chart if two embeds on the same node are
|
||||
// ever in flight at once (heavy spec whose embed outlasts the 300ms
|
||||
// debounce). The debounce makes this rare in M1; when fit-mode/dataset
|
||||
// work (M2/M3) lands, serialize renders or finalize the stale view
|
||||
// without clearing the shared node.
|
||||
// debounce). The debounce makes this rare; when dataset work (M3)
|
||||
// lands, serialize renders or finalize the stale view without
|
||||
// clearing the shared node.
|
||||
handle.destroy(); // a newer render superseded this one
|
||||
return;
|
||||
}
|
||||
@@ -87,21 +137,50 @@ export function LivePreview() {
|
||||
}, RENDER_DEBOUNCE_MS);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [draftText, uiTheme]);
|
||||
}, [shownText, fitMode, uiTheme, setError]);
|
||||
|
||||
// Finalize the live view on unmount.
|
||||
// Re-fit the chart when its container resizes (e.g. a pane drag). Vega doesn't
|
||||
// observe the element, so we do: one observer on the stable host node for the
|
||||
// component's life. Only responsive fit modes depend on container size;
|
||||
// Original is fixed natural size and the pane just scrolls. ResizeObserver
|
||||
// callbacks are frame-batched, so this tracks the drag without thrashing.
|
||||
useEffect(() => {
|
||||
const node = hostRef.current;
|
||||
if (!node || typeof ResizeObserver === 'undefined') return;
|
||||
const ro = new ResizeObserver(() => {
|
||||
if (useAppStore.getState().previewFitMode === 'default') return;
|
||||
handleRef.current?.resize();
|
||||
});
|
||||
ro.observe(node);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
// Finalize the live view on unmount, and clear the shared error so a stale
|
||||
// message never outlives this pane.
|
||||
useEffect(
|
||||
() => () => {
|
||||
handleRef.current?.destroy();
|
||||
handleRef.current = null;
|
||||
usePreviewStore.getState().setError(null);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.preview}>
|
||||
<div className={styles.chart} ref={hostRef} hidden={error !== null} />
|
||||
{error !== null && <pre className={styles.error}>{error}</pre>}
|
||||
<div className={styles.header}>
|
||||
<FitControl />
|
||||
</div>
|
||||
<div className={styles.body}>
|
||||
{/* Frame is React-owned and carries the fit-sizing class; the inner host
|
||||
is owned by vega-embed (it brands it `.vega-embed` and mutates its
|
||||
classList at runtime), so its className stays static and React never
|
||||
clobbers Vega's own classes. */}
|
||||
<div className={`${styles.frame} ${FIT_CLASS[fitMode]}`} hidden={error !== null}>
|
||||
<div className={styles.host} ref={hostRef} />
|
||||
</div>
|
||||
{error !== null && <pre className={styles.error}>{error}</pre>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { loadUiTheme, saveUiTheme } from './settings-store';
|
||||
import { loadPreviewFitMode, loadUiTheme, savePreviewFitMode, saveUiTheme } from './settings-store';
|
||||
|
||||
const KEY = 'astrolabe:settings';
|
||||
|
||||
@@ -84,3 +84,29 @@ describe('settings-store · ui.theme', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('settings-store · preview.fitMode', () => {
|
||||
beforeEach(() => vi.stubGlobal('localStorage', makeStorageStub()));
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('defaults to Original (default) when nothing is stored', () => {
|
||||
expect(loadPreviewFitMode()).toBe('default');
|
||||
});
|
||||
|
||||
it('returns a stored valid fit mode', () => {
|
||||
localStorage.setItem(KEY, JSON.stringify({ preview: { fitMode: 'full' } }));
|
||||
expect(loadPreviewFitMode()).toBe('full');
|
||||
});
|
||||
|
||||
it('falls back to default for an unrecognized value', () => {
|
||||
localStorage.setItem(KEY, JSON.stringify({ preview: { fitMode: 'cover' } }));
|
||||
expect(loadPreviewFitMode()).toBe('default');
|
||||
});
|
||||
|
||||
it('round-trips through load and preserves the theme slice', () => {
|
||||
saveUiTheme('dark');
|
||||
savePreviewFitMode('height');
|
||||
expect(loadPreviewFitMode()).toBe('height');
|
||||
expect(loadUiTheme()).toBe('dark'); // the other slice survives the merge
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
* `localStorage`; everything else goes through these typed functions.
|
||||
*/
|
||||
|
||||
import type { FitMode } from '@core/rendering';
|
||||
import type { UiTheme } from '@core/theme';
|
||||
|
||||
const KEY = 'astrolabe:settings';
|
||||
@@ -20,9 +21,13 @@ const KEY = 'astrolabe:settings';
|
||||
/** Spec §07 Appearance default. */
|
||||
const DEFAULT_THEME: UiTheme = 'light';
|
||||
|
||||
/** Spec §04 — the Fit control defaults to Original. */
|
||||
const DEFAULT_FIT_MODE: FitMode = 'default';
|
||||
|
||||
/** Loose view of the stored record — M5 will give this its full typed shape. */
|
||||
interface StoredSettings {
|
||||
ui?: { theme?: unknown; [k: string]: unknown };
|
||||
preview?: { fitMode?: unknown; [k: string]: unknown };
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -61,6 +66,10 @@ function isUiTheme(v: unknown): v is UiTheme {
|
||||
return v === 'light' || v === 'dark';
|
||||
}
|
||||
|
||||
function isFitMode(v: unknown): v is FitMode {
|
||||
return v === 'default' || v === 'width' || v === 'height' || v === 'full';
|
||||
}
|
||||
|
||||
/** The persisted UI theme, or the default — unknown/legacy values fall back. */
|
||||
export function loadUiTheme(): UiTheme {
|
||||
const stored = readRaw().ui?.theme;
|
||||
@@ -74,3 +83,15 @@ export function saveUiTheme(theme: UiTheme): void {
|
||||
const current = readRaw();
|
||||
writeRaw({ ...current, ui: { ...current.ui, theme } });
|
||||
}
|
||||
|
||||
/** The persisted preview fit mode, or the default — unknown values fall back. */
|
||||
export function loadPreviewFitMode(): FitMode {
|
||||
const stored = readRaw().preview?.fitMode;
|
||||
return isFitMode(stored) ? stored : DEFAULT_FIT_MODE;
|
||||
}
|
||||
|
||||
/** Persist the preview fit mode, preserving every other key already in the record. */
|
||||
export function savePreviewFitMode(fitMode: FitMode): void {
|
||||
const current = readRaw();
|
||||
writeRaw({ ...current, preview: { ...current.preview, fitMode } });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Preference orchestration — bridges the (browser-free) AppStore to the settings
|
||||
* adapter for the small UI preferences pulled forward ahead of the M5 Settings
|
||||
* modal. Same store↔adapter pattern as theme orchestration; currently the only
|
||||
* such preference is the Live Preview fit mode (spec §04, `previewFitMode`).
|
||||
*
|
||||
* Unlike theme there is no FOUC concern (the preview renders after hydration
|
||||
* anyway), but hydrating early keeps the store the single source of truth from
|
||||
* the first render.
|
||||
*/
|
||||
|
||||
import { loadPreviewFitMode, savePreviewFitMode } from '../infrastructure/settings-store';
|
||||
import { useAppStore } from '../stores/AppStore';
|
||||
|
||||
/** Hydrate the persisted fit mode into the store. Call before render. */
|
||||
export function initPreviewFitMode(): void {
|
||||
useAppStore.getState().setPreviewFitMode(loadPreviewFitMode());
|
||||
}
|
||||
|
||||
/** Persist the fit mode on change. Returns a teardown that detaches the subscriber. */
|
||||
export function wirePreviewFitMode(): () => void {
|
||||
return useAppStore.subscribe((state, prev) => {
|
||||
if (state.previewFitMode === prev.previewFitMode) return;
|
||||
savePreviewFitMode(state.previewFitMode);
|
||||
});
|
||||
}
|
||||
@@ -14,6 +14,17 @@ import type { Config } from 'vega-lite';
|
||||
export interface RenderHandle {
|
||||
/** Finalize the underlying Vega view and clear the node. */
|
||||
destroy(): void;
|
||||
/**
|
||||
* Re-fit the chart to its container's current size (spec §04 Responsiveness).
|
||||
*
|
||||
* Vega-Lite compiles `"container"` sizing to width/height signals that re-read
|
||||
* `containerSize()` ONLY on a `window:resize` event (nothing observes the
|
||||
* element, and `view.resize()` alone re-runs layout with the stale size). So a
|
||||
* pane drag — which fires no window resize — needs us to synthesize that event.
|
||||
* Doing it this way also means only the container-bound dimensions re-fit
|
||||
* (fixed ones have no such handler), which is exactly right for Width/Height.
|
||||
*/
|
||||
resize(): void;
|
||||
}
|
||||
|
||||
/** Embed a prepared spec into `node`. Non-negotiable: no actions menu, SVG output. */
|
||||
@@ -33,5 +44,12 @@ export async function renderSpec(
|
||||
result.view.finalize();
|
||||
node.replaceChildren();
|
||||
},
|
||||
resize() {
|
||||
// Synthesize the window:resize the container signals listen for (see the
|
||||
// interface doc). The view re-reads containerSize() and re-renders itself;
|
||||
// a finalized view has already removed its listener, so this is a safe
|
||||
// no-op after destroy().
|
||||
if (typeof window !== 'undefined') window.dispatchEvent(new Event('resize'));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { create } from 'zustand';
|
||||
import type { FitMode } from '@core/rendering';
|
||||
import type { UiTheme } from '@core/theme';
|
||||
|
||||
/**
|
||||
@@ -19,12 +20,16 @@ export type ModalName = 'datasets' | 'settings' | 'about' | 'donate' | 'chartBui
|
||||
export interface AppState {
|
||||
/** Active UI theme; mirrored onto <html data-theme> by a subscriber. */
|
||||
uiTheme: UiTheme;
|
||||
/** Preview sizing mode (spec §04); persisted to Settings as `previewFitMode`. */
|
||||
previewFitMode: FitMode;
|
||||
/** The currently open modal, or null. */
|
||||
activeModal: ModalName | null;
|
||||
|
||||
setTheme: (theme: UiTheme) => void;
|
||||
/** Flip between light and dark — the header ThemeToggle's action. */
|
||||
toggleTheme: () => void;
|
||||
/** Set the preview fit mode — the Live Preview Fit control's action. */
|
||||
setPreviewFitMode: (mode: FitMode) => void;
|
||||
/**
|
||||
* Low-level modal setter — the single primitive that mutates `activeModal`.
|
||||
* High-level open/close (snapshot for unsaved-change detection, URL sync,
|
||||
@@ -36,9 +41,11 @@ export interface AppState {
|
||||
|
||||
export const useAppStore = create<AppState>((set) => ({
|
||||
uiTheme: 'light',
|
||||
previewFitMode: 'default',
|
||||
activeModal: null,
|
||||
|
||||
setTheme: (uiTheme) => set({ uiTheme }),
|
||||
toggleTheme: () => set((s) => ({ uiTheme: s.uiTheme === 'dark' ? 'light' : 'dark' })),
|
||||
setPreviewFitMode: (previewFitMode) => set({ previewFitMode }),
|
||||
setActiveModal: (activeModal) => set({ activeModal }),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user