flexdesk 0.1.0

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.
Files changed (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +195 -0
  3. package/THIRD_PARTY_LICENSES.md +53 -0
  4. package/css/base.css +3439 -0
  5. package/css/flexdesk.css +5797 -0
  6. package/css/overrides.css +2349 -0
  7. package/css/reset.css +46 -0
  8. package/css/tokens.css +137 -0
  9. package/dist/charts.js +780 -0
  10. package/dist/charts.js.map +7 -0
  11. package/dist/chunk-3PHCPZHT.js +222 -0
  12. package/dist/chunk-3PHCPZHT.js.map +7 -0
  13. package/dist/chunk-CT4YXXLP.js +1770 -0
  14. package/dist/chunk-CT4YXXLP.js.map +7 -0
  15. package/dist/chunk-DRYCDMEG.js +2217 -0
  16. package/dist/chunk-DRYCDMEG.js.map +7 -0
  17. package/dist/chunk-DVU44T77.js +855 -0
  18. package/dist/chunk-DVU44T77.js.map +7 -0
  19. package/dist/chunk-FL5KFNQH.js +640 -0
  20. package/dist/chunk-FL5KFNQH.js.map +7 -0
  21. package/dist/chunk-FOOS3T5L.js +117 -0
  22. package/dist/chunk-FOOS3T5L.js.map +7 -0
  23. package/dist/chunk-JYWURG5T.js +32 -0
  24. package/dist/chunk-JYWURG5T.js.map +7 -0
  25. package/dist/chunk-TLZUUFOE.js +276 -0
  26. package/dist/chunk-TLZUUFOE.js.map +7 -0
  27. package/dist/chunk-UCJ2WD4D.js +625 -0
  28. package/dist/chunk-UCJ2WD4D.js.map +7 -0
  29. package/dist/chunk-WVFGV5FT.js +205 -0
  30. package/dist/chunk-WVFGV5FT.js.map +7 -0
  31. package/dist/core.js +1098 -0
  32. package/dist/core.js.map +7 -0
  33. package/dist/editor.js +1661 -0
  34. package/dist/editor.js.map +7 -0
  35. package/dist/flexdesk.css +5797 -0
  36. package/dist/host.js +132 -0
  37. package/dist/host.js.map +7 -0
  38. package/dist/reset.css +46 -0
  39. package/dist/tile_registry-6WZPTUZV.js +28 -0
  40. package/dist/tile_registry-6WZPTUZV.js.map +7 -0
  41. package/dist/tiles.js +2068 -0
  42. package/dist/tiles.js.map +7 -0
  43. package/dist/tokens.css +137 -0
  44. package/dist/widgets.js +5438 -0
  45. package/dist/widgets.js.map +7 -0
  46. package/dist/wm.js +4830 -0
  47. package/dist/wm.js.map +7 -0
  48. package/package.json +80 -0
  49. package/src/charts/chart_types.js +699 -0
  50. package/src/charts/data_series_plot_window.js +761 -0
  51. package/src/charts/downsample.js +119 -0
  52. package/src/charts/plot_config.js +176 -0
  53. package/src/charts/plot_popout_window.js +1560 -0
  54. package/src/charts/plotly_wrapper.js +432 -0
  55. package/src/core/event_bus.js +230 -0
  56. package/src/core/logging.js +300 -0
  57. package/src/core/settings.js +813 -0
  58. package/src/core/state_guard.js +382 -0
  59. package/src/core/state_guard_config.js +309 -0
  60. package/src/core/state_machine.js +258 -0
  61. package/src/editor/editor_pane.js +264 -0
  62. package/src/editor/monaco_editor_factory.js +449 -0
  63. package/src/editor/monaco_loader.js +74 -0
  64. package/src/editor/notebook_search.js +460 -0
  65. package/src/editor/notebook_tab_bar.js +456 -0
  66. package/src/editor/notebook_undo_manager.js +108 -0
  67. package/src/editor/split_pane_container.js +197 -0
  68. package/src/help/help_modal.js +409 -0
  69. package/src/help/help_registry.js +65 -0
  70. package/src/host/host.js +69 -0
  71. package/src/host/pywebview_host.js +133 -0
  72. package/src/tiles/config_schema.js +256 -0
  73. package/src/tiles/layout_persistence.js +662 -0
  74. package/src/tiles/tile_base.js +785 -0
  75. package/src/tiles/tile_grid.js +905 -0
  76. package/src/tiles/tile_registry.js +136 -0
  77. package/src/tiling/command_palette.js +328 -0
  78. package/src/tiling/content_registry.js +110 -0
  79. package/src/tiling/desktops.js +133 -0
  80. package/src/tiling/entity_sources.js +179 -0
  81. package/src/tiling/keymap.js +156 -0
  82. package/src/tiling/kind_taxonomy.js +172 -0
  83. package/src/tiling/landing_table.js +717 -0
  84. package/src/tiling/loading_overlay.js +33 -0
  85. package/src/tiling/nav_panel.js +142 -0
  86. package/src/tiling/page_factory.js +215 -0
  87. package/src/tiling/panel_keys.js +145 -0
  88. package/src/tiling/shell.js +437 -0
  89. package/src/tiling/tile_breadcrumb.js +222 -0
  90. package/src/tiling/tile_renderer.js +459 -0
  91. package/src/tiling/tile_tab_menu.js +340 -0
  92. package/src/tiling/tile_tree.js +880 -0
  93. package/src/tiling/wm.js +1352 -0
  94. package/src/ui/base/component_base.js +30 -0
  95. package/src/ui/base/controller_base.js +48 -0
  96. package/src/ui/base/page_base.js +102 -0
  97. package/src/ui/components/about_dialog.js +224 -0
  98. package/src/ui/components/action_dropdown.js +389 -0
  99. package/src/ui/components/attribute_list_editor.js +652 -0
  100. package/src/ui/components/autocomplete_field.js +732 -0
  101. package/src/ui/components/computing_status_window.js +81 -0
  102. package/src/ui/components/confirm_dialog.js +307 -0
  103. package/src/ui/components/context_menu.js +87 -0
  104. package/src/ui/components/data_table.js +2228 -0
  105. package/src/ui/components/detail_header.js +177 -0
  106. package/src/ui/components/drag_reorder.js +0 -0
  107. package/src/ui/components/gallery_picker.js +147 -0
  108. package/src/ui/components/inline_renamer.js +129 -0
  109. package/src/ui/components/managed_window.js +804 -0
  110. package/src/ui/components/modal.js +512 -0
  111. package/src/ui/components/notification_history.js +264 -0
  112. package/src/ui/components/slide_out_panel.js +185 -0
  113. package/src/ui/components/slider_field.js +122 -0
  114. package/src/ui/components/sortable_list.js +309 -0
  115. package/src/ui/components/table_state_store.js +81 -0
  116. package/src/ui/components/toast.js +59 -0
  117. package/src/ui/components/tree_view.js +460 -0
  118. package/src/ui/components/window_taskbar.js +143 -0
  119. package/src/ui/controllers/panel_state_machine.js +634 -0
  120. package/src/ui/controllers/window_chrome_controller.js +659 -0
  121. package/src/ui/notification_center.js +576 -0
  122. package/src/ui/utils/overlay_scrollbar.js +492 -0
  123. package/src/ui/utils/raf_resize_observer.js +31 -0
  124. package/src/ui/utils/tooltip_service.js +406 -0
@@ -0,0 +1,432 @@
1
+ /**
2
+ * Plotly.js Wrapper
3
+ *
4
+ * Provides lazy loading of Plotly.js and helper functions for chart operations.
5
+ * Supports streaming updates via Plotly.extendTraces() and Plotly.react().
6
+ */
7
+
8
+ import { createRafResizeObserver } from '../ui/utils/raf_resize_observer.js';
9
+
10
+ // Where Plotly lives is the CONSUMER's business, not the library's.
11
+ //
12
+ // This was `const PLOTLY_LOCAL = 'vendor/plotly/plotly-2.35.2.min.js'` — a
13
+ // relative path into one particular application's directory tree. It happens to
14
+ // work for EcoAgent and would 404 for anyone else, and the failure would arrive
15
+ // as an empty chart with a console error, at runtime, only on the screens that
16
+ // plot something.
17
+ //
18
+ // Plotly stays a PEER dependency: 4.4 MB that @flexdesk/charts refuses to bundle.
19
+ //
20
+ // There is NO DEFAULT. Leaving one in place would only have meant leaving
21
+ // EcoAgent's exact versioned filename in the library — the same bug wearing a
22
+ // setter — and it would fail for everyone else as an empty chart and a console
23
+ // error, at runtime, on the screens that plot something.
24
+ //
25
+ // ensurePlotly() short-circuits when `window.Plotly` is already defined, which is
26
+ // how EcoAgent works: it preloads Plotly with a <script> tag in index.html and
27
+ // never reaches the loader below. A consumer that does not preload calls
28
+ // setPlotlySource() instead. A consumer that does neither now gets told so.
29
+ let _plotlySrc = null;
30
+
31
+ /** Point the loader at your copy of Plotly. Call before the first chart renders. */
32
+ export function setPlotlySource(src) {
33
+ if (typeof src === 'string' && src) _plotlySrc = src;
34
+ }
35
+
36
+ // Loading promise for singleton pattern
37
+ let _plotlyLoadPromise = null;
38
+
39
+ // Workarounds for Plotly.js 2.35.x running in Chromium-based pywebview.
40
+ let _plotlyPatchesInstalled = false;
41
+ function _installPlotlyPatches() {
42
+ if (_plotlyPatchesInstalled) return;
43
+ _plotlyPatchesInstalled = true;
44
+
45
+ // Plotly's internal mousemove handler assigns event.target which is
46
+ // read-only in modern browsers. Replace the native getter with a
47
+ // getter/setter pair so the assignment succeeds silently.
48
+ const targetDesc = Object.getOwnPropertyDescriptor(Event.prototype, 'target');
49
+ if (targetDesc && targetDesc.get && !targetDesc.set) {
50
+ const origGetter = targetDesc.get;
51
+ Object.defineProperty(Event.prototype, 'target', {
52
+ configurable: true,
53
+ enumerable: true,
54
+ get() {
55
+ return this._plotlyTarget ?? origGetter.call(this);
56
+ },
57
+ set(v) {
58
+ this._plotlyTarget = v;
59
+ }
60
+ });
61
+ }
62
+
63
+ // Plotly calls getImageData() repeatedly on 2D canvases without the
64
+ // willReadFrequently hint, triggering a Chromium performance warning.
65
+ // Patch getContext to inject the hint for all 2D contexts.
66
+ const _origGetContext = HTMLCanvasElement.prototype.getContext;
67
+ HTMLCanvasElement.prototype.getContext = function (type, attrs) {
68
+ if (type === '2d') {
69
+ attrs = Object.assign({ willReadFrequently: true }, attrs);
70
+ }
71
+ return _origGetContext.call(this, type, attrs);
72
+ };
73
+ }
74
+
75
+ // Run immediately on module import so the patches are active before any Plotly
76
+ // chart is created (including during workspace hydration) — but ONLY in a DOM.
77
+ //
78
+ // Unguarded, this monkey-patched HTMLCanvasElement.prototype at module scope, so
79
+ // `import '@flexdesk/charts'` threw `ReferenceError: HTMLCanvasElement is not defined`
80
+ // the instant anything imported it outside a browser. That is the third module in
81
+ // this library caught doing DOM work on import (see inline_renamer.js and
82
+ // tooltip_service.js), and all three were found by the same test: one that does
83
+ // nothing but import each entry point. A library whose entry point cannot be
84
+ // imported without a DOM cannot be unit-tested, server-rendered, or loaded in a
85
+ // worker.
86
+ if (typeof HTMLCanvasElement !== 'undefined') {
87
+ _installPlotlyPatches();
88
+ }
89
+
90
+ /**
91
+ * Dark theme layout defaults matching EcoSim UI
92
+ */
93
+ export const DARK_THEME_LAYOUT = {
94
+ paper_bgcolor: 'transparent',
95
+ plot_bgcolor: 'transparent',
96
+ font: {
97
+ family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif',
98
+ size: 11,
99
+ color: '#cccccc'
100
+ },
101
+ margin: { l: 50, r: 20, t: 30, b: 40 },
102
+ legend: {
103
+ bgcolor: 'rgba(0,0,0,0)',
104
+ font: { color: '#cccccc' }
105
+ },
106
+ xaxis: {
107
+ gridcolor: '#333333',
108
+ linecolor: '#444444',
109
+ tickcolor: '#666666',
110
+ zerolinecolor: '#444444'
111
+ },
112
+ yaxis: {
113
+ gridcolor: '#333333',
114
+ linecolor: '#444444',
115
+ tickcolor: '#666666',
116
+ zerolinecolor: '#444444'
117
+ },
118
+ // Hover tooltip styling for dark theme
119
+ hoverlabel: {
120
+ bgcolor: '#1e2228',
121
+ bordercolor: '#444444',
122
+ font: {
123
+ family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif',
124
+ size: 12,
125
+ color: '#e0e0e0'
126
+ }
127
+ },
128
+ // 3D scene defaults
129
+ scene: {
130
+ xaxis: {
131
+ gridcolor: '#333333',
132
+ linecolor: '#444444',
133
+ backgroundcolor: 'rgba(0,0,0,0)'
134
+ },
135
+ yaxis: {
136
+ gridcolor: '#333333',
137
+ linecolor: '#444444',
138
+ backgroundcolor: 'rgba(0,0,0,0)'
139
+ },
140
+ zaxis: {
141
+ gridcolor: '#333333',
142
+ linecolor: '#444444',
143
+ backgroundcolor: 'rgba(0,0,0,0)'
144
+ },
145
+ bgcolor: 'rgba(0,0,0,0)'
146
+ }
147
+ };
148
+
149
+ /**
150
+ * Default Plotly config options
151
+ */
152
+ export const DEFAULT_CONFIG = {
153
+ responsive: true,
154
+ displaylogo: false,
155
+ displayModeBar: false, // Hide modebar completely
156
+ scrollZoom: true, // Allow zoom with scroll wheel instead
157
+ toImageButtonOptions: {
158
+ format: 'png',
159
+ filename: 'ecosim_chart',
160
+ scale: 2
161
+ }
162
+ };
163
+
164
+ /**
165
+ * Load Plotly.js from CDN.
166
+ * Uses singleton pattern to avoid multiple loads.
167
+ * @returns {Promise<Plotly>} The Plotly library object
168
+ */
169
+ export async function ensurePlotly() {
170
+ // Already loaded
171
+ if (typeof Plotly !== 'undefined') {
172
+ return Plotly;
173
+ }
174
+
175
+ // Loading in progress
176
+ if (_plotlyLoadPromise) {
177
+ return _plotlyLoadPromise;
178
+ }
179
+
180
+ if (!_plotlySrc) {
181
+ throw new Error(
182
+ '[twm/charts] Plotly is not loaded and no source is configured. '
183
+ + 'Either load Plotly yourself (a <script> tag that sets window.Plotly), '
184
+ + 'or call setPlotlySource("/path/to/plotly.min.js") before rendering a chart.');
185
+ }
186
+
187
+ // Start loading — temporarily hide AMD define so Plotly sets window.Plotly
188
+ // instead of registering itself as an AMD module (conflicts with Monaco loader).
189
+ _plotlyLoadPromise = new Promise((resolve, reject) => {
190
+ const savedDefine = window.define;
191
+ window.define = undefined;
192
+
193
+ const script = document.createElement('script');
194
+ script.src = _plotlySrc;
195
+ script.async = true;
196
+
197
+ script.onload = () => {
198
+ window.define = savedDefine;
199
+ if (typeof Plotly !== 'undefined') {
200
+ console.log('[PlotlyWrapper] Plotly.js loaded successfully');
201
+ resolve(Plotly);
202
+ } else {
203
+ reject(new Error('Plotly.js loaded but Plotly global not found'));
204
+ }
205
+ };
206
+
207
+ script.onerror = () => {
208
+ window.define = savedDefine;
209
+ reject(new Error(`Failed to load Plotly.js from ${_plotlySrc} — `
210
+ + 'call setPlotlySource() with the path to your copy.'));
211
+ };
212
+
213
+ document.head.appendChild(script);
214
+ });
215
+
216
+ return _plotlyLoadPromise;
217
+ }
218
+
219
+ /**
220
+ * Create a new Plotly chart.
221
+ * @param {HTMLElement} container - Container element for the chart
222
+ * @param {Array} data - Array of trace objects
223
+ * @param {Object} layout - Layout configuration
224
+ * @param {Object} config - Plotly config options
225
+ * @returns {Promise<HTMLElement>} The chart element
226
+ */
227
+ export async function createChart(container, data, layout = {}, config = {}) {
228
+ const Plotly = await ensurePlotly();
229
+
230
+ const mergedLayout = {
231
+ ...DARK_THEME_LAYOUT,
232
+ ...layout
233
+ };
234
+
235
+ const mergedConfig = {
236
+ ...DEFAULT_CONFIG,
237
+ ...config
238
+ };
239
+
240
+ return Plotly.newPlot(container, data, mergedLayout, mergedConfig);
241
+ }
242
+
243
+ /**
244
+ * Update chart data and layout efficiently.
245
+ * Uses Plotly.react() which is optimized for updates.
246
+ * @param {HTMLElement} container - Chart container
247
+ * @param {Array} data - New trace data
248
+ * @param {Object} layout - Layout updates
249
+ */
250
+ export async function updateChart(container, data, layout = {}) {
251
+ const Plotly = await ensurePlotly();
252
+
253
+ const mergedLayout = {
254
+ ...DARK_THEME_LAYOUT,
255
+ ...layout
256
+ };
257
+
258
+ return Plotly.react(container, data, mergedLayout);
259
+ }
260
+
261
+ /**
262
+ * Extend traces with new data points (for streaming).
263
+ * More efficient than full update for appending data.
264
+ * @param {HTMLElement} container - Chart container
265
+ * @param {Object} update - Data to extend: { x: [[...]], y: [[...]] }
266
+ * @param {Array<number>} traceIndices - Which traces to extend
267
+ * @param {number} maxPoints - Maximum points to keep (optional)
268
+ */
269
+ export async function extendTraces(container, update, traceIndices, maxPoints = null) {
270
+ const Plotly = await ensurePlotly();
271
+
272
+ if (maxPoints) {
273
+ return Plotly.extendTraces(container, update, traceIndices, maxPoints);
274
+ }
275
+ return Plotly.extendTraces(container, update, traceIndices);
276
+ }
277
+
278
+ /**
279
+ * Relayout chart (update layout only, no data change).
280
+ * @param {HTMLElement} container - Chart container
281
+ * @param {Object} layoutUpdate - Layout properties to update
282
+ */
283
+ export async function relayout(container, layoutUpdate) {
284
+ const Plotly = await ensurePlotly();
285
+ return Plotly.relayout(container, layoutUpdate);
286
+ }
287
+
288
+ /**
289
+ * Restyle traces (update trace properties without full redraw).
290
+ * @param {HTMLElement} container - Chart container
291
+ * @param {Object} styleUpdate - Style properties to update
292
+ * @param {Array<number>} traceIndices - Which traces to update
293
+ */
294
+ export async function restyle(container, styleUpdate, traceIndices) {
295
+ const Plotly = await ensurePlotly();
296
+ return Plotly.restyle(container, styleUpdate, traceIndices);
297
+ }
298
+
299
+ /**
300
+ * Delete all traces and release resources.
301
+ * @param {HTMLElement} container - Chart container
302
+ */
303
+ export async function purge(container) {
304
+ const Plotly = await ensurePlotly();
305
+ return Plotly.purge(container);
306
+ }
307
+
308
+ /**
309
+ * Check if container has a Plotly chart.
310
+ * @param {HTMLElement} container - Container element
311
+ * @returns {boolean}
312
+ */
313
+ export function hasPlot(container) {
314
+ return container && container._fullLayout !== undefined;
315
+ }
316
+
317
+ /**
318
+ * Resize chart to fit container.
319
+ * Call this when container size changes.
320
+ * @param {HTMLElement} container - Chart container
321
+ */
322
+ export async function resize(container) {
323
+ const Plotly = await ensurePlotly();
324
+ return Plotly.Plots.resize(container);
325
+ }
326
+
327
+ /**
328
+ * Convert hex color to rgba.
329
+ * @param {string} hex - Hex color code
330
+ * @param {number} alpha - Alpha value (0-1)
331
+ * @returns {string} rgba color string
332
+ */
333
+ export function hexToRgba(hex, alpha = 1) {
334
+ const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
335
+ if (!result) return hex;
336
+ const r = parseInt(result[1], 16);
337
+ const g = parseInt(result[2], 16);
338
+ const b = parseInt(result[3], 16);
339
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
340
+ }
341
+
342
+ // Solarized theme colors matching legacy plot_node.js
343
+ export const COLOR_PALETTE = [
344
+ '#268bd2', // Solarized Blue
345
+ '#d33682', // Solarized Magenta
346
+ '#cb4b16', // Solarized Orange
347
+ '#2aa198', // Solarized Cyan
348
+ '#6a5acd', // SlateBlue/Purple
349
+ '#7a7d80', // Gray
350
+ '#9fb6cf', // Gray-Blue
351
+ ];
352
+
353
+ /**
354
+ * Get color from palette by index.
355
+ * @param {number} index - Series index
356
+ * @returns {string} Color hex code
357
+ */
358
+ export function getSeriesColor(index) {
359
+ return COLOR_PALETTE[index % COLOR_PALETTE.length];
360
+ }
361
+
362
+ /**
363
+ * Create a Plotly chart with deferred rendering support.
364
+ * Handles zero-dimension containers by waiting for valid dimensions via ResizeObserver.
365
+ * @param {HTMLElement} container - Container element for the chart
366
+ * @param {Array} traces - Array of trace objects
367
+ * @param {Object} layout - Layout configuration
368
+ * @param {Object} config - Plotly config options
369
+ * @returns {Promise<{element: HTMLElement, cleanup: Function}>} Chart element and cleanup function
370
+ */
371
+ export async function createChartDeferred(container, traces, layout = {}, config = {}) {
372
+ const Plotly = await ensurePlotly();
373
+
374
+ const mergedLayout = {
375
+ ...DARK_THEME_LAYOUT,
376
+ ...layout
377
+ };
378
+
379
+ const mergedConfig = {
380
+ ...DEFAULT_CONFIG,
381
+ ...config
382
+ };
383
+
384
+ const w = container.offsetWidth;
385
+ const h = container.offsetHeight;
386
+
387
+ // Cleanup function to return
388
+ let resizeObserver = null;
389
+ const cleanup = () => {
390
+ if (resizeObserver) {
391
+ resizeObserver.disconnect();
392
+ resizeObserver = null;
393
+ }
394
+ };
395
+
396
+ // If dimensions are valid, render immediately
397
+ if (w > 0 && h > 0) {
398
+ await Plotly.newPlot(container, traces, mergedLayout, mergedConfig);
399
+ // Still set up resize observer for ongoing resizes
400
+ resizeObserver = createRafResizeObserver(() => {
401
+ if (hasPlot(container)) {
402
+ Plotly.Plots.resize(container).catch(() => {});
403
+ }
404
+ });
405
+ resizeObserver.observe(container);
406
+ return { element: container, cleanup };
407
+ }
408
+
409
+ // Dimensions are zero - wait for valid dimensions via ResizeObserver
410
+ return new Promise((resolve) => {
411
+ let rendered = false;
412
+ resizeObserver = createRafResizeObserver((entries) => {
413
+ for (const entry of entries) {
414
+ const { width, height } = entry.contentRect;
415
+ if (width > 0 && height > 0) {
416
+ if (!rendered) {
417
+ rendered = true;
418
+ Plotly.newPlot(container, traces, mergedLayout, mergedConfig).then(() => {
419
+ resolve({ element: container, cleanup });
420
+ }).catch(() => {
421
+ resolve({ element: container, cleanup });
422
+ });
423
+ } else if (hasPlot(container)) {
424
+ // Handle subsequent resizes
425
+ Plotly.Plots.resize(container).catch(() => {});
426
+ }
427
+ }
428
+ }
429
+ });
430
+ resizeObserver.observe(container);
431
+ });
432
+ }
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Event Bus (js_new)
3
+ *
4
+ * Purpose
5
+ * -------
6
+ * Provide a deterministic, dependency-injected publish/subscribe hub for all front-end modules.
7
+ *
8
+ * Responsibilities
9
+ * - Maintain per-event listener registries with namespace awareness.
10
+ * - Support synchronous + async dispatch without DOM bridges or global singletons.
11
+ * - Surface diagnostics via the shared logging service instead of ad hoc console calls.
12
+ *
13
+ * Source Material
14
+ * - html/js/event_manager.js (core pub/sub semantics, minus DOM bridge + globals).
15
+ * - html/js/debug_utils.js (event history/diagnostics requirements).
16
+ *
17
+ */
18
+
19
+ const PATTERN_TOKEN_RE = /[.*+?^${}()|[\]\\]/g;
20
+ const HAS_WILDCARD_RE = /[\*\?]/;
21
+
22
+ export class EventBus {
23
+ constructor({ logger, historyLimit = 200 } = {}) {
24
+ this.logger = logger;
25
+ this.historyLimit = historyLimit;
26
+ this.sequence = 0;
27
+ this.listeners = new Map(); // event -> Set<entry>
28
+ this.wildcardListeners = new Map(); // id -> entry (regex based)
29
+ this.listenerIndex = new Map(); // id -> entry
30
+ this.eventHistory = [];
31
+ }
32
+
33
+ on(eventName, handler, options = {}) {
34
+ if (typeof eventName !== 'string' || !eventName.trim()) {
35
+ throw new TypeError('eventName must be a non-empty string');
36
+ }
37
+ if (typeof handler !== 'function') {
38
+ throw new TypeError('handler must be a function');
39
+ }
40
+
41
+ const entry = {
42
+ id: ++this.sequence,
43
+ eventName,
44
+ handler,
45
+ namespace: options.namespace ?? null,
46
+ once: Boolean(options.once),
47
+ priority: options.priority ?? 0,
48
+ regex: null,
49
+ abortCleanup: null,
50
+ };
51
+
52
+ if (HAS_WILDCARD_RE.test(eventName)) {
53
+ entry.regex = this._compilePattern(eventName);
54
+ this.wildcardListeners.set(entry.id, entry);
55
+ } else {
56
+ if (!this.listeners.has(eventName)) {
57
+ this.listeners.set(eventName, new Set());
58
+ }
59
+ this.listeners.get(eventName).add(entry);
60
+ }
61
+
62
+ const useAbortSignal = typeof AbortSignal !== 'undefined' && options.signal instanceof AbortSignal;
63
+ if (useAbortSignal) {
64
+ if (options.signal.aborted) {
65
+ // Immediately unsubscribe if already aborted.
66
+ return { dispose: () => {}, id: entry.id };
67
+ }
68
+ const abortFn = () => this.off(entry.id);
69
+ options.signal.addEventListener('abort', abortFn, { once: true });
70
+ entry.abortCleanup = () => options.signal.removeEventListener('abort', abortFn);
71
+ }
72
+
73
+ this.listenerIndex.set(entry.id, entry);
74
+ return {
75
+ id: entry.id,
76
+ dispose: () => this.off(entry.id),
77
+ };
78
+ }
79
+
80
+ once(eventName, handler, options = {}) {
81
+ return this.on(eventName, handler, { ...options, once: true });
82
+ }
83
+
84
+ off(target) {
85
+ const id = typeof target === 'number' ? target : target?.id;
86
+ if (!this.listenerIndex.has(id)) {
87
+ return false;
88
+ }
89
+ const entry = this.listenerIndex.get(id);
90
+ this.listenerIndex.delete(id);
91
+
92
+ if (entry.abortCleanup) {
93
+ try {
94
+ entry.abortCleanup();
95
+ } catch (err) {
96
+ this.logger?.warn?.('event-bus', 'Abort cleanup failed', err);
97
+ }
98
+ }
99
+
100
+ if (entry.regex) {
101
+ this.wildcardListeners.delete(entry.id);
102
+ } else {
103
+ const bucket = this.listeners.get(entry.eventName);
104
+ if (bucket) {
105
+ bucket.delete(entry);
106
+ if (bucket.size === 0) {
107
+ this.listeners.delete(entry.eventName);
108
+ }
109
+ }
110
+ }
111
+ return true;
112
+ }
113
+
114
+ emit(eventName, payload = undefined, options = {}) {
115
+ const context = this._createContext(eventName, options);
116
+ const listeners = this._collectListeners(eventName, context.namespace);
117
+ this._recordHistory(eventName, context.namespace, listeners.length);
118
+ return this._invokeListeners(listeners, payload, context);
119
+ }
120
+
121
+ emitAsync(eventName, payload = undefined, options = {}) {
122
+ const context = this._createContext(eventName, options);
123
+ const listeners = this._collectListeners(eventName, context.namespace);
124
+ this._recordHistory(eventName, context.namespace, listeners.length);
125
+ return Promise.resolve().then(() => this._invokeListeners(listeners, payload, context));
126
+ }
127
+
128
+ listenerCount(eventName) {
129
+ const direct = this.listeners.get(eventName)?.size ?? 0;
130
+ const wildcard = [...this.wildcardListeners.values()].filter((entry) => entry.regex.test(eventName)).length;
131
+ return direct + wildcard;
132
+ }
133
+
134
+ getHistory() {
135
+ return [...this.eventHistory];
136
+ }
137
+
138
+ clearHistory() {
139
+ this.eventHistory.length = 0;
140
+ }
141
+
142
+ reset() {
143
+ this.listeners.clear();
144
+ this.wildcardListeners.clear();
145
+ this.listenerIndex.clear();
146
+ this.eventHistory.length = 0;
147
+ }
148
+
149
+ _collectListeners(eventName, namespace) {
150
+ const matches = [];
151
+ const bucket = this.listeners.get(eventName);
152
+ if (bucket) {
153
+ matches.push(...bucket);
154
+ }
155
+
156
+ for (const entry of this.wildcardListeners.values()) {
157
+ if (entry.regex.test(eventName)) {
158
+ matches.push(entry);
159
+ }
160
+ }
161
+
162
+ return matches
163
+ .filter((entry) => !entry.namespace || entry.namespace === namespace)
164
+ .sort((a, b) => {
165
+ if (a.priority === b.priority) {
166
+ return a.id - b.id;
167
+ }
168
+ return b.priority - a.priority;
169
+ });
170
+ }
171
+
172
+ _invokeListeners(listeners, payload, context) {
173
+ const results = [];
174
+ context.stopPropagation = () => {
175
+ context.__stopped = true;
176
+ };
177
+
178
+ for (const entry of listeners) {
179
+ if (context.__stopped) {
180
+ break;
181
+ }
182
+ try {
183
+ const result = entry.handler(payload, context);
184
+ results.push(result);
185
+ if (entry.once) {
186
+ this.off(entry.id);
187
+ }
188
+ } catch (err) {
189
+ this._handleListenerError(entry, context, err);
190
+ }
191
+ }
192
+ return results;
193
+ }
194
+
195
+ _createContext(eventName, options) {
196
+ return {
197
+ event: eventName,
198
+ namespace: options.namespace ?? null,
199
+ timestamp: Date.now(),
200
+ stopPropagation: () => {},
201
+ };
202
+ }
203
+
204
+ _recordHistory(eventName, namespace, listenerCount) {
205
+ this.eventHistory.push({
206
+ event: eventName,
207
+ namespace,
208
+ timestamp: Date.now(),
209
+ listeners: listenerCount,
210
+ });
211
+ if (this.eventHistory.length > this.historyLimit) {
212
+ this.eventHistory.shift();
213
+ }
214
+ }
215
+
216
+ _handleListenerError(entry, context, error) {
217
+ this.logger?.error?.('event-bus', 'Listener error', {
218
+ event: context.event,
219
+ namespace: context.namespace,
220
+ listenerId: entry.id,
221
+ error,
222
+ });
223
+ }
224
+
225
+ _compilePattern(pattern) {
226
+ const escaped = pattern.replace(PATTERN_TOKEN_RE, '\\$&');
227
+ const regexSource = `^${escaped.replace(/\\\*/g, '.*').replace(/\\\?/g, '.')}$`;
228
+ return new RegExp(regexSource);
229
+ }
230
+ }