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,699 @@
1
+ /**
2
+ * Chart Types and Trace Configurations
3
+ *
4
+ * Defines trace configurations for Plotly.js and provides mapping
5
+ * from Chart.js options to Plotly equivalents.
6
+ */
7
+
8
+ import { getSeriesColor, hexToRgba } from './plotly_wrapper.js';
9
+
10
+ /**
11
+ * Available 2D chart types
12
+ */
13
+ export const CHART_TYPES_2D = Object.freeze([
14
+ { value: 'line', label: 'Line' },
15
+ { value: 'bar', label: 'Bar' },
16
+ { value: 'scatter', label: 'Scatter' },
17
+ { value: 'area', label: 'Area' },
18
+ { value: 'area-stacked', label: 'Area (Stacked %)' },
19
+ { value: 'phase', label: 'Phase' },
20
+ ]);
21
+
22
+ /**
23
+ * Available 3D chart types
24
+ */
25
+ export const CHART_TYPES_3D = Object.freeze([
26
+ { value: 'scatter3d', label: '3D Scatter' },
27
+ { value: 'line3d', label: '3D Line' },
28
+ { value: 'surface', label: '3D Surface' },
29
+ ]);
30
+
31
+ /**
32
+ * All chart types combined
33
+ */
34
+ export const ALL_CHART_TYPES = Object.freeze([...CHART_TYPES_2D, ...CHART_TYPES_3D]);
35
+
36
+ /**
37
+ * Check if chart type is 3D
38
+ * @param {string} chartType
39
+ * @returns {boolean}
40
+ */
41
+ export function is3DChart(chartType) {
42
+ return ['scatter3d', 'line3d', 'surface'].includes(chartType);
43
+ }
44
+
45
+ /**
46
+ * Line style mapping (Chart.js borderDash to Plotly dash)
47
+ */
48
+ const LINE_DASH_MAP = {
49
+ solid: 'solid',
50
+ dashed: 'dash',
51
+ dotted: 'dot',
52
+ };
53
+
54
+ /**
55
+ * Interpolation mapping (Chart.js to Plotly line shape)
56
+ */
57
+ const INTERPOLATION_MAP = {
58
+ linear: 'linear',
59
+ step: 'hv', // horizontal-vertical step
60
+ cubic: 'spline', // smooth spline
61
+ auto: 'linear',
62
+ };
63
+
64
+ /**
65
+ * Build a Plotly trace from series configuration.
66
+ * @param {Object} options
67
+ * @param {string} options.chartType - Chart type (line, bar, scatter, area, scatter3d, etc.)
68
+ * @param {Array} options.x - X-axis data
69
+ * @param {Array} options.y - Y-axis data
70
+ * @param {Array} [options.z] - Z-axis data (for 3D charts)
71
+ * @param {string} options.name - Series name/label
72
+ * @param {string} [options.color] - Series color (hex)
73
+ * @param {number} [options.lineWidth] - Line width in pixels
74
+ * @param {string} [options.lineStyle] - Line style: solid, dashed, dotted
75
+ * @param {string} [options.interpolation] - Interpolation: linear, step, cubic
76
+ * @param {string} [options.yAxisId] - Y-axis identifier (y, y2, y3, etc.)
77
+ * @param {boolean} [options.showMarkers] - Show data point markers
78
+ * @param {number} [options.seriesIndex] - Index for default color selection
79
+ * @returns {Object} Plotly trace object
80
+ */
81
+ export function buildTrace(options) {
82
+ const {
83
+ chartType = 'line',
84
+ x = [],
85
+ y = [],
86
+ z = null,
87
+ name = 'Series',
88
+ color = null,
89
+ lineWidth = 2,
90
+ lineStyle = 'solid',
91
+ interpolation = 'linear',
92
+ yAxisId = 'y',
93
+ showMarkers = false,
94
+ seriesIndex = 0,
95
+ stackGroup = null,
96
+ } = options;
97
+
98
+ const seriesColor = color || getSeriesColor(seriesIndex);
99
+
100
+ // Determine Plotly trace type and mode
101
+ let plotlyType, mode;
102
+ switch (chartType) {
103
+ case 'line':
104
+ plotlyType = 'scatter';
105
+ mode = showMarkers ? 'lines+markers' : 'lines';
106
+ break;
107
+ case 'scatter':
108
+ plotlyType = 'scatter';
109
+ mode = 'markers';
110
+ break;
111
+ case 'area':
112
+ case 'area-stacked':
113
+ plotlyType = 'scatter';
114
+ mode = showMarkers ? 'lines+markers' : 'lines';
115
+ break;
116
+ case 'phase':
117
+ plotlyType = 'scatter';
118
+ mode = 'lines+markers';
119
+ break;
120
+ case 'bar':
121
+ plotlyType = 'bar';
122
+ mode = undefined;
123
+ break;
124
+ case 'scatter3d':
125
+ plotlyType = 'scatter3d';
126
+ mode = 'markers';
127
+ break;
128
+ case 'line3d':
129
+ plotlyType = 'scatter3d';
130
+ mode = showMarkers ? 'lines+markers' : 'lines';
131
+ break;
132
+ case 'surface':
133
+ plotlyType = 'surface';
134
+ mode = undefined;
135
+ break;
136
+ default:
137
+ plotlyType = 'scatter';
138
+ mode = showMarkers ? 'lines+markers' : 'lines';
139
+ }
140
+
141
+ // Build base trace
142
+ const trace = {
143
+ type: plotlyType,
144
+ name,
145
+ x,
146
+ y,
147
+ };
148
+
149
+ // Add mode if applicable
150
+ if (mode) {
151
+ trace.mode = mode;
152
+ }
153
+
154
+ // Add z-axis data for 3D charts
155
+ if (z && is3DChart(chartType)) {
156
+ // Surface charts require z to be a 2D array
157
+ // If z is 1D (time series data), use mesh3d with Delaunay triangulation
158
+ if (chartType === 'surface') {
159
+ const zIs2D = Array.isArray(z) && z.length > 0 && Array.isArray(z[0]);
160
+ if (zIs2D) {
161
+ trace.z = z;
162
+ } else {
163
+ // Use mesh3d with Delaunay triangulation for 1D scattered points
164
+ console.info('[chart_types] Surface chart with 1D z data - using mesh3d with Delaunay triangulation');
165
+ trace.type = 'mesh3d';
166
+ trace.x = x;
167
+ trace.y = y;
168
+ trace.z = z;
169
+ trace.alphahull = 0; // Delaunay triangulation (convex hull = -1, 0 = Delaunay)
170
+ trace.opacity = 0.8;
171
+ trace.color = seriesColor;
172
+ trace.flatshading = true;
173
+ trace.lighting = {
174
+ ambient: 0.6,
175
+ diffuse: 0.5,
176
+ specular: 0.2,
177
+ };
178
+ // Skip surface-specific config below
179
+ return trace;
180
+ }
181
+ } else {
182
+ trace.z = z;
183
+ }
184
+ }
185
+
186
+ // Phase chart: line with small markers
187
+ if (chartType === 'phase') {
188
+ trace.line = {
189
+ color: seriesColor,
190
+ width: lineWidth,
191
+ dash: LINE_DASH_MAP[lineStyle] || 'solid',
192
+ };
193
+ trace.marker = {
194
+ color: seriesColor,
195
+ size: 3,
196
+ };
197
+ return trace;
198
+ }
199
+
200
+ // Line configuration (for line/area charts)
201
+ if (['line', 'area', 'area-stacked', 'line3d'].includes(chartType)) {
202
+ trace.line = {
203
+ color: seriesColor,
204
+ width: lineWidth,
205
+ dash: LINE_DASH_MAP[lineStyle] || 'solid',
206
+ shape: INTERPOLATION_MAP[interpolation] || 'linear',
207
+ };
208
+ }
209
+
210
+ // Marker configuration
211
+ if (['scatter', 'scatter3d'].includes(chartType) || showMarkers) {
212
+ trace.marker = {
213
+ color: seriesColor,
214
+ size: chartType === 'scatter3d' ? 4 : 6,
215
+ };
216
+ }
217
+
218
+ // Normalized stacked area: each Y-axis is its own stack group, each summing to 1.
219
+ if (chartType === 'area-stacked') {
220
+ trace.stackgroup = yAxisId;
221
+ trace.groupnorm = 'fraction';
222
+ trace.fillcolor = hexToRgba(seriesColor, 0.5);
223
+ trace.line = { ...(trace.line || {}), width: 0.5, color: seriesColor };
224
+ trace.mode = 'lines';
225
+ }
226
+ // Fill for area charts (non-stacked)
227
+ else if (chartType === 'area' && !stackGroup) {
228
+ trace.fill = 'tozeroy';
229
+ trace.fillcolor = hexToRgba(seriesColor, 0.3);
230
+ }
231
+ // Stacked area via explicit stackGroup on any chart type
232
+ else if (stackGroup) {
233
+ trace.stackgroup = stackGroup;
234
+ trace.fillcolor = hexToRgba(seriesColor, 0.5);
235
+ trace.line = { ...(trace.line || {}), width: 0.5, color: seriesColor };
236
+ trace.mode = 'lines';
237
+ }
238
+
239
+ // Bar color
240
+ if (chartType === 'bar') {
241
+ trace.marker = {
242
+ color: seriesColor,
243
+ };
244
+ }
245
+
246
+ // Surface colorscale
247
+ if (chartType === 'surface') {
248
+ trace.colorscale = 'Viridis';
249
+ trace.showscale = true;
250
+ }
251
+
252
+ // Y-axis assignment (for multi-axis support)
253
+ if (!is3DChart(chartType) && yAxisId && yAxisId !== 'y') {
254
+ trace.yaxis = yAxisId;
255
+ }
256
+
257
+ return trace;
258
+ }
259
+
260
+ /**
261
+ * Build layout for Y-axis based on configuration.
262
+ * @param {Object} options
263
+ * @param {string} options.axisId - Axis identifier (y, y2, y3, etc.)
264
+ * @param {string} [options.title] - Axis title
265
+ * @param {string} [options.position] - Axis position: left or right
266
+ * @param {string} [options.scaleType] - Scale type: linear or log
267
+ * @param {number} [options.min] - Minimum value
268
+ * @param {number} [options.max] - Maximum value
269
+ * @param {number} [options.step] - Tick step size
270
+ * @param {boolean} [options.showGrid] - Show grid lines
271
+ * @param {number|null} [options.axisPosition] - Explicit position in normalized coords (0-1), null for default
272
+ * @returns {Object} Plotly axis layout object
273
+ */
274
+ export function buildYAxisLayout(options) {
275
+ const {
276
+ axisId = 'yaxis',
277
+ title = '',
278
+ position = 'left',
279
+ scaleType = 'linear',
280
+ min = null,
281
+ max = null,
282
+ step = null,
283
+ showGrid = true,
284
+ axisPosition = null,
285
+ } = options;
286
+
287
+ const layout = {
288
+ title: { text: title },
289
+ type: scaleType === 'log' ? 'log' : 'linear',
290
+ side: position,
291
+ gridcolor: 'rgba(255,255,255,0.05)',
292
+ linecolor: 'rgba(255,255,255,0.08)',
293
+ tickcolor: 'rgba(255,255,255,0.3)',
294
+ zerolinecolor: 'rgba(255,255,255,0.06)',
295
+ showgrid: showGrid,
296
+ automargin: true, // Auto-expand margin for tick labels
297
+ };
298
+
299
+ // Set range if min/max provided.
300
+ // Both bounds: fixed range (autorange off).
301
+ // One-sided: use autorangeoptions so Plotly auto-ranges within data but respects the cap/floor.
302
+ // Plotly does not accept null in a range array — [null, max] silently sets the lower bound to 0.
303
+ if (min !== null && max !== null) {
304
+ layout.autorange = false;
305
+ layout.range = [min, max];
306
+ } else if (min !== null || max !== null) {
307
+ layout.autorange = true;
308
+ layout.autorangeoptions = {};
309
+ if (min !== null) layout.autorangeoptions.minallowed = min;
310
+ if (max !== null) layout.autorangeoptions.maxallowed = max;
311
+ }
312
+
313
+ // Set tick step if provided
314
+ if (step !== null && step > 0) {
315
+ layout.dtick = step;
316
+ }
317
+
318
+ // For secondary axes, overlay on the first y-axis
319
+ if (axisId !== 'yaxis') {
320
+ layout.overlaying = 'y';
321
+ layout.anchor = 'free';
322
+
323
+ // Use explicit position if provided (for proper multi-axis layout)
324
+ // axisPosition is in normalized figure coordinates (0-1)
325
+ if (axisPosition !== null) {
326
+ layout.position = axisPosition;
327
+ } else {
328
+ // Fallback: position at domain edge (0 for left, 1 for right)
329
+ layout.position = position === 'left' ? 0 : 1;
330
+ }
331
+ }
332
+
333
+ return layout;
334
+ }
335
+
336
+ /**
337
+ * Build X-axis layout.
338
+ * @param {Object} options
339
+ * @param {string} [options.title] - Axis title
340
+ * @param {string} [options.scaleType] - Scale type: linear or log
341
+ * @param {number} [options.min] - Minimum value
342
+ * @param {number} [options.max] - Maximum value
343
+ * @returns {Object} Plotly xaxis layout object
344
+ */
345
+ export function buildXAxisLayout(options) {
346
+ const {
347
+ title = 'Time',
348
+ scaleType = 'linear',
349
+ min = null,
350
+ max = null,
351
+ } = options;
352
+
353
+ const layout = {
354
+ title: { text: title },
355
+ type: scaleType === 'log' ? 'log' : 'linear',
356
+ gridcolor: 'rgba(255,255,255,0.05)',
357
+ linecolor: 'rgba(255,255,255,0.08)',
358
+ tickcolor: 'rgba(255,255,255,0.3)',
359
+ zerolinecolor: 'rgba(255,255,255,0.06)',
360
+ automargin: true,
361
+ };
362
+
363
+ if (min !== null || max !== null) {
364
+ layout.range = [min, max];
365
+ }
366
+
367
+ return layout;
368
+ }
369
+
370
+ /**
371
+ * Build 3D scene layout.
372
+ * @param {Object} options
373
+ * @param {string} [options.xTitle] - X-axis title
374
+ * @param {string} [options.yTitle] - Y-axis title
375
+ * @param {string} [options.zTitle] - Z-axis title
376
+ * @param {string} [options.zScale] - Z-axis scale type: 'linear' or 'log'
377
+ * @param {number} [options.zMin] - Z-axis minimum value
378
+ * @param {number} [options.zMax] - Z-axis maximum value
379
+ * @param {number} [options.zStep] - Z-axis tick step size
380
+ * @returns {Object} Plotly scene layout object
381
+ */
382
+ export function build3DSceneLayout(options) {
383
+ const {
384
+ xTitle = 'X',
385
+ yTitle = 'Y',
386
+ zTitle = 'Z',
387
+ zScale = 'linear',
388
+ zMin = null,
389
+ zMax = null,
390
+ zStep = null,
391
+ } = options;
392
+
393
+ const zaxis = {
394
+ title: { text: zTitle },
395
+ type: zScale === 'log' ? 'log' : 'linear',
396
+ gridcolor: 'rgba(255,255,255,0.05)',
397
+ linecolor: 'rgba(255,255,255,0.08)',
398
+ backgroundcolor: 'rgba(0,0,0,0)',
399
+ };
400
+
401
+ // Set range if min/max provided
402
+ if (zMin !== null || zMax !== null) {
403
+ zaxis.range = [zMin, zMax];
404
+ }
405
+
406
+ // Set tick step if provided
407
+ if (zStep !== null && zStep > 0) {
408
+ zaxis.dtick = zStep;
409
+ }
410
+
411
+ return {
412
+ xaxis: {
413
+ title: { text: xTitle },
414
+ gridcolor: 'rgba(255,255,255,0.05)',
415
+ linecolor: 'rgba(255,255,255,0.08)',
416
+ backgroundcolor: 'rgba(0,0,0,0)',
417
+ },
418
+ yaxis: {
419
+ title: { text: yTitle },
420
+ gridcolor: 'rgba(255,255,255,0.05)',
421
+ linecolor: 'rgba(255,255,255,0.08)',
422
+ backgroundcolor: 'rgba(0,0,0,0)',
423
+ },
424
+ zaxis,
425
+ bgcolor: 'rgba(0,0,0,0)',
426
+ };
427
+ }
428
+
429
+ /**
430
+ * Build legend layout.
431
+ * @param {string} position - Legend position: top, bottom, left, right, hidden
432
+ * @returns {Object} Plotly legend layout
433
+ */
434
+ export function buildLegendLayout(position) {
435
+ if (position === 'hidden') {
436
+ return { showlegend: false };
437
+ }
438
+
439
+ const baseLayout = {
440
+ showlegend: true,
441
+ bgcolor: 'rgba(0,0,0,0)',
442
+ font: { color: '#cccccc' },
443
+ };
444
+
445
+ switch (position) {
446
+ case 'top':
447
+ return {
448
+ ...baseLayout,
449
+ legend: {
450
+ ...baseLayout,
451
+ orientation: 'h',
452
+ y: 1.1,
453
+ yanchor: 'bottom',
454
+ x: 0.5,
455
+ xanchor: 'center',
456
+ },
457
+ };
458
+ case 'bottom':
459
+ return {
460
+ ...baseLayout,
461
+ legend: {
462
+ ...baseLayout,
463
+ orientation: 'h',
464
+ y: -0.15,
465
+ yanchor: 'top',
466
+ x: 0.5,
467
+ xanchor: 'center',
468
+ },
469
+ };
470
+ case 'left':
471
+ return {
472
+ ...baseLayout,
473
+ legend: {
474
+ ...baseLayout,
475
+ orientation: 'v',
476
+ x: -0.15,
477
+ xanchor: 'right',
478
+ y: 0.5,
479
+ yanchor: 'middle',
480
+ },
481
+ };
482
+ case 'right':
483
+ default:
484
+ return {
485
+ ...baseLayout,
486
+ legend: {
487
+ ...baseLayout,
488
+ orientation: 'v',
489
+ x: 1.02,
490
+ xanchor: 'left',
491
+ y: 0.5,
492
+ yanchor: 'middle',
493
+ },
494
+ };
495
+ }
496
+ }
497
+
498
+ /**
499
+ * Build Plotly layout shapes + annotations for reference lines and shaded x-bands.
500
+ *
501
+ * @param {Object} opts
502
+ * @param {Array<{yAxisId?:string, value:number, label?:string, color?:string, lineStyle?:string, lineWidth?:number, labelX?:number}>} [opts.referenceLines]
503
+ * Horizontal threshold lines drawn across the plot, anchored to a specific Y axis.
504
+ * `yAxisId` matches an axis id in sp.yAxes[].id; omitted → first axis.
505
+ * @param {Array<{x0:number, x1:number, label?:string, color?:string, opacity?:number, labelY?:number, labelColor?:string}>} [opts.shadedBands]
506
+ * Vertical stripes (rectangles spanning full y range) to mark policy/recession periods.
507
+ * @param {Object<string,string>} [opts.yRefByAxisId]
508
+ * Mapping from subplot yAxes[i].id → Plotly yref ('y', 'y2', ...).
509
+ * @returns {{shapes: Array, annotations: Array}}
510
+ */
511
+ export function buildOverlayShapes(opts = {}) {
512
+ const { referenceLines = [], shadedBands = [], yRefByAxisId = {} } = opts;
513
+ const shapes = [];
514
+ const annotations = [];
515
+ const dashMap = { solid: 'solid', dashed: 'dash', dotted: 'dot', dashdot: 'dashdot' };
516
+
517
+ for (const band of shadedBands) {
518
+ if (band.x0 == null || band.x1 == null) continue;
519
+ shapes.push({
520
+ type: 'rect',
521
+ xref: 'x',
522
+ yref: 'paper',
523
+ x0: band.x0,
524
+ x1: band.x1,
525
+ y0: 0,
526
+ y1: 1,
527
+ fillcolor: band.color || 'rgba(255, 200, 0, 0.08)',
528
+ line: { width: 0 },
529
+ opacity: band.opacity ?? 1,
530
+ layer: 'below',
531
+ });
532
+ if (band.label) {
533
+ annotations.push({
534
+ x: (band.x0 + band.x1) / 2,
535
+ y: band.labelY ?? 1.0,
536
+ xref: 'x',
537
+ yref: 'paper',
538
+ text: band.label,
539
+ showarrow: false,
540
+ font: { size: 9, color: band.labelColor || 'rgba(255,255,255,0.55)' },
541
+ xanchor: 'center',
542
+ yanchor: 'bottom',
543
+ });
544
+ }
545
+ }
546
+
547
+ const firstYRef = Object.values(yRefByAxisId)[0] || 'y';
548
+ for (const ref of referenceLines) {
549
+ if (ref.value == null || !Number.isFinite(ref.value)) continue;
550
+ const yRef = (ref.yAxisId && yRefByAxisId[ref.yAxisId]) || firstYRef;
551
+ const color = ref.color || 'rgba(255, 120, 120, 0.75)';
552
+ shapes.push({
553
+ type: 'line',
554
+ xref: 'paper',
555
+ yref: yRef,
556
+ x0: 0,
557
+ x1: 1,
558
+ y0: ref.value,
559
+ y1: ref.value,
560
+ line: {
561
+ color,
562
+ width: ref.lineWidth ?? 1.5,
563
+ dash: dashMap[ref.lineStyle || 'dashed'] || 'dash',
564
+ },
565
+ layer: 'above',
566
+ });
567
+ if (ref.label) {
568
+ annotations.push({
569
+ x: ref.labelX ?? 0.015,
570
+ y: ref.value,
571
+ xref: 'paper',
572
+ yref: yRef,
573
+ text: ref.label,
574
+ showarrow: false,
575
+ font: { size: 10, color },
576
+ xanchor: 'left',
577
+ yanchor: 'bottom',
578
+ bgcolor: 'rgba(20,22,26,0.6)',
579
+ borderpad: 2,
580
+ });
581
+ }
582
+ }
583
+
584
+ return { shapes, annotations };
585
+ }
586
+
587
+ /**
588
+ * Create a fan chart trace with confidence bands.
589
+ * @param {Object} options
590
+ * @param {Array} options.x - X-axis data (time)
591
+ * @param {Array} options.upper - Upper bound data
592
+ * @param {Array} options.lower - Lower bound data
593
+ * @param {string} options.color - Band color
594
+ * @param {number} options.opacity - Fill opacity
595
+ * @param {string} [options.name] - Band name for legend
596
+ * @returns {Array} Array of two traces (lower bound + fill to upper)
597
+ */
598
+ export function buildFanBandTraces(options) {
599
+ const { x, upper, lower, color, opacity = 0.2, name = '' } = options;
600
+
601
+ // Lower bound line (invisible, for fill reference)
602
+ const lowerTrace = {
603
+ type: 'scatter',
604
+ x,
605
+ y: lower,
606
+ mode: 'lines',
607
+ line: { width: 0 },
608
+ showlegend: false,
609
+ hoverinfo: 'skip',
610
+ };
611
+
612
+ // Upper bound with fill to lower
613
+ const upperTrace = {
614
+ type: 'scatter',
615
+ x,
616
+ y: upper,
617
+ mode: 'lines',
618
+ line: { width: 0 },
619
+ fill: 'tonexty',
620
+ fillcolor: hexToRgba(color, opacity),
621
+ name: name || `${(opacity * 100).toFixed(0)}% Band`,
622
+ showlegend: !!name,
623
+ };
624
+
625
+ return [lowerTrace, upperTrace];
626
+ }
627
+
628
+ /**
629
+ * Create a histogram trace.
630
+ * @param {Object} options
631
+ * @param {Array} options.x - Data values
632
+ * @param {number} options.nbins - Number of bins
633
+ * @param {string} options.color - Bar color
634
+ * @param {string} [options.name] - Series name
635
+ * @returns {Object} Plotly histogram trace
636
+ */
637
+ export function buildHistogramTrace(options) {
638
+ const { x, nbins = 20, color, name = 'Distribution' } = options;
639
+
640
+ return {
641
+ type: 'histogram',
642
+ x,
643
+ nbinsx: nbins,
644
+ marker: { color },
645
+ name,
646
+ };
647
+ }
648
+
649
+ /**
650
+ * Create a box plot trace.
651
+ * @param {Object} options
652
+ * @param {Array} options.y - Data values
653
+ * @param {string} [options.x] - Category/time label
654
+ * @param {string} options.color - Box color
655
+ * @param {string} [options.name] - Series name
656
+ * @returns {Object} Plotly box trace
657
+ */
658
+ export function buildBoxTrace(options) {
659
+ const { y, x = null, color, name = '' } = options;
660
+
661
+ const trace = {
662
+ type: 'box',
663
+ y,
664
+ marker: { color },
665
+ name,
666
+ boxpoints: false, // Don't show individual points
667
+ };
668
+
669
+ if (x !== null) {
670
+ trace.x = Array.isArray(x) ? x : [x];
671
+ }
672
+
673
+ return trace;
674
+ }
675
+
676
+ /**
677
+ * Create a heatmap trace.
678
+ * @param {Object} options
679
+ * @param {Array} options.z - 2D array of values
680
+ * @param {Array} [options.x] - X labels
681
+ * @param {Array} [options.y] - Y labels
682
+ * @param {string} [options.colorscale] - Plotly colorscale name
683
+ * @returns {Object} Plotly heatmap trace
684
+ */
685
+ export function buildHeatmapTrace(options) {
686
+ const { z, x = null, y = null, colorscale = 'RdBu' } = options;
687
+
688
+ const trace = {
689
+ type: 'heatmap',
690
+ z,
691
+ colorscale,
692
+ showscale: true,
693
+ };
694
+
695
+ if (x) trace.x = x;
696
+ if (y) trace.y = y;
697
+
698
+ return trace;
699
+ }