import { createDefaultVSpec, createVisualizationRuntime, registry } from '@astrato-io/visualizations'; import type { DataBinding, StandaloneVisualizationType, VisualizationRuntimeOptions } from '@astrato-io/visualizations'; import { compileVisualizationDefinition, createVisualizationAvailableFields, } from '@astrato-io/visualizations/definitions'; import type { VisualizationDefinition } from '@astrato-io/visualizations/definitions'; import '@astrato-io/visualizations/styles.css'; import './demo.css'; import { createPanelLifecycle } from './panel-lifecycle.js'; export interface DemoView { id: string; title: string; subtitle: string; definition: VisualizationDefinition & { type: StandaloneVisualizationType }; } export interface DemoOptions { root: HTMLElement; title: string; scope: string; client: VisualizationRuntimeOptions['client']; dataBinding: DataBinding; fieldMetadata: NonNullable; views: readonly DemoView[]; } const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); function element(tag: K, className: string, text = '') { const node = document.createElement(tag); node.className = className; node.textContent = text; return node; } /** Call only after the delivery-specific client and live metadata have been established. */ export async function createDemoDashboard(options: DemoOptions) { const { root, client, fieldMetadata, views } = options; if (!views.length || new Set(views.map(({ id }) => id)).size !== views.length) { throw new Error('Provide at least one view with unique IDs.'); } const selections = client.hostServices?.selections; if (!selections) throw new Error('A selection service is required for this demo.'); const GROUP = 'demo-dashboard'; const availableFields = createVisualizationAvailableFields(fieldMetadata); const runtime = createVisualizationRuntime({ client, registry }); // Compile the whole plan before creating instances or replacing the page. const compiled = await Promise.all( views.map(async (view) => { const result = await compileVisualizationDefinition({ definition: view.definition, availableFields, baseVSpec: createDefaultVSpec(view.definition.type), }); if (!result.success) throw new Error(`${view.title}: ${JSON.stringify(result.issues)}`); return { view, vSpec: result.vSpec }; }), ); const header = element('header', 'demo-header'); const identity = element('div', 'demo-identity'); identity.append(element('h1', '', options.title), element('p', '', options.scope)); const status = element('p', 'demo-selection-status', 'No selections'); status.setAttribute('role', 'status'); const controls = element('div', 'demo-controls'); const controller = new AbortController(); for (const [label, action] of [ ['Apply', () => selections.applyPendingSelections(GROUP)], ['Discard', () => selections.clearPendingSelections(GROUP)], ['Clear all', () => selections.clearAllSelections(GROUP)], ] as const) { const button = element('button', '', label); button.type = 'button'; button.addEventListener('click', action, { signal: controller.signal }); controls.append(button); } header.append(identity, controls); const board = element('main', 'demo-board'); root.replaceChildren(header, status, board); const subscription = selections.onActiveSelectionChange?.(GROUP).subscribe((event) => { if (!isRecord(event)) return; const pending = Array.isArray(event.pending) ? event.pending.length : 0; const applied = Array.isArray(event.applied) ? event.applied.length : 0; status.textContent = `${applied} applied field selections ยท ${pending} pending`; }); let teardown: Promise | undefined; const cards = compiled.map(({ view, vSpec }) => { runtime.createInstance({ id: view.id, type: view.definition.type, vSpec, groupId: GROUP, dataBinding: { ...options.dataBinding, fieldMetadata, queryContext: { ...(isRecord(options.dataBinding.queryContext) ? options.dataBinding.queryContext : {}), objectId: view.id } }, }); const card = element('section', 'demo-card'); const heading = element('h2', '', view.title); heading.id = `heading-${view.id}`; card.setAttribute('aria-labelledby', heading.id); const host = element('div', 'demo-host'); const state = element('p', 'demo-state'); const toggle = element('button', 'demo-toggle', 'Hide chart'); toggle.type = 'button'; const retry = element('button', 'demo-retry', 'Retry'); retry.type = 'button'; retry.hidden = true; card.append(heading, element('p', 'demo-subtitle', view.subtitle), toggle, host, state, retry); board.append(card); let visible = true; const panel = createPanelLifecycle({ mount: () => runtime.mount(host, view.id), onState(value, message) { card.dataset.state = value; card.setAttribute('aria-busy', String(value === 'loading')); state.textContent = message; retry.hidden = !['error', 'timeout'].includes(value); }, }); toggle.addEventListener( 'click', () => { visible = !visible; toggle.textContent = visible ? 'Hide chart' : 'Show chart'; toggle.setAttribute('aria-expanded', String(visible)); void (visible ? panel.show() : panel.hide()); }, { signal: controller.signal }, ); toggle.setAttribute('aria-expanded', 'true'); retry.addEventListener( 'click', () => { void panel.retry(); }, { signal: controller.signal }, ); return { id: view.id, start: panel.start, close: panel.destroy, setEmpty: panel.setEmpty, }; }); await Promise.all(cards.map((card) => card.start())); return { /** Supply current query evidence; readiness alone does not establish row count. */ setEmpty(id: string, value: boolean) { const card = cards.find((entry) => entry.id === id); if (!card) throw new Error(`Unknown view: ${id}`); card.setEmpty(value); }, destroy() { teardown ??= (async () => { controller.abort(); subscription?.unsubscribe(); await Promise.all(cards.map((card) => card.close())); for (const { id } of cards) runtime.removeInstance(id); root.replaceChildren(); })(); return teardown; }, }; }