@ai-gui/plugin-bigscreen 0.36.3 → 0.37.1

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.
@@ -1,9 +1,9 @@
1
1
  "use strict";
2
- const require_options = require('./options-BpHnWRpI.cjs');
3
- const echarts_charts = require_options.__toESM(require("echarts/charts"));
4
- const echarts_components = require_options.__toESM(require("echarts/components"));
5
- const echarts_core = require_options.__toESM(require("echarts/core"));
6
- const echarts_renderers = require_options.__toESM(require("echarts/renderers"));
2
+ const require_positions = require('./positions-CHtRx0fb.cjs');
3
+ const echarts_charts = require_positions.__toESM(require("echarts/charts"));
4
+ const echarts_components = require_positions.__toESM(require("echarts/components"));
5
+ const echarts_core = require_positions.__toESM(require("echarts/core"));
6
+ const echarts_renderers = require_positions.__toESM(require("echarts/renderers"));
7
7
 
8
8
  //#region src/mount.ts
9
9
  (0, echarts_core.use)([
@@ -36,7 +36,9 @@ const DEFAULT_HEIGHT = {
36
36
  rank: 220,
37
37
  chart: 240,
38
38
  chart3d: 280,
39
- globe: 300
39
+ globe: 300,
40
+ timeline: 320,
41
+ graph3d: 320
40
42
  };
41
43
  const easeOut = (t) => 1 - Math.pow(1 - t, 3);
42
44
  function el(tag, attrs = {}, text) {
@@ -49,7 +51,7 @@ function el(tag, attrs = {}, text) {
49
51
  function countUp(node, panel, animate) {
50
52
  const decimals = panel.decimals ?? 0;
51
53
  const show = (value) => {
52
- node.textContent = `${panel.prefix ?? ""}${require_options.formatNumber(value, decimals)}`;
54
+ node.textContent = `${panel.prefix ?? ""}${require_positions.formatNumber(value, decimals)}`;
53
55
  };
54
56
  if (!animate || typeof requestAnimationFrame !== "function") {
55
57
  show(panel.value);
@@ -81,7 +83,7 @@ function sparkline(values, c, width, height) {
81
83
  svg.setAttribute("aria-hidden", "true");
82
84
  const fill = document.createElementNS("http://www.w3.org/2000/svg", "polygon");
83
85
  fill.setAttribute("points", `0,${height} ${points.join(" ")} ${width},${height}`);
84
- fill.setAttribute("fill", require_options.withAlpha(c.accent, .18));
86
+ fill.setAttribute("fill", require_positions.withAlpha(c.accent, .18));
85
87
  const line = document.createElementNS("http://www.w3.org/2000/svg", "polyline");
86
88
  line.setAttribute("points", points.join(" "));
87
89
  line.setAttribute("fill", "none");
@@ -97,7 +99,7 @@ function mountKpi(body, panel, c, animate) {
97
99
  const row = el("div", { class: "aigui-bs-kpi-row" });
98
100
  const value = el("span", { class: "aigui-bs-kpi-value" });
99
101
  value.style.color = c.accent;
100
- value.style.textShadow = `0 0 18px ${require_options.withAlpha(c.accent, .45)}`;
102
+ value.style.textShadow = `0 0 18px ${require_positions.withAlpha(c.accent, .45)}`;
101
103
  row.appendChild(value);
102
104
  if (panel.unit) row.appendChild(el("span", { class: "aigui-bs-kpi-unit" }, panel.unit));
103
105
  body.appendChild(row);
@@ -105,7 +107,7 @@ function mountKpi(body, panel, c, animate) {
105
107
  if (panel.delta !== void 0) {
106
108
  const up = panel.delta >= 0;
107
109
  const good = (panel.upIsGood ?? true) === up;
108
- const delta = el("span", { class: "aigui-bs-kpi-delta" }, `${up ? "▲" : "▼"} ${require_options.formatNumber(Math.abs(panel.delta) * 100, 1)}%`);
110
+ const delta = el("span", { class: "aigui-bs-kpi-delta" }, `${up ? "▲" : "▼"} ${require_positions.formatNumber(Math.abs(panel.delta) * 100, 1)}%`);
109
111
  delta.style.color = good ? c.good : c.bad;
110
112
  meta.appendChild(delta);
111
113
  }
@@ -132,12 +134,12 @@ function mountRank(body, panel, c, animate) {
132
134
  track.style.background = c.track;
133
135
  const fill = el("span", { class: "aigui-bs-rank-fill" });
134
136
  const colour = index === 0 ? c.accent : c.series[index % (c.series.length - 1) + 1];
135
- fill.style.background = `linear-gradient(90deg, ${require_options.withAlpha(colour, .55)}, ${colour})`;
136
- fill.style.boxShadow = `0 0 10px ${require_options.withAlpha(colour, .5)}`;
137
+ fill.style.background = `linear-gradient(90deg, ${require_positions.withAlpha(colour, .55)}, ${colour})`;
138
+ fill.style.boxShadow = `0 0 10px ${require_positions.withAlpha(colour, .5)}`;
137
139
  const width = `${Math.max(2, Math.max(item.value, 0) / max * 100)}%`;
138
140
  fill.style.width = animate ? "0%" : width;
139
141
  track.appendChild(fill);
140
- const value = el("span", { class: "aigui-bs-rank-value" }, `${require_options.formatNumber(item.value, Number.isInteger(item.value) ? 0 : 1)}${panel.unit ?? ""}`);
142
+ const value = el("span", { class: "aigui-bs-rank-value" }, `${require_positions.formatNumber(item.value, Number.isInteger(item.value) ? 0 : 1)}${panel.unit ?? ""}`);
141
143
  row.append(rank, name, track, value);
142
144
  body.appendChild(row);
143
145
  bars.push({
@@ -171,12 +173,13 @@ function whenSized(body, fn) {
171
173
  const handle = requestAnimationFrame(fn);
172
174
  return () => cancelAnimationFrame(handle);
173
175
  }
174
- function mountChart(body, option, theme) {
176
+ function mountChart(body, option, theme, ready) {
175
177
  let chart;
176
178
  let unfollow;
177
179
  const cancel = whenSized(body, () => {
178
180
  chart = (0, echarts_core.init)(body, theme === "dark" ? "dark" : void 0, { renderer: "canvas" });
179
181
  chart.setOption(option);
182
+ ready?.(chart);
180
183
  unfollow = follow(body, chart);
181
184
  });
182
185
  return () => {
@@ -185,10 +188,17 @@ function mountChart(body, option, theme) {
185
188
  chart?.dispose();
186
189
  };
187
190
  }
188
- function mountGl(body, build, theme, fallback) {
191
+ /**
192
+ * A chart that needs WebGL, with an optional loop running against it.
193
+ *
194
+ * `ready` may hand back a teardown of its own — the settling loop does — and it is run before the
195
+ * chart is disposed, because a frame that lands on a disposed chart throws.
196
+ */
197
+ function mountGl(body, build, theme, fallback, ready) {
189
198
  let chart;
190
199
  let unfollow;
191
200
  let cancel;
201
+ let stop;
192
202
  let disposed = false;
193
203
  loadGl().then(() => {
194
204
  if (disposed) return;
@@ -196,6 +206,7 @@ function mountGl(body, build, theme, fallback) {
196
206
  if (disposed) return;
197
207
  chart = (0, echarts_core.init)(body, theme === "dark" ? "dark" : void 0, { renderer: "canvas" });
198
208
  chart.setOption(build());
209
+ stop = ready?.(chart) ?? void 0;
199
210
  unfollow = follow(body, chart);
200
211
  });
201
212
  }).catch(() => {
@@ -207,6 +218,7 @@ function mountGl(body, build, theme, fallback) {
207
218
  return () => {
208
219
  disposed = true;
209
220
  cancel?.();
221
+ stop?.();
210
222
  unfollow?.();
211
223
  chart?.dispose();
212
224
  };
@@ -214,7 +226,103 @@ function mountGl(body, build, theme, fallback) {
214
226
  function note(body, text) {
215
227
  body.replaceChildren(el("div", { class: "aigui-bs-note" }, text));
216
228
  }
217
- function mountPanel(panel, definition, c, animate) {
229
+ /**
230
+ * What a click on a claim does.
231
+ *
232
+ * The host wins. A page that passed `onItemClick` has its own idea of what a claim is — a drawer,
233
+ * a route, a second panel — and must not also get a tab it never asked for. With no handler the
234
+ * claim's own `url` opens in a new tab with the opener cut off; the parser has already refused
235
+ * anything that is not `http` or `https`, because this argument reaches `window.open`.
236
+ */
237
+ function openTimelineItem(item, events) {
238
+ if (events?.onItemClick) {
239
+ events.onItemClick(item);
240
+ return;
241
+ }
242
+ if (item.url && typeof window !== "undefined") window.open(item.url, "_blank", "noopener,noreferrer");
243
+ }
244
+ function bindTimeline(chart, events) {
245
+ chart.on("click", (params) => {
246
+ const item = params.data?.item;
247
+ if (item) openTimelineItem(item, events);
248
+ });
249
+ }
250
+ function bindGraph(chart, events) {
251
+ chart.on("click", (params) => {
252
+ const event = params;
253
+ if (event.dataType === "edge") return;
254
+ const node = event.data?.node;
255
+ if (node) events?.onNodeClick?.(node);
256
+ });
257
+ }
258
+ /**
259
+ * Step the layout in front of the reader, and stop.
260
+ *
261
+ * A knowledge graph pulling itself apart is the panel telling you what it found: the clusters
262
+ * arrive one after another and you watch which entities go with which. So the layout is stepped a
263
+ * few steps per frame rather than run to convergence behind a promise, and only the two series'
264
+ * data is pushed each time — merged, not replaced, so the camera and the lights survive the
265
+ * update and the graph does not jump back to its opening pose sixty times a second.
266
+ *
267
+ * When it has settled the loop ends for good and the positions are remembered, which is what lets
268
+ * the next render of the same fence resume rather than reshuffle. The camera keeps turning after:
269
+ * that is echarts-gl's own animation, not this one.
270
+ */
271
+ function settleGraph(chart, panel, c, layout, key) {
272
+ const ids = panel.nodes.map((node) => node.id);
273
+ const remember = () => require_positions.rememberPositions(key, ids, layout.positions());
274
+ if (layout.done || typeof requestAnimationFrame !== "function") {
275
+ remember();
276
+ return () => {};
277
+ }
278
+ const chunk = require_positions.layoutChunk(panel.nodes.length);
279
+ const tick = () => {
280
+ layout.step(chunk);
281
+ const data = require_positions.graphOrbitData(panel, c, layout.positions());
282
+ chart.setOption({ series: [{ data: data.edges }, { data: data.nodes }] }, {
283
+ notMerge: false,
284
+ lazyUpdate: false
285
+ });
286
+ if (layout.done) {
287
+ remember();
288
+ return;
289
+ }
290
+ handle = requestAnimationFrame(tick);
291
+ };
292
+ let handle = requestAnimationFrame(tick);
293
+ return () => cancelAnimationFrame(handle);
294
+ }
295
+ /**
296
+ * The key to a graph's colours, in HTML over the canvas.
297
+ *
298
+ * HTML rather than an ECharts legend: the graph is a WebGL series with no categories for a legend
299
+ * component to read, and a corner of text a reader can select beats a row of chart furniture. It
300
+ * is `pointer-events:none` so it never eats a click meant for the graph behind it, and an untyped
301
+ * graph gets nothing at all rather than an empty box.
302
+ *
303
+ * It hangs off the panel rather than off the chart's own element, because ECharts empties the
304
+ * element it is initialised on — a legend appended there is gone the moment the graph appears,
305
+ * which is exactly what happened the first time this was drawn.
306
+ */
307
+ function graphLegendNode(panel, c) {
308
+ const entries = require_positions.graphLegend(panel, c);
309
+ if (!entries.length) return void 0;
310
+ const box = el("div", { class: "aigui-bs-graph-legend" });
311
+ for (const entry of entries) {
312
+ const row = el("div", { class: "aigui-bs-graph-legend-row" });
313
+ const swatch = el("i", { class: `aigui-bs-graph-legend-${entry.shape === "node" ? "dot" : "line"}` });
314
+ swatch.style.background = entry.colour;
315
+ row.append(swatch, document.createTextNode(entry.label));
316
+ box.appendChild(row);
317
+ }
318
+ return box;
319
+ }
320
+ /** The body height a panel gets when it did not ask for one. */
321
+ function bodyHeight(panel) {
322
+ if (panel.kind === "timeline") return require_positions.timelineHeight(panel.lanes.length);
323
+ return DEFAULT_HEIGHT[panel.kind];
324
+ }
325
+ function mountPanel(panel, definition, c, animate, earth, events) {
218
326
  const node = el("section", {
219
327
  class: "aigui-bs-panel",
220
328
  "data-aigui-bigscreen-panel": panel.kind
@@ -222,7 +330,7 @@ function mountPanel(panel, definition, c, animate) {
222
330
  const span = Math.min(panel.span ?? 4, definition.columns);
223
331
  node.style.gridColumn = `span ${span}`;
224
332
  if (span * 2 > definition.columns) node.setAttribute("data-aigui-bigscreen-wide", "");
225
- node.style.borderColor = require_options.withAlpha(c.accent, definition.theme === "dark" ? .22 : .18);
333
+ node.style.borderColor = require_positions.withAlpha(c.accent, definition.theme === "dark" ? .22 : .18);
226
334
  if (panel.title) {
227
335
  const head = el("header", { class: "aigui-bs-panel-title" }, panel.title);
228
336
  const mark = el("i", { class: "aigui-bs-panel-mark" });
@@ -232,7 +340,7 @@ function mountPanel(panel, definition, c, animate) {
232
340
  node.appendChild(head);
233
341
  }
234
342
  const body = el("div", { class: "aigui-bs-panel-body" });
235
- const height = panel.height ?? DEFAULT_HEIGHT[panel.kind];
343
+ const height = panel.height ?? bodyHeight(panel);
236
344
  if (height) body.style.height = `${height}px`;
237
345
  node.appendChild(body);
238
346
  let destroy = () => {};
@@ -245,17 +353,40 @@ function mountPanel(panel, definition, c, animate) {
245
353
  destroy = mountRank(body, panel, c, animate);
246
354
  break;
247
355
  case "gauge":
248
- destroy = mountChart(body, require_options.gaugeOption(panel, c, animate), definition.theme);
356
+ destroy = mountChart(body, require_positions.gaugeOption(panel, c, animate), definition.theme);
249
357
  break;
250
358
  case "chart":
251
- destroy = mountChart(body, require_options.chartOption(panel, c, animate), definition.theme);
359
+ destroy = mountChart(body, require_positions.chartOption(panel, c, animate), definition.theme);
252
360
  break;
253
361
  case "chart3d":
254
- destroy = mountGl(body, () => require_options.chart3dOption(panel, c, animate), definition.theme, () => note(body, "3D panels need echarts-gl and WebGL."));
362
+ destroy = mountGl(body, () => require_positions.chart3dOption(panel, c, animate), definition.theme, () => note(body, "3D panels need echarts-gl and WebGL."));
255
363
  break;
256
364
  case "globe":
257
- destroy = mountGl(body, () => require_options.globeOption(panel, c, animate, require_options.globeTexture(c, definition.theme)), definition.theme, () => note(body, "Globe panels need echarts-gl and WebGL."));
365
+ destroy = mountGl(body, () => require_positions.globeOption(panel, c, animate, require_positions.earthTexture(c, definition.theme, earth), earth), definition.theme, () => note(body, "Globe panels need echarts-gl and WebGL."));
366
+ break;
367
+ case "timeline":
368
+ destroy = mountChart(body, require_positions.timelineOption(panel, c, animate), definition.theme, (chart) => bindTimeline(chart, events));
258
369
  break;
370
+ case "graph3d": {
371
+ const legend = graphLegendNode(panel, c);
372
+ if (legend) node.appendChild(legend);
373
+ const missing = () => {
374
+ legend?.remove();
375
+ note(body, "Knowledge graph panels need echarts-gl and WebGL.");
376
+ };
377
+ if (panel.mode === "flat") {
378
+ destroy = mountGl(body, () => require_positions.graph3dOption(panel, c, animate), definition.theme, missing, (chart) => bindGraph(chart, events));
379
+ break;
380
+ }
381
+ const key = require_positions.graphKey(panel);
382
+ const layout = require_positions.createLayout(panel.nodes, panel.edges, require_positions.recallPositions(key));
383
+ if (!animate) layout.step(layout.steps);
384
+ destroy = mountGl(body, () => require_positions.graphOrbitOption(panel, c, animate, layout.positions()), definition.theme, missing, (chart) => {
385
+ bindGraph(chart, events);
386
+ return settleGraph(chart, panel, c, layout, key);
387
+ });
388
+ break;
389
+ }
259
390
  }
260
391
  } catch {
261
392
  note(body, "Panel could not be drawn.");
@@ -265,9 +396,15 @@ function mountPanel(panel, definition, c, animate) {
265
396
  destroy
266
397
  };
267
398
  }
268
- /** Build the whole screen into `host`, returning the teardown the reconciler will call. */
269
- function mountScreen(host, definition, animate) {
270
- const c = require_options.palette(definition);
399
+ /**
400
+ * Build the whole screen into `host`, returning the teardown the reconciler will call.
401
+ *
402
+ * `earth` is the host's globe configuration, passed through untouched: what the planet looks like
403
+ * is the page's decision, not the fence's. `events` is the same bargain for clicks: what a claim
404
+ * or an entity does when it is clicked is the page's decision too.
405
+ */
406
+ function mountScreen(host, definition, animate, earth, events) {
407
+ const c = require_positions.palette(definition);
271
408
  host.setAttribute("data-aigui-bigscreen", definition.theme);
272
409
  host.style.setProperty("--aigui-bs-accent", c.accent);
273
410
  host.style.setProperty("--aigui-bs-text", c.text);
@@ -276,7 +413,7 @@ function mountScreen(host, definition, animate) {
276
413
  const head = el("header", { class: "aigui-bs-head" });
277
414
  if (definition.title) {
278
415
  const title = el("h2", { class: "aigui-bs-title" }, definition.title);
279
- title.style.textShadow = definition.theme === "dark" ? `0 0 24px ${require_options.withAlpha(c.accent, .35)}` : "none";
416
+ title.style.textShadow = definition.theme === "dark" ? `0 0 24px ${require_positions.withAlpha(c.accent, .35)}` : "none";
280
417
  head.appendChild(title);
281
418
  }
282
419
  if (definition.subtitle) head.appendChild(el("p", { class: "aigui-bs-subtitle" }, definition.subtitle));
@@ -288,7 +425,7 @@ function mountScreen(host, definition, animate) {
288
425
  const grid = el("div", { class: "aigui-bs-grid" });
289
426
  grid.style.gridTemplateColumns = `repeat(${definition.columns}, minmax(0, 1fr))`;
290
427
  host.appendChild(grid);
291
- const mounted = definition.panels.map((panel) => mountPanel(panel, definition, c, animate));
428
+ const mounted = definition.panels.map((panel) => mountPanel(panel, definition, c, animate, earth, events));
292
429
  for (const { node } of mounted) grid.appendChild(node);
293
430
  return () => {
294
431
  for (const { destroy } of mounted) destroy();
@@ -1,4 +1,4 @@
1
- import { chart3dOption, chartOption, formatNumber, gaugeOption, globeOption, globeTexture, palette, withAlpha } from "./options-C087Peeh.js";
1
+ import { chart3dOption, chartOption, createLayout, earthTexture, formatNumber, gaugeOption, globeOption, graph3dOption, graphKey, graphLegend, graphOrbitData, graphOrbitOption, layoutChunk, palette, recallPositions, rememberPositions, timelineHeight, timelineOption, withAlpha } from "./positions-CN8aP4-S.js";
2
2
  import { BarChart, EffectScatterChart, FunnelChart, GaugeChart, HeatmapChart, LineChart, LinesChart, PieChart, RadarChart, ScatterChart } from "echarts/charts";
3
3
  import { DatasetComponent, GridComponent, LegendComponent, PolarComponent, RadarComponent, TitleComponent, TooltipComponent, VisualMapComponent } from "echarts/components";
4
4
  import { init, use } from "echarts/core";
@@ -35,7 +35,9 @@ const DEFAULT_HEIGHT = {
35
35
  rank: 220,
36
36
  chart: 240,
37
37
  chart3d: 280,
38
- globe: 300
38
+ globe: 300,
39
+ timeline: 320,
40
+ graph3d: 320
39
41
  };
40
42
  const easeOut = (t) => 1 - Math.pow(1 - t, 3);
41
43
  function el(tag, attrs = {}, text) {
@@ -170,12 +172,13 @@ function whenSized(body, fn) {
170
172
  const handle = requestAnimationFrame(fn);
171
173
  return () => cancelAnimationFrame(handle);
172
174
  }
173
- function mountChart(body, option, theme) {
175
+ function mountChart(body, option, theme, ready) {
174
176
  let chart;
175
177
  let unfollow;
176
178
  const cancel = whenSized(body, () => {
177
179
  chart = init(body, theme === "dark" ? "dark" : void 0, { renderer: "canvas" });
178
180
  chart.setOption(option);
181
+ ready?.(chart);
179
182
  unfollow = follow(body, chart);
180
183
  });
181
184
  return () => {
@@ -184,10 +187,17 @@ function mountChart(body, option, theme) {
184
187
  chart?.dispose();
185
188
  };
186
189
  }
187
- function mountGl(body, build, theme, fallback) {
190
+ /**
191
+ * A chart that needs WebGL, with an optional loop running against it.
192
+ *
193
+ * `ready` may hand back a teardown of its own — the settling loop does — and it is run before the
194
+ * chart is disposed, because a frame that lands on a disposed chart throws.
195
+ */
196
+ function mountGl(body, build, theme, fallback, ready) {
188
197
  let chart;
189
198
  let unfollow;
190
199
  let cancel;
200
+ let stop;
191
201
  let disposed = false;
192
202
  loadGl().then(() => {
193
203
  if (disposed) return;
@@ -195,6 +205,7 @@ function mountGl(body, build, theme, fallback) {
195
205
  if (disposed) return;
196
206
  chart = init(body, theme === "dark" ? "dark" : void 0, { renderer: "canvas" });
197
207
  chart.setOption(build());
208
+ stop = ready?.(chart) ?? void 0;
198
209
  unfollow = follow(body, chart);
199
210
  });
200
211
  }).catch(() => {
@@ -206,6 +217,7 @@ function mountGl(body, build, theme, fallback) {
206
217
  return () => {
207
218
  disposed = true;
208
219
  cancel?.();
220
+ stop?.();
209
221
  unfollow?.();
210
222
  chart?.dispose();
211
223
  };
@@ -213,7 +225,103 @@ function mountGl(body, build, theme, fallback) {
213
225
  function note(body, text) {
214
226
  body.replaceChildren(el("div", { class: "aigui-bs-note" }, text));
215
227
  }
216
- function mountPanel(panel, definition, c, animate) {
228
+ /**
229
+ * What a click on a claim does.
230
+ *
231
+ * The host wins. A page that passed `onItemClick` has its own idea of what a claim is — a drawer,
232
+ * a route, a second panel — and must not also get a tab it never asked for. With no handler the
233
+ * claim's own `url` opens in a new tab with the opener cut off; the parser has already refused
234
+ * anything that is not `http` or `https`, because this argument reaches `window.open`.
235
+ */
236
+ function openTimelineItem(item, events) {
237
+ if (events?.onItemClick) {
238
+ events.onItemClick(item);
239
+ return;
240
+ }
241
+ if (item.url && typeof window !== "undefined") window.open(item.url, "_blank", "noopener,noreferrer");
242
+ }
243
+ function bindTimeline(chart, events) {
244
+ chart.on("click", (params) => {
245
+ const item = params.data?.item;
246
+ if (item) openTimelineItem(item, events);
247
+ });
248
+ }
249
+ function bindGraph(chart, events) {
250
+ chart.on("click", (params) => {
251
+ const event = params;
252
+ if (event.dataType === "edge") return;
253
+ const node = event.data?.node;
254
+ if (node) events?.onNodeClick?.(node);
255
+ });
256
+ }
257
+ /**
258
+ * Step the layout in front of the reader, and stop.
259
+ *
260
+ * A knowledge graph pulling itself apart is the panel telling you what it found: the clusters
261
+ * arrive one after another and you watch which entities go with which. So the layout is stepped a
262
+ * few steps per frame rather than run to convergence behind a promise, and only the two series'
263
+ * data is pushed each time — merged, not replaced, so the camera and the lights survive the
264
+ * update and the graph does not jump back to its opening pose sixty times a second.
265
+ *
266
+ * When it has settled the loop ends for good and the positions are remembered, which is what lets
267
+ * the next render of the same fence resume rather than reshuffle. The camera keeps turning after:
268
+ * that is echarts-gl's own animation, not this one.
269
+ */
270
+ function settleGraph(chart, panel, c, layout, key) {
271
+ const ids = panel.nodes.map((node) => node.id);
272
+ const remember = () => rememberPositions(key, ids, layout.positions());
273
+ if (layout.done || typeof requestAnimationFrame !== "function") {
274
+ remember();
275
+ return () => {};
276
+ }
277
+ const chunk = layoutChunk(panel.nodes.length);
278
+ const tick = () => {
279
+ layout.step(chunk);
280
+ const data = graphOrbitData(panel, c, layout.positions());
281
+ chart.setOption({ series: [{ data: data.edges }, { data: data.nodes }] }, {
282
+ notMerge: false,
283
+ lazyUpdate: false
284
+ });
285
+ if (layout.done) {
286
+ remember();
287
+ return;
288
+ }
289
+ handle = requestAnimationFrame(tick);
290
+ };
291
+ let handle = requestAnimationFrame(tick);
292
+ return () => cancelAnimationFrame(handle);
293
+ }
294
+ /**
295
+ * The key to a graph's colours, in HTML over the canvas.
296
+ *
297
+ * HTML rather than an ECharts legend: the graph is a WebGL series with no categories for a legend
298
+ * component to read, and a corner of text a reader can select beats a row of chart furniture. It
299
+ * is `pointer-events:none` so it never eats a click meant for the graph behind it, and an untyped
300
+ * graph gets nothing at all rather than an empty box.
301
+ *
302
+ * It hangs off the panel rather than off the chart's own element, because ECharts empties the
303
+ * element it is initialised on — a legend appended there is gone the moment the graph appears,
304
+ * which is exactly what happened the first time this was drawn.
305
+ */
306
+ function graphLegendNode(panel, c) {
307
+ const entries = graphLegend(panel, c);
308
+ if (!entries.length) return void 0;
309
+ const box = el("div", { class: "aigui-bs-graph-legend" });
310
+ for (const entry of entries) {
311
+ const row = el("div", { class: "aigui-bs-graph-legend-row" });
312
+ const swatch = el("i", { class: `aigui-bs-graph-legend-${entry.shape === "node" ? "dot" : "line"}` });
313
+ swatch.style.background = entry.colour;
314
+ row.append(swatch, document.createTextNode(entry.label));
315
+ box.appendChild(row);
316
+ }
317
+ return box;
318
+ }
319
+ /** The body height a panel gets when it did not ask for one. */
320
+ function bodyHeight(panel) {
321
+ if (panel.kind === "timeline") return timelineHeight(panel.lanes.length);
322
+ return DEFAULT_HEIGHT[panel.kind];
323
+ }
324
+ function mountPanel(panel, definition, c, animate, earth, events) {
217
325
  const node = el("section", {
218
326
  class: "aigui-bs-panel",
219
327
  "data-aigui-bigscreen-panel": panel.kind
@@ -231,7 +339,7 @@ function mountPanel(panel, definition, c, animate) {
231
339
  node.appendChild(head);
232
340
  }
233
341
  const body = el("div", { class: "aigui-bs-panel-body" });
234
- const height = panel.height ?? DEFAULT_HEIGHT[panel.kind];
342
+ const height = panel.height ?? bodyHeight(panel);
235
343
  if (height) body.style.height = `${height}px`;
236
344
  node.appendChild(body);
237
345
  let destroy = () => {};
@@ -253,8 +361,31 @@ function mountPanel(panel, definition, c, animate) {
253
361
  destroy = mountGl(body, () => chart3dOption(panel, c, animate), definition.theme, () => note(body, "3D panels need echarts-gl and WebGL."));
254
362
  break;
255
363
  case "globe":
256
- destroy = mountGl(body, () => globeOption(panel, c, animate, globeTexture(c, definition.theme)), definition.theme, () => note(body, "Globe panels need echarts-gl and WebGL."));
364
+ destroy = mountGl(body, () => globeOption(panel, c, animate, earthTexture(c, definition.theme, earth), earth), definition.theme, () => note(body, "Globe panels need echarts-gl and WebGL."));
365
+ break;
366
+ case "timeline":
367
+ destroy = mountChart(body, timelineOption(panel, c, animate), definition.theme, (chart) => bindTimeline(chart, events));
257
368
  break;
369
+ case "graph3d": {
370
+ const legend = graphLegendNode(panel, c);
371
+ if (legend) node.appendChild(legend);
372
+ const missing = () => {
373
+ legend?.remove();
374
+ note(body, "Knowledge graph panels need echarts-gl and WebGL.");
375
+ };
376
+ if (panel.mode === "flat") {
377
+ destroy = mountGl(body, () => graph3dOption(panel, c, animate), definition.theme, missing, (chart) => bindGraph(chart, events));
378
+ break;
379
+ }
380
+ const key = graphKey(panel);
381
+ const layout = createLayout(panel.nodes, panel.edges, recallPositions(key));
382
+ if (!animate) layout.step(layout.steps);
383
+ destroy = mountGl(body, () => graphOrbitOption(panel, c, animate, layout.positions()), definition.theme, missing, (chart) => {
384
+ bindGraph(chart, events);
385
+ return settleGraph(chart, panel, c, layout, key);
386
+ });
387
+ break;
388
+ }
258
389
  }
259
390
  } catch {
260
391
  note(body, "Panel could not be drawn.");
@@ -264,8 +395,14 @@ function mountPanel(panel, definition, c, animate) {
264
395
  destroy
265
396
  };
266
397
  }
267
- /** Build the whole screen into `host`, returning the teardown the reconciler will call. */
268
- function mountScreen(host, definition, animate) {
398
+ /**
399
+ * Build the whole screen into `host`, returning the teardown the reconciler will call.
400
+ *
401
+ * `earth` is the host's globe configuration, passed through untouched: what the planet looks like
402
+ * is the page's decision, not the fence's. `events` is the same bargain for clicks: what a claim
403
+ * or an entity does when it is clicked is the page's decision too.
404
+ */
405
+ function mountScreen(host, definition, animate, earth, events) {
269
406
  const c = palette(definition);
270
407
  host.setAttribute("data-aigui-bigscreen", definition.theme);
271
408
  host.style.setProperty("--aigui-bs-accent", c.accent);
@@ -287,7 +424,7 @@ function mountScreen(host, definition, animate) {
287
424
  const grid = el("div", { class: "aigui-bs-grid" });
288
425
  grid.style.gridTemplateColumns = `repeat(${definition.columns}, minmax(0, 1fr))`;
289
426
  host.appendChild(grid);
290
- const mounted = definition.panels.map((panel) => mountPanel(panel, definition, c, animate));
427
+ const mounted = definition.panels.map((panel) => mountPanel(panel, definition, c, animate, earth, events));
291
428
  for (const { node } of mounted) grid.appendChild(node);
292
429
  return () => {
293
430
  for (const { destroy } of mounted) destroy();