@malloydata/malloyyo 0.2.16 → 0.2.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,943 @@
1
+ // @ts-nocheck
2
+ // The dashboard frame runtime — the ONE implementation shared by the CLI dev
3
+ // preview (bundled from source by dashboard.ts) and the hosted app (bundled
4
+ // into public/dashboard-vendor.js at build time as window.__DASH_RUNTIME__).
5
+ //
6
+ // It runs inside the sandboxed iframe: no credentials, no network — its only
7
+ // channel is postMessage to the trusted parent, which runs queries server-side
8
+ // (named queries from the model's published surface, or restricted Malloy text)
9
+ // and posts results back.
10
+ //
11
+ // Injected frame globals (read lazily — script order differs between hosts):
12
+ // window.__DASHBOARD__ { name, query, title, description? } (the # artifact tag)
13
+ // window.__GIVENS__ given specs introspected from the model's given: decls
14
+ // window.__INITIAL_GIVENS__ URL-seeded given values for shareable links
15
+ import React, {
16
+ createContext,
17
+ useCallback,
18
+ useContext,
19
+ useEffect,
20
+ useMemo,
21
+ useRef,
22
+ useState,
23
+ } from "react";
24
+ import { createRoot } from "react-dom/client";
25
+ import { MalloyRenderer } from "@malloydata/render";
26
+ import { filters } from "./filters";
27
+ // The drill contract (which cells drill, to where, seeding which given) is shared
28
+ // with the hosted app's ltool result view — see drill.ts.
29
+ import { drillFieldNames, humanizeSlug, markDrillableCells, resolveDrill } from "./drill";
30
+ import { combineTiles } from "./combine";
31
+
32
+ export { filters };
33
+
34
+ export const dashboardInfo = () => window.__DASHBOARD__ || {};
35
+ export const givenSpecs = () => window.__GIVENS__ || [];
36
+
37
+ // ── host bridge ─────────────────────────────────────────────────────
38
+ // The runtime performs three privileged actions it can't do itself: run a
39
+ // governed query, navigate to a sibling dashboard, and mirror the committed
40
+ // givens into the shareable URL. It delegates all three to a HOST. Two hosts
41
+ // implement the contract:
42
+ // • postMessage host (default) — the sandboxed iframe: posts to the trusted
43
+ // parent shell, which holds the runner. This is the CUSTOM-dashboard path.
44
+ // • in-page host — a tag-only dashboard mounted directly in the trusted page
45
+ // with NO iframe (see index.ts mountInPage): fetches the run endpoint and
46
+ // drives the URL itself. The parent installs it via setHost().
47
+ let seq = 0;
48
+ const pending = new Map();
49
+ if (typeof window !== "undefined") {
50
+ window.addEventListener("message", (e) => {
51
+ if (e.source !== window.parent) return; // only the trusted shell
52
+ const m = e.data;
53
+ if (m && m.type === "result" && pending.has(m.id)) {
54
+ pending.get(m.id)(m);
55
+ pending.delete(m.id);
56
+ }
57
+ });
58
+ }
59
+
60
+ // Default host: the postMessage bridge to the trusted parent (iframe path).
61
+ // `run` resolves with the RAW result message ({ok, rows, stable_result|
62
+ // stableResult, problems|error}); runQuery normalizes it below.
63
+ let host = {
64
+ run(req, givens) {
65
+ const id = ++seq;
66
+ return new Promise((resolve) => {
67
+ pending.set(id, resolve);
68
+ parent.postMessage({ type: "run", id, query: req.query, malloy: req.malloy, givens }, "*");
69
+ });
70
+ },
71
+ navigate(dashboard, givens) {
72
+ parent.postMessage({ type: "navigate", dashboard, givens }, "*");
73
+ },
74
+ syncGivens(givens) {
75
+ parent.postMessage({ type: "givens", givens }, "*");
76
+ },
77
+ };
78
+
79
+ /** Swap the host — a tag-only dashboard mounted in the trusted page (mountInPage)
80
+ installs a direct-fetch host instead of the postMessage bridge. */
81
+ export function setHost(h) {
82
+ host = h;
83
+ }
84
+
85
+ // req: { query } (a named query the model publishes) or { malloy } (restricted
86
+ // Malloy text — the server's restricted mode is the gate). A composite dashboard
87
+ // runs one { query } per tile and combines the results client-side (see
88
+ // CompositeDashboard), so there's no single "run the whole dashboard" request.
89
+ // The host resolves the request; the result shape is normalized across hosts
90
+ // (dev server: {stable_result, problems[]}; hosted: {stableResult, error}).
91
+ export function runQuery(req, givens) {
92
+ return Promise.resolve(host.run(req, givens)).then((m) => ({
93
+ ok: !!m.ok,
94
+ rows: m.rows || [],
95
+ result: m.stable_result ?? m.stableResult,
96
+ error: m.ok
97
+ ? undefined
98
+ : String(m.error ?? (m.problems || []).map((p) => p.message).join("; ") ?? "query failed"),
99
+ }));
100
+ }
101
+
102
+ // Panel/data query text may come with or without a leading `run:`.
103
+ export const asRunText = (text) => (/^\s*run\s*:/.test(text) ? text : `run: ${text}`);
104
+
105
+ /** Run restricted Malloy text, resolve to the result rows (array of objects). */
106
+ export function runData(malloy, givens) {
107
+ return runQuery({ malloy: asRunText(malloy) }, givens ?? {}).then((m) => {
108
+ if (!m.ok) throw new Error(m.error);
109
+ return m.rows;
110
+ });
111
+ }
112
+
113
+ // ── givens state (context) ──────────────────────────────────────────
114
+ const Ctx = createContext(null);
115
+
116
+ export function useDashboard() {
117
+ const ctx = useContext(Ctx);
118
+ if (!ctx) throw new Error("dashboard hooks must run inside the dashboard runtime");
119
+ return ctx;
120
+ }
121
+
122
+ /** One given's value + setter + declaration spec: const state = useGiven("STATE").
123
+ `value` is the DRAFT value the control is editing — in the live (autorun)
124
+ default draft === committed, so a control change re-runs immediately; under
125
+ `autorun=false` the draft accumulates until Controls' Apply commits it. */
126
+ export function useGiven(name) {
127
+ const { draft, setGiven } = useDashboard();
128
+ const spec = useMemo(() => givenSpecs().find((s) => s.name === name), [name]);
129
+ return {
130
+ value: draft[name],
131
+ set: useCallback((v) => setGiven(name, v), [name, setGiven]),
132
+ spec,
133
+ };
134
+ }
135
+
136
+ // ── queries as hooks ────────────────────────────────────────────────
137
+ /** Run a query and get plain data back: { rows, result, loading, error }.
138
+ req: { query?: string, malloy?: string, givens?: object }. For charting
139
+ with your own components — Panel is the same thing plus Malloy's renderer. */
140
+ export function useQuery(req) {
141
+ const wire = req.malloy ? { malloy: asRunText(req.malloy) } : { query: req.query };
142
+ const skip = !!req.skip; // a Panel handed a pre-run result fetches nothing
143
+ const givens = req.givens ?? {};
144
+ const key = JSON.stringify([wire, givens, skip]);
145
+ const [state, setState] = useState({ rows: [], loading: !skip });
146
+ useEffect(() => {
147
+ if (skip) return;
148
+ let cancelled = false;
149
+ setState((s) => ({ ...s, loading: true }));
150
+ runQuery(wire, givens).then((m) => {
151
+ if (cancelled) return;
152
+ if (m.ok) setState({ rows: m.rows, result: m.result, loading: false });
153
+ else setState({ rows: [], loading: false, error: m.error });
154
+ });
155
+ return () => {
156
+ cancelled = true;
157
+ };
158
+ }, [key]);
159
+ return state;
160
+ }
161
+
162
+ // ── suggestions / typeahead ─────────────────────────────────────────
163
+ // A given's options come from its structured `# suggest { … }` tag, which the
164
+ // engine surfaces as spec.suggest:
165
+ // { source, dimension } — distinct values of a dimension: run: <source> -> <field>
166
+ // { query [, dimension] } — a named query's first column: run: <query>
167
+ // When the dimension is known, typed text refines the base server-side:
168
+ // <base> + { where: lower(<field>) ~ f'<typed>%'; limit: 50 }
169
+ // (case-insensitive prefix; the typed text is escaped through the filter
170
+ // serializer so `%`/`,`/quotes can't break out). Without a dimension the
171
+ // runtime prefix-filters the fetched list client-side.
172
+ const TYPEAHEAD_LIMIT = 50;
173
+ const optionCache = new Map();
174
+
175
+ function firstColumn(rows) {
176
+ return rows.map((r) => Object.values(r)[0]).filter((v) => v != null);
177
+ }
178
+
179
+ const quoteField = (f) => (/^[A-Za-z_]\w*$/.test(f) ? f : `\`${f}\``);
180
+
181
+ function suggestBase(s) {
182
+ if (s.query) return `run: ${s.query}`;
183
+ if (s.source && s.dimension) return `run: ${s.source} -> ${quoteField(s.dimension)}`;
184
+ return null;
185
+ }
186
+
187
+ function typeaheadText(s, typed) {
188
+ const filterSrc = filters.startsWith(typed.toLowerCase()).replace(/'/g, "\\'");
189
+ return `${suggestBase(s)} + { where: lower(${quoteField(s.dimension)}) ~ f'${filterSrc}'; limit: ${TYPEAHEAD_LIMIT} }`;
190
+ }
191
+
192
+ /** Options for a control, from the given's `# suggest {…}` tag. Pass the text
193
+ the user has typed so far for typeahead: { options, loading }. */
194
+ export function useOptions(name, typed) {
195
+ // Narrow suggestions against the DRAFT filters the user is composing, so a
196
+ // related-filter suggest query (e.g. brand narrowed by category) tracks
197
+ // edits even before Apply under autorun=false.
198
+ const { draft: givens } = useDashboard();
199
+ const spec = givenSpecs().find((s) => s.name === name);
200
+ const suggest = spec && spec.suggest;
201
+ const base = suggest ? suggestBase(suggest) : null;
202
+ const term = (typed ?? "").trim();
203
+ // RELATED FILTERS: suggestion queries run with the dashboard's CURRENT given
204
+ // values, so a suggest query that references other givens (e.g. brand_suggest
205
+ // with `where: product_category ~ $CATEGORY`) narrows as the user filters.
206
+ // The suggested given itself is excluded — its current value is what the
207
+ // user is replacing; self-filtering would collapse the list to the current
208
+ // pick. Which givens apply (if any) stays declared in the model's query.
209
+ const others = {};
210
+ for (const k of Object.keys(givens)) if (k !== name) others[k] = givens[k];
211
+ const othersKey = JSON.stringify(others);
212
+ const [state, setState] = useState({ options: [], loading: !!base });
213
+ useEffect(() => {
214
+ if (!base) return;
215
+ let cancelled = false;
216
+ // With a known dimension the typed term refines server-side; otherwise the
217
+ // full base list is fetched once (per given values) and filtered client-side.
218
+ const serverSide = !!suggest.dimension;
219
+ const key = `${name}\0${serverSide ? term.toLowerCase() : ""}\0${othersKey}`;
220
+ const hit = optionCache.get(key);
221
+ if (hit) {
222
+ setState({ options: clientFilter(hit, serverSide, term), loading: false });
223
+ return;
224
+ }
225
+ setState((s) => ({ ...s, loading: true }));
226
+ // Debounce keystrokes; the empty-term (full list) fetch runs immediately.
227
+ const timer = setTimeout(
228
+ () => {
229
+ const q = term && serverSide ? typeaheadText(suggest, term) : base;
230
+ runData(q, JSON.parse(othersKey))
231
+ .then((rows) => {
232
+ const options = firstColumn(rows);
233
+ optionCache.set(key, options);
234
+ if (!cancelled)
235
+ setState({ options: clientFilter(options, serverSide, term), loading: false });
236
+ })
237
+ .catch(() => {
238
+ if (!cancelled) setState({ options: [], loading: false });
239
+ });
240
+ },
241
+ term && serverSide ? 150 : 0,
242
+ );
243
+ return () => {
244
+ cancelled = true;
245
+ clearTimeout(timer);
246
+ };
247
+ }, [name, base, term, othersKey]);
248
+ return state;
249
+ }
250
+
251
+ // Query-form suggests without a dimension can't be refined server-side (the
252
+ // runtime doesn't know the output column) — prefix-filter the fetched list.
253
+ function clientFilter(options, serverSide, term) {
254
+ if (!term || serverSide) return options;
255
+ const lower = term.toLowerCase();
256
+ return options.filter((o) => String(o).toLowerCase().startsWith(lower)).slice(0, TYPEAHEAD_LIMIT);
257
+ }
258
+
259
+ // ── Panel: run a query, render with Malloy's renderer ───────────────
260
+ // Inputs (pick one): `query` (a model query name or `source -> view`), `malloy`
261
+ // (restricted text), `dashboard` (run the current composite artifact's tiles),
262
+ // or a pre-run `result` (render it as-is, fetch nothing — the composite simple
263
+ // path hands the combined result straight in).
264
+ export function Panel({ query, malloy, dashboard, result: presetResult, givens, style }) {
265
+ // Input precedence: explicit malloy / dashboard / query prop wins. A BARE
266
+ // <Panel/> (no input) renders "this dashboard": a COMPOSITE artifact (tiles,
267
+ // empty query) delegates to CompositeDashboard; a single-query artifact runs
268
+ // its own `dashboardInfo().query`. So a composite must fall through to
269
+ // dashboard mode rather than run an empty query.
270
+ const info = dashboardInfo();
271
+ // A COMPOSITE dashboard (tiles) is rendered by CompositeDashboard, which runs
272
+ // its tiles and combines them into one Malloy-rendered `# dashboard`. A bare
273
+ // <Panel/> or an explicit <Panel dashboard/> on a composite delegates here; an
274
+ // explicit query / malloy / preset does not. `info.tiles`/`isComposite` are
275
+ // stable for a given call site, so this early return never changes the hook
276
+ // count across renders.
277
+ const isComposite = !query && !malloy && presetResult === undefined && (dashboard || info.tiles);
278
+ if (isComposite && info.tiles) {
279
+ return <CompositeDashboard givens={givens} style={style} />;
280
+ }
281
+ // Past the composite early-return this is always a single query: an explicit
282
+ // malloy / query prop, else the artifact's own query.
283
+ const req = malloy ? { malloy } : query ? { query } : { query: info.query };
284
+ const hasPreset = presetResult !== undefined;
285
+ const live = useQuery(hasPreset ? { skip: true } : { ...req, givens });
286
+ const result = hasPreset ? presetResult : live.result;
287
+ const loading = hasPreset ? false : live.loading;
288
+ const error = hasPreset ? undefined : live.error;
289
+ const ref = useRef(null);
290
+ // Drill: a dimension declared `# drill { to=[<slug>|self, …] }` makes clicking
291
+ // its cell navigate to another dashboard (slug) and/or filter in place (self),
292
+ // seeding the given named like the dimension, upcased (category → CATEGORY).
293
+ const { setGiven } = useDashboard();
294
+ const setGivenRef = useRef(setGiven);
295
+ setGivenRef.current = setGiven;
296
+ const [menu, setMenu] = useState(null); // { x, y, items:[{label, run}] } | null
297
+ const drillNamesRef = useRef(new Set()); // drillable field names for the current result
298
+ const observerRef = useRef(null);
299
+ const onCellClick = useCallback((payload) => {
300
+ const drill = resolveDrill(payload);
301
+ if (!drill) return;
302
+ const { dests, given, filterExpr } = drill;
303
+ // `self` needs a matching given on THIS dashboard to filter in place (case-insensitive).
304
+ const selfSpec = givenSpecs().find((s) => s.name.toUpperCase() === given.toUpperCase());
305
+ const run = (dest) => {
306
+ if (dest === "self") {
307
+ if (selfSpec) setGivenRef.current(selfSpec.name, filterExpr);
308
+ } else {
309
+ host.navigate(dest, { [given]: filterExpr });
310
+ }
311
+ };
312
+ const valid = dests.filter((d) => d !== "self" || selfSpec);
313
+ if (!valid.length) return;
314
+ if (valid.length === 1) return run(valid[0]);
315
+ const items = valid.map((d) => ({
316
+ label: d === "self" ? "Filter this dashboard" : humanizeSlug(d),
317
+ run: () => run(d),
318
+ }));
319
+ const ev = payload.event || {};
320
+ setMenu({ x: ev.clientX || 0, y: ev.clientY || 0, items });
321
+ }, []);
322
+ // Keep ONE renderer/viz alive for the Panel's lifetime and update it in place
323
+ // (setResult + render) on each new result. Rebuilding the MalloyRenderer per
324
+ // result — the old approach — cold-re-inits plugins/metadata/chart workers and
325
+ // remove()s the previous render, so the whole panel blanks and flashes on every
326
+ // control change. render() disposes only the prior render, not the renderer.
327
+ const vizRef = useRef(null);
328
+ useEffect(() => {
329
+ if (!ref.current || !result) return;
330
+ const container = ref.current;
331
+ try {
332
+ if (!vizRef.current) {
333
+ const renderer = new MalloyRenderer({});
334
+ // Virtualization OFF, and no scrollEl. The renderer's virtualizers
335
+ // only work when they own the scroll container; here the Panel is the
336
+ // scroller. scrollEl binds EVERY virtualizer in the result (each
337
+ // nested `# dashboard` table gets one) to that single element and
338
+ // they fight over its offset; without scrollEl they unmount whatever
339
+ // "scrolled away" from their frozen offset 0 and the content
340
+ // collapses. Either way the panel snaps to 0 while the user scrolls —
341
+ // the "screen jumps around" bug. Static rendering is fine at the
342
+ // dashboard row cap (5000).
343
+ vizRef.current = renderer.createViz({
344
+ // rowLimit caps how many rows any table (incl. dashboard cards) builds
345
+ // into the DOM. Without it, an unbounded card table (e.g. group_by user)
346
+ // renders every row as static DOM (virtualization is off) and crashes
347
+ // the tab. The renderer truncates data() at rowLimit and shows a
348
+ // "Limiting … to N records" footer — so a huge table degrades to
349
+ // "top N + too many rows" instead of blowing up. Dashboard cards get
350
+ // this via the tableConfig fallback (they pass no rowLimit of their own).
351
+ tableConfig: { enableDrill: false, disableVirtualization: true, rowLimit: 1000 },
352
+ dashboardConfig: { disableVirtualization: true },
353
+ // Drill: clicking a `# drill`-tagged dimension navigates / filters in
354
+ // place (no-op for cells without the tag).
355
+ onClick: onCellClick,
356
+ });
357
+ }
358
+ vizRef.current.setResult(result);
359
+ vizRef.current.render(container);
360
+ // Flag drillable cells so they read as links (see .dash-drill in THEME_CSS).
361
+ // `# dashboard` cards render progressively, so a one-shot mark right after
362
+ // render() misses tables that appear a frame later — re-mark on DOM changes.
363
+ drillNamesRef.current = drillFieldNames(vizRef.current);
364
+ markDrillableCells(container, drillNamesRef.current);
365
+ if (!observerRef.current && typeof MutationObserver !== "undefined") {
366
+ let raf = 0;
367
+ observerRef.current = new MutationObserver(() => {
368
+ cancelAnimationFrame(raf);
369
+ raf = requestAnimationFrame(() => markDrillableCells(container, drillNamesRef.current));
370
+ });
371
+ observerRef.current.observe(container, { childList: true, subtree: true });
372
+ }
373
+ } catch (err) {
374
+ // Drop the viz so the next good result rebuilds cleanly from scratch.
375
+ try {
376
+ vizRef.current && vizRef.current.remove();
377
+ } catch {
378
+ /* ignore */
379
+ }
380
+ vizRef.current = null;
381
+ container.innerHTML = "";
382
+ const pre = document.createElement("pre");
383
+ pre.style.cssText = "color:crimson;white-space:pre-wrap;font:12px ui-monospace,monospace";
384
+ pre.textContent = "Malloy render error:\n" + ((err && err.stack) || String(err));
385
+ container.appendChild(pre);
386
+ }
387
+ }, [result]);
388
+ // Dispose the viz only when the Panel unmounts.
389
+ useEffect(
390
+ () => () => {
391
+ try {
392
+ observerRef.current && observerRef.current.disconnect();
393
+ vizRef.current && vizRef.current.remove();
394
+ } catch {
395
+ /* ignore */
396
+ }
397
+ observerRef.current = null;
398
+ vizRef.current = null;
399
+ },
400
+ [],
401
+ );
402
+ if (error) return <pre style={{ color: "crimson", whiteSpace: "pre-wrap" }}>{error}</pre>;
403
+ // display:grid + width:100% + a real min-height: the Malloy render web
404
+ // component has no intrinsic height for charts/maps, so it collapses in a
405
+ // plain block container.
406
+ //
407
+ // maxHeight caps the panel at the frame viewport so the container is the
408
+ // element that ACTUALLY scrolls. The renderer's virtualizer is bound to it
409
+ // via scrollEl; if the page scrolled instead, the virtualizer would fight
410
+ // the user (it keeps restoring its own offset → the "screen jumps around"
411
+ // bug with `# dashboard` results). Override via the style prop only with a
412
+ // layout that keeps the panel itself scrollable (e.g. flex:1 + minHeight:0
413
+ // in a 100vh column, as DefaultDashboard does).
414
+ return (
415
+ <>
416
+ <div
417
+ ref={ref}
418
+ style={{
419
+ display: "grid",
420
+ width: "100%",
421
+ minHeight: 480,
422
+ maxHeight: "100vh",
423
+ overflow: "auto",
424
+ // Trap the Malloy renderer's own z-indexes (its sticky dashboard-row
425
+ // header is position:sticky z-index:200). Without an isolate here the
426
+ // Panel is position:static, so that 200 escapes into the ROOT stacking
427
+ // context and paints OVER a control's open dropdown. isolate makes the
428
+ // Panel a stacking context so its internals stay below the filter bar.
429
+ isolation: "isolate",
430
+ // A light results surface in both light/dark shells — the Malloy renderer
431
+ // has no dark theme, so it always draws on a legible card.
432
+ background: "var(--dash-panel-bg, #fff)",
433
+ color: "#171717",
434
+ border: "1px solid var(--dash-border, #e5e7eb)",
435
+ borderRadius: "var(--dash-radius, 8px)",
436
+ opacity: loading ? 0.4 : 1,
437
+ transition: "opacity .15s",
438
+ ...style,
439
+ }}
440
+ />
441
+ {menu && <DrillMenu menu={menu} onClose={() => setMenu(null)} />}
442
+ </>
443
+ );
444
+ }
445
+
446
+ // ── composite dashboards ────────────────────────────────────────────
447
+ // A composite artifact (`# artifact { tiles=[…] }`) runs each tile as its own
448
+ // query and combines the results into ONE `# dashboard` result that Malloy's own
449
+ // dashboard renderer lays out (colspan / break / card heights, and single-row
450
+ // aggregate tiles merged as top-level KPIs — see combine.ts). Tiles fetch in
451
+ // parallel; the dashboard paints once they've all settled, with a single early
452
+ // paint if one tile straggles (see below) so a slow tile can't block the rest.
453
+ //
454
+ // The host injects `dashboardInfo().tileSpecs` = [{ run, name, givens:[names] }]
455
+ // so each tile runs with ONLY the givens it references (binding a given a tile
456
+ // doesn't reference fails the compile). Falls back to the raw `tiles` string
457
+ // array (all givens) when the host didn't provide specs.
458
+ function tileSpecs() {
459
+ const info = dashboardInfo();
460
+ if (Array.isArray(info.tileSpecs) && info.tileSpecs.length) return info.tileSpecs;
461
+ return (info.tiles || []).map((run) => ({ run, name: undefined, givens: null }));
462
+ }
463
+
464
+ // The controls hold the UNION of givens; a tile gets only the ones it declares.
465
+ // `names === null` (fallback, no per-tile info) → pass everything through.
466
+ function pickGivens(givens, names) {
467
+ if (!names) return givens;
468
+ const out = {};
469
+ for (const n of names) if (n in (givens || {})) out[n] = givens[n];
470
+ return out;
471
+ }
472
+
473
+ // Card name for a tile: its declared name, else the view name from the
474
+ // run-expression (`source -> view` → view).
475
+ function tileName(spec) {
476
+ if (spec.name) return spec.name;
477
+ const run = String(spec.run || "");
478
+ const arrow = run.lastIndexOf("->");
479
+ return (arrow >= 0 ? run.slice(arrow + 2) : run).trim();
480
+ }
481
+
482
+ function tileStatusColor(status) {
483
+ if (status === "ok") return "var(--dash-accent, #2563eb)";
484
+ if (status === "error") return "var(--dash-danger, #dc2626)";
485
+ return "var(--dash-muted, #666)";
486
+ }
487
+ function tileStatusGlyph(status) {
488
+ return status === "ok" ? "✓" : status === "error" ? "✕" : "⋯";
489
+ }
490
+
491
+ export function CompositeDashboard({ givens, style }) {
492
+ const specs = tileSpecs();
493
+ const columns = dashboardInfo().dashboard_columns;
494
+ const gkey = JSON.stringify(givens ?? {});
495
+ // NO incremental rendering. Run every tile, WAIT for all of them, then combine
496
+ // and render the Malloy dashboard exactly ONCE — no skeleton, no batched fill,
497
+ // no per-tile flashing. While loading, show a per-tile status list (state +
498
+ // load time) with a "Show incomplete" button that renders whatever HAS loaded —
499
+ // so a slow or hung tile is diagnosable, not an endless spinner.
500
+ const dataRef = useRef({}); // run -> full result (loaded tiles)
501
+ const startsRef = useRef({}); // run -> perf.now() when the query fired
502
+ const [states, setStates] = useState({}); // run -> { status:'pending'|'ok'|'error', ms?, error? }
503
+ const [combined, setCombined] = useState(null); // the rendered dashboard; set once (all-done or forced)
504
+ const [, tick] = useState(0); // ticks the live elapsed clock for pending rows
505
+
506
+ // Combine whatever has loaded into ONE dashboard result and render it. (A
507
+ // single-tile `tiles=[X]` is normalized upstream to a single-query artifact, so
508
+ // it never reaches here — CompositeDashboard only handles genuine 2+ composites.)
509
+ const renderRef = useRef(null);
510
+ renderRef.current = () => {
511
+ const data = dataRef.current;
512
+ const usable = specs.filter((t) => data[t.run]);
513
+ setCombined(
514
+ usable.length
515
+ ? combineTiles(
516
+ usable.map((t) => ({ name: tileName(t), result: data[t.run] })),
517
+ { columns },
518
+ )
519
+ : null,
520
+ );
521
+ };
522
+
523
+ useEffect(() => {
524
+ let cancelled = false;
525
+ dataRef.current = {};
526
+ startsRef.current = {};
527
+ setStates(Object.fromEntries(specs.map((t) => [t.run, { status: "pending" }])));
528
+ setCombined(null);
529
+ let settled = 0;
530
+ for (const t of specs) {
531
+ startsRef.current[t.run] = performance.now();
532
+ runQuery({ query: t.run }, pickGivens(givens, t.givens)).then((m) => {
533
+ if (cancelled) return;
534
+ const ms = Math.round(performance.now() - (startsRef.current[t.run] ?? performance.now()));
535
+ if (m.ok && m.result) {
536
+ dataRef.current[t.run] = m.result;
537
+ setStates((s) => ({ ...s, [t.run]: { status: "ok", ms } }));
538
+ } else {
539
+ setStates((s) => ({ ...s, [t.run]: { status: "error", ms, error: m.error || "query failed" } }));
540
+ }
541
+ settled++;
542
+ if (settled >= specs.length) renderRef.current(); // ALL settled → render the dashboard once
543
+ });
544
+ }
545
+ return () => {
546
+ cancelled = true;
547
+ };
548
+ // eslint-disable-next-line react-hooks/exhaustive-deps
549
+ }, [gkey]);
550
+
551
+ // While loading (status list up) with pending tiles, tick so elapsed clocks advance.
552
+ const statusOf = (t) => states[t.run]?.status ?? "pending";
553
+ const anyPending = specs.some((t) => statusOf(t) === "pending");
554
+ useEffect(() => {
555
+ if (combined || !anyPending) return;
556
+ const id = setInterval(() => tick((n) => n + 1), 400);
557
+ return () => clearInterval(id);
558
+ }, [combined, anyPending]);
559
+
560
+ const total = specs.length;
561
+ const doneCount = specs.filter((t) => statusOf(t) !== "pending").length;
562
+ const okCount = specs.filter((t) => statusOf(t) === "ok").length;
563
+ const failedTiles = specs
564
+ .filter((t) => statusOf(t) === "error")
565
+ .map((t) => ({ name: tileName(t), message: states[t.run].error }));
566
+
567
+ // "Show incomplete" → render whatever has loaded now.
568
+ const showIncomplete = () => {
569
+ renderRef.current();
570
+ };
571
+
572
+ const ProgressBar = ({ h = 4 }) => (
573
+ <div style={{ height: h, borderRadius: h / 2, background: "var(--dash-border, #e5e7eb)", overflow: "hidden" }}>
574
+ <div
575
+ style={{
576
+ height: "100%",
577
+ width: `${total ? (doneCount / total) * 100 : 0}%`,
578
+ background: "var(--dash-accent, #2563eb)",
579
+ transition: "width .2s",
580
+ }}
581
+ />
582
+ </div>
583
+ );
584
+
585
+ // 1) Rendered dashboard (all tiles settled, or the user forced it).
586
+ if (combined) {
587
+ return (
588
+ <div style={{ ...style }}>
589
+ {failedTiles.length > 0 && (
590
+ <div
591
+ style={{
592
+ marginBottom: 12,
593
+ padding: "8px 12px",
594
+ borderRadius: "var(--dash-radius, 8px)",
595
+ background: "color-mix(in srgb, var(--dash-danger, #dc2626) 10%, transparent)",
596
+ color: "var(--dash-danger, #dc2626)",
597
+ fontSize: 13,
598
+ }}
599
+ >
600
+ {failedTiles.map((f) => `${f.name}: ${f.message}`).join(" · ")}
601
+ </div>
602
+ )}
603
+ {/* Page-scroll layout (DefaultDashboard has no fixed height): the Panel
604
+ grows with the combined dashboard's content and the PAGE scrolls.
605
+ maxHeight:"none" removes the Panel's default 100vh cap; it keeps its
606
+ own minHeight floor. (An earlier flex:1/minHeight:0 here collapsed the
607
+ Panel to ~0px once DefaultDashboard stopped being a 100vh column.) */}
608
+ <Panel result={combined} style={{ maxHeight: "none" }} />
609
+ </div>
610
+ );
611
+ }
612
+
613
+ // 2) Loading (or everything settled with nothing renderable): per-tile status list.
614
+ return (
615
+ <div style={{ padding: 24, maxWidth: 640, ...style }}>
616
+ <div style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between", gap: 12, marginBottom: 12 }}>
617
+ <div style={{ fontWeight: 600 }}>
618
+ {doneCount >= total ? "Dashboard loaded with errors" : "Loading dashboard…"} ({doneCount}/{total})
619
+ </div>
620
+ {doneCount < total && (
621
+ <button
622
+ onClick={showIncomplete}
623
+ disabled={okCount === 0}
624
+ style={{
625
+ padding: "4px 10px",
626
+ fontSize: 12,
627
+ fontWeight: 500,
628
+ cursor: okCount === 0 ? "not-allowed" : "pointer",
629
+ opacity: okCount === 0 ? 0.5 : 1,
630
+ borderRadius: "var(--dash-radius, 6px)",
631
+ border: "1px solid var(--dash-border, #e5e7eb)",
632
+ background: "var(--dash-control-bg, #fff)",
633
+ color: "var(--dash-fg, #171717)",
634
+ }}
635
+ >
636
+ Show incomplete
637
+ </button>
638
+ )}
639
+ </div>
640
+ <div style={{ marginBottom: 16 }}>
641
+ <ProgressBar />
642
+ </div>
643
+ <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
644
+ {specs.map((t) => {
645
+ const st = states[t.run] ?? { status: "pending" };
646
+ const elapsed =
647
+ st.status === "pending"
648
+ ? (performance.now() - (startsRef.current[t.run] ?? performance.now())) / 1000
649
+ : (st.ms ?? 0) / 1000;
650
+ return (
651
+ <div key={t.run}>
652
+ <div style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 13 }}>
653
+ <span style={{ width: 14, textAlign: "center", color: tileStatusColor(st.status) }}>
654
+ {tileStatusGlyph(st.status)}
655
+ </span>
656
+ <span style={{ flex: 1, fontWeight: 500 }}>{tileName(t)}</span>
657
+ <span style={{ color: "var(--dash-muted, #666)", fontVariantNumeric: "tabular-nums" }}>
658
+ {st.status === "pending" ? `${elapsed.toFixed(1)}s…` : `${elapsed.toFixed(2)}s`}
659
+ </span>
660
+ </div>
661
+ {st.status === "error" && (
662
+ <div style={{ marginLeft: 22, fontSize: 12, color: "var(--dash-danger, #dc2626)" }}>{st.error}</div>
663
+ )}
664
+ </div>
665
+ );
666
+ })}
667
+ </div>
668
+ </div>
669
+ );
670
+ }
671
+
672
+ // A small popup shown at the cursor when a drilled dimension offers more than one
673
+ // target (e.g. open a dashboard vs. filter in place). Fixed-position, above
674
+ // everything; a full-viewport backdrop dismisses it.
675
+ function DrillMenu({ menu, onClose }) {
676
+ useEffect(() => {
677
+ const onKey = (e) => e.key === "Escape" && onClose();
678
+ window.addEventListener("keydown", onKey);
679
+ return () => window.removeEventListener("keydown", onKey);
680
+ }, [onClose]);
681
+ return (
682
+ <>
683
+ <div onClick={onClose} style={{ position: "fixed", inset: 0, zIndex: 2000 }} />
684
+ <div
685
+ style={{
686
+ position: "fixed",
687
+ left: Math.min(menu.x, (typeof window !== "undefined" ? window.innerWidth : 9999) - 220),
688
+ top: menu.y,
689
+ zIndex: 2001,
690
+ minWidth: 180,
691
+ padding: 4,
692
+ background: "var(--dash-control-bg, #fff)",
693
+ color: "var(--dash-fg, #171717)",
694
+ border: "1px solid var(--dash-border, #e5e7eb)",
695
+ borderRadius: "var(--dash-radius, 8px)",
696
+ boxShadow: "0 8px 24px rgba(0,0,0,.16)",
697
+ font: "14px var(--dash-font, system-ui, sans-serif)",
698
+ }}
699
+ >
700
+ {menu.items.map((it, i) => (
701
+ <button
702
+ key={i}
703
+ onClick={() => {
704
+ it.run();
705
+ onClose();
706
+ }}
707
+ onMouseEnter={(e) => (e.currentTarget.style.background = "var(--dash-controls-bg, #f3f4f6)")}
708
+ onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
709
+ style={{
710
+ display: "block",
711
+ width: "100%",
712
+ textAlign: "left",
713
+ border: "none",
714
+ background: "transparent",
715
+ color: "inherit",
716
+ font: "inherit",
717
+ padding: "7px 10px",
718
+ borderRadius: 6,
719
+ cursor: "pointer",
720
+ }}
721
+ >
722
+ {it.label}
723
+ </button>
724
+ ))}
725
+ </div>
726
+ </>
727
+ );
728
+ }
729
+
730
+ // ── mounting ────────────────────────────────────────────────────────
731
+ // Coerce a URL-string given value to the given's declared Malloy type.
732
+ // filter<T> given values ARE strings (filter expression source) — no coercion.
733
+ function coerceGiven(raw, type) {
734
+ if (type === "number") return raw === "" ? raw : Number(raw);
735
+ if (type === "boolean") return raw === true || raw === "true";
736
+ return raw;
737
+ }
738
+
739
+ function showFatal(msg) {
740
+ const root = document.getElementById("root");
741
+ if (!root) return;
742
+ const pre = document.createElement("pre");
743
+ pre.style.cssText =
744
+ "color:crimson;white-space:pre-wrap;padding:16px;margin:0;font:12px ui-monospace,monospace;border-bottom:2px solid crimson";
745
+ pre.textContent = "⚠ Dashboard error:\n" + msg;
746
+ root.prepend(pre);
747
+ }
748
+ const isBenign = (msg) => typeof msg === "string" && msg.indexOf("ResizeObserver loop") !== -1;
749
+
750
+ class ErrorBoundary extends React.Component {
751
+ constructor(p) {
752
+ super(p);
753
+ this.state = { err: null };
754
+ }
755
+ static getDerivedStateFromError(err) {
756
+ return { err };
757
+ }
758
+ render() {
759
+ if (this.state.err) {
760
+ return (
761
+ <pre style={{ color: "crimson", whiteSpace: "pre-wrap", padding: 16 }}>
762
+ {"⚠ Render error:\n" + (this.state.err.stack || String(this.state.err))}
763
+ </pre>
764
+ );
765
+ }
766
+ return this.props.children;
767
+ }
768
+ }
769
+
770
+ // Shallow value equality over the union of keys — givens values are strings,
771
+ // numbers, booleans (filter-expression source or scalars), never objects.
772
+ function sameGivens(a, b) {
773
+ const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
774
+ for (const k of keys) if (a[k] !== b[k]) return false;
775
+ return true;
776
+ }
777
+
778
+ function Root({ Dashboard, extraProps }) {
779
+ // Seed givens: URL (shareable links) > the artifact tag's per-dashboard
780
+ // defaults (`# artifact { givens { X="…" } }`) > the given declaration's
781
+ // default. A given with no usable value is omitted — the model default
782
+ // still applies at run time; the control just starts empty.
783
+ const seed = useMemo(() => {
784
+ const g = {};
785
+ // URL givens are `$`-prefixed (`?$NAME=…`) — bare params are reserved for
786
+ // future dimension filters. Normalize to a case-insensitive lookup so a
787
+ // drilled dimension (`name`) matches its given (`NAME`) with no config.
788
+ const fromUrl = window.__INITIAL_GIVENS__ || {};
789
+ const urlByLower = {};
790
+ for (const [k, v] of Object.entries(fromUrl)) {
791
+ if (k[0] === "$") urlByLower[k.slice(1).toLowerCase()] = v;
792
+ }
793
+ const fromTag = dashboardInfo().givens || {};
794
+ for (const spec of givenSpecs()) {
795
+ const raw = urlByLower[spec.name.toLowerCase()];
796
+ if (raw !== undefined && raw !== null) g[spec.name] = coerceGiven(raw, spec.type);
797
+ else if (fromTag[spec.name] !== undefined) g[spec.name] = fromTag[spec.name];
798
+ else if (spec.default !== undefined) g[spec.name] = spec.default;
799
+ }
800
+ return g;
801
+ }, []);
802
+ // Live by default; `# artifact { autorun=false }` stages changes behind Apply.
803
+ const autorun = dashboardInfo().autorun !== false;
804
+ // `committed` is what queries run with; `draft` is what the controls edit.
805
+ // Live: every setGiven commits at once (draft === committed). Staged: setGiven
806
+ // only touches the draft; apply() promotes it to committed.
807
+ const [committed, setCommitted] = useState(seed);
808
+ const [draft, setDraft] = useState(seed);
809
+ const setGiven = useCallback(
810
+ (name, value) => {
811
+ setDraft((prev) => ({ ...prev, [name]: value }));
812
+ if (autorun) setCommitted((prev) => ({ ...prev, [name]: value }));
813
+ },
814
+ [autorun],
815
+ );
816
+ const apply = useCallback(() => setCommitted(draft), [draft]);
817
+ const reset = useCallback(() => setDraft(committed), [committed]);
818
+ const dirty = !autorun && !sameGivens(draft, committed);
819
+ // Reflect the COMMITTED givens up to the trusted parent so it mirrors the
820
+ // applied state (not half-typed drafts) into the shareable URL.
821
+ useEffect(() => {
822
+ host.syncGivens(committed);
823
+ }, [committed]);
824
+ const ctx = useMemo(
825
+ () => ({ givens: committed, draft, setGiven, apply, reset, dirty, autorun }),
826
+ [committed, draft, setGiven, apply, reset, dirty, autorun],
827
+ );
828
+ return (
829
+ <Ctx.Provider value={ctx}>
830
+ <Dashboard
831
+ dashboard={dashboardInfo()}
832
+ givenSpecs={givenSpecs()}
833
+ givens={committed}
834
+ setGiven={setGiven}
835
+ Panel={Panel}
836
+ filters={filters}
837
+ useGiven={useGiven}
838
+ useOptions={useOptions}
839
+ useQuery={useQuery}
840
+ runData={runData}
841
+ {...extraProps}
842
+ />
843
+ </Ctx.Provider>
844
+ );
845
+ }
846
+
847
+ // ── default theme ───────────────────────────────────────────────────
848
+ // The Malloyyo look, expressed entirely as the `--dash-*` custom properties the
849
+ // widgets already read. Injected once at mount into document root, so it styles
850
+ // BOTH the no-code DefaultDashboard and any custom Dashboard.tsx. Authors
851
+ // override by setting the same vars on a wrapper element (more specific than
852
+ // :root wins) or via DefaultDashboard's `theme={{ accent: "…" }}` prop.
853
+ //
854
+ // Auto light/dark follows the viewer's OS via prefers-color-scheme. The results
855
+ // Panel keeps a light surface in both modes (--dash-panel-bg) because the Malloy
856
+ // renderer has no dark theme of its own — a dark shell with a light results card
857
+ // stays legible; override --dash-panel-bg if your renderer output is dark-safe.
858
+ const THEME_CSS = `
859
+ :root {
860
+ --dash-font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
861
+ --dash-bg: #ffffff;
862
+ --dash-fg: #171717;
863
+ --dash-muted: #6b7280;
864
+ --dash-border: #e5e7eb;
865
+ --dash-accent: #2563eb;
866
+ --dash-accent-fg: #ffffff;
867
+ --dash-control-bg: #ffffff;
868
+ --dash-controls-bg: #f9fafb;
869
+ --dash-chip-bg: #eef2ff;
870
+ --dash-chip-fg: #3730a3;
871
+ --dash-panel-bg: #ffffff;
872
+ --dash-radius: 8px;
873
+ --dash-danger: #dc2626;
874
+ }
875
+ @media (prefers-color-scheme: dark) {
876
+ :root {
877
+ --dash-bg: #0a0a0a;
878
+ --dash-fg: #ededed;
879
+ --dash-muted: #9ca3af;
880
+ --dash-border: #2a2a2a;
881
+ --dash-accent: #3b82f6;
882
+ --dash-accent-fg: #ffffff;
883
+ --dash-control-bg: #171717;
884
+ --dash-controls-bg: #141414;
885
+ --dash-chip-bg: #1e293b;
886
+ --dash-chip-fg: #bfdbfe;
887
+ --dash-panel-bg: #ffffff;
888
+ --dash-danger: #f87171;
889
+ }
890
+ }
891
+ /* Drillable dimension cells (marked .dash-drill by the runtime): normal text,
892
+ accent color on hover + pointer — matching the web app's clickable-item look. */
893
+ .dash-drill { cursor: pointer; }
894
+ .dash-drill:hover > .cell-content { color: var(--dash-accent, #2563eb); }
895
+ `;
896
+
897
+ // Owns the whole document background/font — right for the iframe (the dashboard
898
+ // IS the page) but wrong in-page, where the dashboard is one element inside the
899
+ // app shell and must not restyle <body>. So it's split off and injected only for
900
+ // the iframe host (mountInPage passes bodyReset:false).
901
+ const BODY_RESET_CSS = `
902
+ html, body { margin: 0; background: var(--dash-bg); color: var(--dash-fg); font-family: var(--dash-font); }
903
+ `;
904
+
905
+ function injectTheme(bodyReset) {
906
+ if (typeof document === "undefined" || document.getElementById("dash-theme")) return;
907
+ const style = document.createElement("style");
908
+ style.id = "dash-theme";
909
+ style.textContent = THEME_CSS + (bodyReset ? BODY_RESET_CSS : "");
910
+ document.head.appendChild(style);
911
+ }
912
+
913
+ // Drill (click a `# drill`-tagged dimension → navigate to another dashboard
914
+ // and/or filter in place) is wired in Panel via the renderer's onClick — see
915
+ // onCellClick there. The trusted parent resolves the real URL (only it knows the
916
+ // environment's dashboard shape: hosted /datasets/:id/dashboard/:slug vs local
917
+ // /?d=slug) from the structured {dashboard, givens} we post.
918
+
919
+ /** Frame entry point: mount a Dashboard component (custom or default) into
920
+ `rootEl` (defaults to #root, the sandboxed iframe's mount node). The tag-only
921
+ in-page host passes its own container so the dashboard mounts directly in the
922
+ trusted page — no iframe. */
923
+ export function mount(Dashboard, extraProps, rootEl: any = null, opts: any = {}) {
924
+ injectTheme(opts.bodyReset !== false);
925
+ window.addEventListener("error", (e) => {
926
+ if (isBenign(e && e.message)) return;
927
+ showFatal((e.error && e.error.stack) || e.message);
928
+ });
929
+ window.addEventListener("unhandledrejection", (e) => {
930
+ const r = e.reason;
931
+ if (isBenign(r && r.message)) return;
932
+ showFatal(String((r && (r.stack || r.message)) || r));
933
+ });
934
+ const root = createRoot(rootEl || document.getElementById("root"));
935
+ root.render(
936
+ <ErrorBoundary>
937
+ <Root Dashboard={Dashboard} extraProps={extraProps} />
938
+ </ErrorBoundary>,
939
+ );
940
+ // Returned so an in-page host can unmount on teardown (client navigation
941
+ // between dashboards); the iframe host discards it — the frame unloads whole.
942
+ return root;
943
+ }