composeui 0.1.0__py3-none-any.whl

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 (97) hide show
  1. composeui/__init__.py +11 -0
  2. composeui/core/__init__.py +0 -0
  3. composeui/core/application.py +368 -0
  4. composeui/core/chartcolors.py +23 -0
  5. composeui/core/chartimage.py +336 -0
  6. composeui/core/dataframesource.py +307 -0
  7. composeui/core/jobs.py +426 -0
  8. composeui/core/model.py +66 -0
  9. composeui/core/registry.py +66 -0
  10. composeui/core/reload.py +476 -0
  11. composeui/core/renderer.py +193 -0
  12. composeui/core/signal.py +358 -0
  13. composeui/core/sqlitemodel.py +1183 -0
  14. composeui/core/study.py +121 -0
  15. composeui/core/translation.py +221 -0
  16. composeui/core/translationcheck.py +254 -0
  17. composeui/core/view.py +5508 -0
  18. composeui/images/composeui.svg +15 -0
  19. composeui/locales/fr.json +73 -0
  20. composeui/mypy.py +46 -0
  21. composeui/py.typed +0 -0
  22. composeui/qtcore/__init__.py +0 -0
  23. composeui/qtcore/maprenderer.py +115 -0
  24. composeui/qtcore/qtapplication.py +222 -0
  25. composeui/qtcore/qtrenderer.py +2862 -0
  26. composeui/qtcore/widgets/__init__.py +0 -0
  27. composeui/qtcore/widgets/map.qml +123 -0
  28. composeui/qtcore/widgets/qchart.py +393 -0
  29. composeui/qtcore/widgets/qdatefield.py +101 -0
  30. composeui/qtcore/widgets/qexpander.py +60 -0
  31. composeui/qtcore/widgets/qfilepathfield.py +34 -0
  32. composeui/qtcore/widgets/qfiltertable.py +394 -0
  33. composeui/qtcore/widgets/qformdialog.py +64 -0
  34. composeui/qtcore/widgets/qformlabel.py +53 -0
  35. composeui/qtcore/widgets/qmap.py +68 -0
  36. composeui/qtcore/widgets/qmarkicon.py +37 -0
  37. composeui/qtcore/widgets/qmetric.py +44 -0
  38. composeui/qtcore/widgets/qplaintext.py +29 -0
  39. composeui/qtcore/widgets/qradiogroup.py +57 -0
  40. composeui/qtcore/widgets/qsidebar.py +116 -0
  41. composeui/qtcore/widgets/qsourcetable.py +216 -0
  42. composeui/qtcore/widgets/qsplitter.py +69 -0
  43. composeui/qtcore/widgets/qtexttable.py +703 -0
  44. composeui/qtcore/widgets/qvaluecombobox.py +41 -0
  45. composeui/qtcore/widgets/qvalueslider.py +64 -0
  46. composeui/qtcore/widgets/qwindowrelease.py +32 -0
  47. composeui/textualcore/__init__.py +0 -0
  48. composeui/textualcore/textualapplication.py +121 -0
  49. composeui/textualcore/textualrenderer.py +2893 -0
  50. composeui/textualcore/widgets/__init__.py +0 -0
  51. composeui/textualcore/widgets/composebutton.py +17 -0
  52. composeui/textualcore/widgets/composechart.py +212 -0
  53. composeui/textualcore/widgets/composecheckbox.py +35 -0
  54. composeui/textualcore/widgets/composecollapsible.py +41 -0
  55. composeui/textualcore/widgets/composecombobox.py +54 -0
  56. composeui/textualcore/widgets/composedateinput.py +325 -0
  57. composeui/textualcore/widgets/composedialog.py +90 -0
  58. composeui/textualcore/widgets/composefilepathinput.py +85 -0
  59. composeui/textualcore/widgets/composefiltertable.py +367 -0
  60. composeui/textualcore/widgets/composeformdialog.py +86 -0
  61. composeui/textualcore/widgets/composeinput.py +50 -0
  62. composeui/textualcore/widgets/composelist.py +20 -0
  63. composeui/textualcore/widgets/composemarkdown.py +17 -0
  64. composeui/textualcore/widgets/composemetric.py +60 -0
  65. composeui/textualcore/widgets/composeradioset.py +54 -0
  66. composeui/textualcore/widgets/composesidebar.py +111 -0
  67. composeui/textualcore/widgets/composeslider.py +144 -0
  68. composeui/textualcore/widgets/composesourcetable.py +288 -0
  69. composeui/textualcore/widgets/composesplitter.py +211 -0
  70. composeui/textualcore/widgets/composetabbedcontent.py +35 -0
  71. composeui/textualcore/widgets/composetable.py +476 -0
  72. composeui/textualcore/widgets/composetoolbar.py +62 -0
  73. composeui/textualcore/widgets/composetree.py +139 -0
  74. composeui/webcore/__init__.py +0 -0
  75. composeui/webcore/headers.py +57 -0
  76. composeui/webcore/icons.py +58 -0
  77. composeui/webcore/limits.py +55 -0
  78. composeui/webcore/origin.py +63 -0
  79. composeui/webcore/page.py +146 -0
  80. composeui/webcore/routes.py +303 -0
  81. composeui/webcore/session.py +436 -0
  82. composeui/webcore/static/app.css +2 -0
  83. composeui/webcore/static/cally.js +1312 -0
  84. composeui/webcore/static/daisyui.LICENSE +21 -0
  85. composeui/webcore/static/deckgl.LICENSE +19 -0
  86. composeui/webcore/static/deckgl.min.js +6423 -0
  87. composeui/webcore/static/htmx.min.js +1 -0
  88. composeui/webcore/static/page.js +1175 -0
  89. composeui/webcore/static/uPlot.LICENSE +21 -0
  90. composeui/webcore/static/uPlot.iife.min.js +2 -0
  91. composeui/webcore/webapplication.py +495 -0
  92. composeui/webcore/webrenderer.py +5113 -0
  93. composeui-0.1.0.dist-info/METADATA +222 -0
  94. composeui-0.1.0.dist-info/RECORD +97 -0
  95. composeui-0.1.0.dist-info/WHEEL +5 -0
  96. composeui-0.1.0.dist-info/licenses/LICENSE +9 -0
  97. composeui-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1175 @@
1
+ /* The page's script (see `page.py`), run in its <head> before the body is parsed.
2
+ Its <script> element carries the addresses of uPlot and Cally, which carry
3
+ their content's hash. */
4
+ var PAGE_SCRIPT = document.currentScript;
5
+ /* Maps load their renderer only when needed. Tile fetches identify the page by its
6
+ origin, never its path, and never carry credentials to the tile provider. */
7
+ var maps = new Map();
8
+ var deckLoading = false;
9
+ function mapPost(element, event, state) {
10
+ state = Object.assign({}, state, {
11
+ latitude: Math.max(-85.05112878, Math.min(85.05112878, state.latitude)),
12
+ longitude: ((state.longitude + 180) % 360 + 360) % 360 - 180
13
+ });
14
+ htmx.ajax("POST", "/widget/" + element.dataset.widget, {
15
+ source: element,
16
+ values: Object.assign({event: event}, state),
17
+ swap: "none"
18
+ });
19
+ }
20
+ function fittedMapZoom(element, span) {
21
+ if (span[0] === 0 && span[1] === 0) return 12;
22
+ var scales = [];
23
+ [element.clientWidth, element.clientHeight].forEach(function (size, index) {
24
+ if (span[index] > 0) scales.push(Math.max(1, size - 64) / (256 * span[index]));
25
+ });
26
+ return Math.max(0, Math.min(19, Math.log2(Math.min.apply(null, scales))));
27
+ }
28
+ function drawMap(element) {
29
+ element.setAttribute("aria-busy", "true");
30
+ var spec = JSON.parse(element.dataset.map);
31
+ var autoFit = spec.zoom === null;
32
+ var state = {
33
+ latitude: spec.latitude, longitude: spec.longitude,
34
+ zoom: (autoFit ? fittedMapZoom(element, spec.span) : spec.zoom) - 1
35
+ };
36
+ var layers = [];
37
+ if (spec.tiles) {
38
+ layers.push(new deck.TileLayer({
39
+ id: "streets", data: "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
40
+ tileSize: 256, minZoom: 0, maxZoom: 19,
41
+ maxCacheSize: 128,
42
+ onViewportLoad: function () { element.setAttribute("aria-busy", "false"); },
43
+ getTileData: function (tile) {
44
+ return fetch(tile.url, {signal: tile.signal, credentials: "omit", referrerPolicy: "origin"})
45
+ .then(function (response) {
46
+ if (!response.ok) throw new Error("Map tiles unavailable");
47
+ return response.blob();
48
+ }).then(function (blob) { return createImageBitmap(blob); });
49
+ },
50
+ renderSubLayers: function (props) {
51
+ var bounds = props.tile.boundingBox;
52
+ return new deck.BitmapLayer(props, {
53
+ data: null, image: props.data,
54
+ bounds: [bounds[0][0], bounds[0][1], bounds[1][0], bounds[1][1]]
55
+ });
56
+ }
57
+ }));
58
+ }
59
+ layers.push(new deck.ScatterplotLayer({
60
+ id: "pickups", data: spec.points, getPosition: function (point) { return point; },
61
+ getRadius: 3, radiusUnits: "pixels", getFillColor: [37, 99, 235, 140]
62
+ }));
63
+ var timer;
64
+ var map = new deck.Deck({
65
+ parent: element, width: "100%", height: "100%",
66
+ initialViewState: Object.assign({minZoom: -1, maxZoom: 18}, state),
67
+ controller: spec.enabled ? {dragRotate: false, touchRotate: false, keyboard: false} : false,
68
+ layers: layers,
69
+ onAfterRender: function () {
70
+ if (!spec.tiles) element.setAttribute("aria-busy", "false");
71
+ },
72
+ onViewStateChange: function (event) {
73
+ state = event.viewState;
74
+ var interaction = event.interactionState || {};
75
+ if (!interaction.isDragging && !interaction.isPanning && !interaction.isZooming &&
76
+ !interaction.isRotating) return;
77
+ autoFit = false;
78
+ clearTimeout(timer);
79
+ timer = setTimeout(function () {
80
+ if (!element.isConnected) return;
81
+ mapPost(element, "viewport", {
82
+ latitude: state.latitude, longitude: state.longitude, zoom: state.zoom + 1
83
+ });
84
+ }, 150);
85
+ },
86
+ onClick: function (info) {
87
+ if (spec.enabled && info.coordinate) mapPost(element, "click", {
88
+ longitude: info.coordinate[0], latitude: info.coordinate[1]
89
+ });
90
+ }
91
+ });
92
+ element.mapObserver = new ResizeObserver(function () {
93
+ if (!autoFit || element.clientWidth === 0 || element.clientHeight === 0) return;
94
+ state.zoom = fittedMapZoom(element, spec.span) - 1;
95
+ map.setProps({initialViewState: Object.assign({minZoom: -1, maxZoom: 18}, state)});
96
+ });
97
+ element.mapObserver.observe(element);
98
+ element.addEventListener("keydown", function (event) {
99
+ if (!spec.enabled) return;
100
+ var next = Object.assign({}, state);
101
+ var step = 360 / Math.pow(2, state.zoom + 4);
102
+ if (event.key === "+" || event.key === "=") next.zoom = Math.min(18, state.zoom + 1);
103
+ else if (event.key === "-") next.zoom = Math.max(-1, state.zoom - 1);
104
+ else if (event.key === "ArrowLeft") next.longitude -= step;
105
+ else if (event.key === "ArrowRight") next.longitude += step;
106
+ else if (event.key === "ArrowUp") next.latitude += step;
107
+ else if (event.key === "ArrowDown") next.latitude -= step;
108
+ else return;
109
+ autoFit = false;
110
+ event.preventDefault();
111
+ next.latitude = Math.max(-85.05112878, Math.min(85.05112878, next.latitude));
112
+ next.longitude = ((next.longitude + 180) % 360 + 360) % 360 - 180;
113
+ state = next;
114
+ map.setProps({initialViewState: Object.assign({minZoom: -1, maxZoom: 18}, next)});
115
+ mapPost(element, "viewport", {latitude: next.latitude, longitude: next.longitude, zoom: next.zoom + 1});
116
+ });
117
+ return map;
118
+ }
119
+ function syncMaps() {
120
+ maps.forEach(function (map, element) {
121
+ if (!element.isConnected) {
122
+ element.mapObserver.disconnect();
123
+ map.finalize();
124
+ maps.delete(element);
125
+ }
126
+ });
127
+ if (document.querySelector("[data-map]") === null) return;
128
+ if (typeof deck === "undefined") {
129
+ if (deckLoading) return;
130
+ deckLoading = true;
131
+ var script = document.createElement("script");
132
+ script.src = PAGE_SCRIPT.dataset.deckgl;
133
+ script.onload = syncMaps;
134
+ document.head.appendChild(script);
135
+ return;
136
+ }
137
+ document.querySelectorAll("[data-map]").forEach(function (element) {
138
+ if (!maps.has(element) && element.clientWidth > 0) maps.set(element, drawMap(element));
139
+ });
140
+ }
141
+ /* charts (see `WebChartRenderer`): uPlot draws each `[data-chart]` element from its
142
+ JSON. The page loads uPlot only once it shows its first chart, from a swap too. A
143
+ chart waits to be drawn until its element has a width (one on a hidden page has
144
+ none), follows its element's width, and is rebuilt when the theme changes: uPlot
145
+ can't recolor a chart. One swapped out is destroyed, leaving the x range the user
146
+ zoomed into to the chart swapped in with its id (a job adding points). */
147
+ var charts = new Map(); // each element drawn, or waiting for a width, to its chart
148
+ var chartZooms = new Map(); // the zoom of a chart swapped out, by its element's id
149
+ var uPlotLoading = false;
150
+ function loadCharts() {
151
+ if (document.querySelector("[data-chart]") === null || uPlotLoading) return;
152
+ uPlotLoading = true;
153
+ var script = document.createElement("script");
154
+ script.src = PAGE_SCRIPT.dataset.uplot;
155
+ script.onload = syncCharts;
156
+ document.head.appendChild(script);
157
+ }
158
+ /* a CSS color as the canvas takes it: daisyUI's variables, mixed, resolved */
159
+ function resolveColor(element, color) {
160
+ var probe = document.createElement("span");
161
+ probe.hidden = true;
162
+ probe.style.color = color;
163
+ element.appendChild(probe);
164
+ var resolved = getComputedStyle(probe).color;
165
+ probe.remove();
166
+ return resolved;
167
+ }
168
+ /* the theme's colors, ordered so neighbours differ in hue: primary and secondary alike
169
+ are blue to violet */
170
+ var SERIES_COLORS = ["primary", "warning", "accent", "secondary", "success", "info", "error"];
171
+ function chartValue(unit) {
172
+ return function (chart, value) {
173
+ if (value === null || value === undefined) return "-";
174
+ // its trailing zeros kept: as many digits wherever the cursor is
175
+ return value.toPrecision(4) + (unit === "" ? "" : " " + unit);
176
+ };
177
+ }
178
+ function chartOptions(element, spec, width) {
179
+ var ink = resolveColor(element, "var(--color-base-content)");
180
+ var background = resolveColor(element, "var(--color-base-100)");
181
+ var faint = resolveColor(
182
+ element, "color-mix(in oklch, var(--color-base-content) 12%, transparent)"
183
+ );
184
+ function axis(side) {
185
+ return {
186
+ label: side.label,
187
+ stroke: ink,
188
+ grid: { stroke: faint, width: 1 },
189
+ ticks: { stroke: faint, width: 1 },
190
+ };
191
+ }
192
+ var series = spec.series.map(function (series, index) {
193
+ // its own color, the same in every theme, or the theme's
194
+ var color = series.color || resolveColor(
195
+ element, "var(--color-" + SERIES_COLORS[index % SERIES_COLORS.length] + ")"
196
+ );
197
+ var options = { label: series.name, stroke: color, value: chartValue(spec.y.unit) };
198
+ if (series.kind === "bar") {
199
+ // its bar in each category's group, by its left edge: offsets from the server
200
+ function lefts(chart) {
201
+ return chart.data[0].map(function (x) { return x + series.offset - series.width / 2; });
202
+ }
203
+ function widths(chart) {
204
+ return chart.data[0].map(function () { return series.width; });
205
+ }
206
+ options.paths = uPlot.paths.bars({
207
+ disp: { x0: { unit: 1, values: lefts }, size: { unit: 1, values: widths } },
208
+ });
209
+ options.fill = color;
210
+ // a line of the background between neighbouring bars
211
+ options.stroke = background;
212
+ options.width = 1;
213
+ options.points = { show: false };
214
+ } else if (series.kind === "scatter") {
215
+ // dots alone, ringed with the background: overlapping ones stay apart
216
+ options.paths = function () { return null; };
217
+ options.points = { show: true, size: 8, fill: color, stroke: background, width: 1 };
218
+ } else {
219
+ options.width = 2;
220
+ // down to 0, uPlot's own fill, e.g. a shape
221
+ if (series.fill) options.fill = color;
222
+ // past the theme's colors, around again dashed
223
+ options.dash = index < SERIES_COLORS.length ? undefined : [6, 4];
224
+ options.spanGaps = false;
225
+ options.points = { show: false };
226
+ // each value held until the next point; its fill, a step area
227
+ if (series.step) options.paths = uPlot.paths.stepped({ align: 1 });
228
+ }
229
+ return options;
230
+ });
231
+ var x = { label: spec.x.label, value: chartValue(spec.x.unit) };
232
+ if (spec.x.label === "") {
233
+ // no title: the value alone, rather than uPlot's "Value:" before it
234
+ x.label = document.createElement("span");
235
+ x.class = "chart-unnamed";
236
+ }
237
+ // a log scale: uPlot's distribution 3
238
+ var scales = { x: { time: false, distr: spec.x.log ? 3 : 1 }, y: { distr: spec.y.log ? 3 : 1 } };
239
+ var axes = [axis(spec.x), axis(spec.y)];
240
+ if ("categories" in spec) {
241
+ // a category's index stands for its name; the bars stand on 0
242
+ var categories = spec.categories;
243
+ var name = function (chart, index) {
244
+ return index === null || index === undefined ? "-" : categories[index] || "";
245
+ };
246
+ x.value = name;
247
+ scales.x.range = function () { return [-0.5, categories.length - 0.5]; };
248
+ if (!spec.y.log) {
249
+ scales.y.range = function (chart, min, max) {
250
+ return uPlot.rangeNum(Math.min(0, min), Math.max(0, max), 0.1, true);
251
+ };
252
+ }
253
+ axes[0].splits = function () {
254
+ return categories.map(function (_, index) { return index; });
255
+ };
256
+ axes[0].values = function (chart, splits) {
257
+ return splits.map(function (split) { return name(chart, split); });
258
+ };
259
+ axes[0].grid = { show: false };
260
+ }
261
+ // the ranges take the reference lines in, as they would points
262
+ var lineXs = [];
263
+ var lineYs = [];
264
+ spec.lines.forEach(function (line) { (line.vertical ? lineXs : lineYs).push(line.value); });
265
+ if (!("categories" in spec)) {
266
+ scales.x.range = rangeWith(scales.x.range, lineXs, spec.x.log, false);
267
+ }
268
+ scales.y.range = rangeWith(scales.y.range, lineYs, spec.y.log, true);
269
+ return {
270
+ width: width,
271
+ height: element.clientHeight,
272
+ cursor: { dataIdx: heldIndex(spec) },
273
+ scales: scales,
274
+ axes: axes,
275
+ series: [x].concat(series),
276
+ // under the plot, out of its element: the element's height is the plot's
277
+ legend: {
278
+ mount: function (chart, legend) {
279
+ element.chartLegend = legend;
280
+ element.after(legend);
281
+ },
282
+ },
283
+ hooks: { draw: [drawReferenceLines(spec, ink)] },
284
+ };
285
+ }
286
+ /* a scale's range, uPlot's own unless given, widened to `values`; as given without any */
287
+ function rangeWith(range, values, log, padded) {
288
+ if (values.length === 0) return range;
289
+ if (range === undefined) {
290
+ range = function (chart, min, max) {
291
+ if (log) return uPlot.rangeLog(min, max, 10, padded);
292
+ return padded || min === max ? uPlot.rangeNum(min, max, 0.1, true) : [min, max];
293
+ };
294
+ }
295
+ var low = Math.min.apply(null, values);
296
+ var high = Math.max.apply(null, values);
297
+ return function (chart, min, max, key) {
298
+ // no data: the lines alone
299
+ return range(
300
+ chart, min === null ? low : Math.min(min, low), max === null ? high : Math.max(max, high), key
301
+ );
302
+ };
303
+ }
304
+ /* a reference line's dash pattern, by its style, in CSS pixels */
305
+ var LINE_DASHES = { solid: [], dashed: [6, 4], dotted: [2, 3], dash_dot: [6, 3, 2, 3] };
306
+ /* the reference lines (see `ReferenceLine`): in their styles, over the series, each
307
+ labeled at its end in the ticks' font, which an image's options enlarge */
308
+ function drawReferenceLines(spec, ink) {
309
+ return function (chart) {
310
+ var context = chart.ctx;
311
+ var box = chart.bbox; // the plot, in the canvas' pixels
312
+ var gap = 4 * uPlot.pxRatio;
313
+ context.save();
314
+ context.font = chart.axes[1].font[0];
315
+ context.lineWidth = 1.5 * uPlot.pxRatio;
316
+ context.textAlign = "right";
317
+ spec.lines.forEach(function (line) {
318
+ var position = chart.valToPos(line.value, line.vertical ? "x" : "y", true);
319
+ var start = line.vertical ? box.left : box.top;
320
+ var end = start + (line.vertical ? box.width : box.height);
321
+ if (!(position >= start && position <= end)) return; // zoomed past it
322
+ context.strokeStyle = context.fillStyle = line.color || ink;
323
+ context.setLineDash(LINE_DASHES[line.style].map(function (length) {
324
+ return length * uPlot.pxRatio;
325
+ }));
326
+ context.beginPath();
327
+ if (line.vertical) {
328
+ context.moveTo(position, box.top);
329
+ context.lineTo(position, box.top + box.height);
330
+ } else {
331
+ context.moveTo(box.left, position);
332
+ context.lineTo(box.left + box.width, position);
333
+ }
334
+ context.stroke();
335
+ // a horizontal one's over it at its right end; a vertical one's at its top, left
336
+ // of it (now, after the data)
337
+ if (line.vertical) {
338
+ context.textBaseline = "top";
339
+ context.fillText(line.label, position - gap, box.top + gap);
340
+ } else {
341
+ context.textBaseline = "bottom";
342
+ context.fillText(line.label, box.left + box.width - gap, position - gap / 2);
343
+ }
344
+ });
345
+ context.restore();
346
+ };
347
+ }
348
+ /* the point whose value the legend shows at the cursor: uPlot's nearest, but a step
349
+ line's the last at or before the cursor, whose value it holds there */
350
+ function heldIndex(spec) {
351
+ return function (chart, seriesIndex, nearest, x) {
352
+ var series = spec.series[seriesIndex - 1]; // none for the x, at 0
353
+ if (series === undefined || !series.step || series.kind !== "line" || nearest === null) {
354
+ return nearest;
355
+ }
356
+ var xs = chart.data[0];
357
+ var ys = chart.data[seriesIndex];
358
+ var index = nearest;
359
+ // back over a later point, and over the other series' x, where it has no point
360
+ while (index >= 0 && (xs[index] > x || ys[index] === undefined)) index--;
361
+ return index < 0 ? null : index;
362
+ };
363
+ }
364
+ function chartData(spec) {
365
+ if ("data" in spec) return spec.data;
366
+ if (spec.pairs.length === 0) return [[]]; // no series yet: the axes alone
367
+ // each series by its own x: joined on every x, a series' own gaps kept
368
+ return uPlot.join(spec.pairs);
369
+ }
370
+ function drawChart(element, zoom) {
371
+ var spec = JSON.parse(element.dataset.chart);
372
+ var options = chartOptions(element, spec, element.clientWidth);
373
+ // the image's download follows the zoom
374
+ options.hooks.setScale = [function (chart) { updateImageLink(element, chart); }];
375
+ options.hooks.setLegend = [function () { keepLegendWidths(element.chartLegend); }];
376
+ var chart = new uPlot(options, chartData(spec), element);
377
+ if (zoom !== undefined) chart.setScale("x", zoom);
378
+ charts.set(element, chart);
379
+ updateImageLink(element, chart);
380
+ postClicks(element, chart);
381
+ }
382
+ /* each value of the legend as wide as the widest it has shown: a value of other digits
383
+ at the cursor, e.g. a minus sign or an exponent, would move its neighbours */
384
+ function keepLegendWidths(legend) {
385
+ if (legend === undefined) return;
386
+ legend.querySelectorAll(".u-value").forEach(function (cell) {
387
+ var width = cell.getBoundingClientRect().width;
388
+ if (width > (cell.widestValue || 0)) {
389
+ cell.widestValue = width;
390
+ cell.style.minWidth = width + "px";
391
+ }
392
+ });
393
+ }
394
+ /* a click on the plot posts its point, in the axes' values (see `WebChartRenderer`);
395
+ not the end of a drag, which zooms */
396
+ var CLICK_SLOP = 3;
397
+ function postClicks(element, chart) {
398
+ var down = null;
399
+ chart.over.addEventListener("mousedown", function (event) {
400
+ down = { x: event.clientX, y: event.clientY };
401
+ });
402
+ chart.over.addEventListener("click", function (event) {
403
+ if (down === null) return;
404
+ var moved = Math.abs(event.clientX - down.x) + Math.abs(event.clientY - down.y);
405
+ down = null;
406
+ if (moved > CLICK_SLOP) return;
407
+ var figure = element.closest("figure");
408
+ htmx.ajax("POST", "/widget/" + figure.id, {
409
+ source: figure,
410
+ swap: "none",
411
+ values: {
412
+ event: "click",
413
+ x: chart.posToVal(event.offsetX, "x"),
414
+ y: chart.posToVal(event.offsetY, "y"),
415
+ },
416
+ });
417
+ });
418
+ }
419
+ /* the address of a chart's image (see `WebChartRenderer`): its format picked beside it,
420
+ and the region the user zoomed into, which the server draws */
421
+ function updateImageLink(element, chart) {
422
+ var figure = element.closest("figure");
423
+ var link = figure === null ? null : figure.querySelector("[data-chart-image]");
424
+ if (link === null) return;
425
+ var query = new URLSearchParams({ format: figure.querySelector("[data-chart-format]").value });
426
+ if (chart !== null && chart !== undefined && chartZoom(chart) !== undefined) {
427
+ query.set("x_min", chart.scales.x.min);
428
+ query.set("x_max", chart.scales.x.max);
429
+ query.set("y_min", chart.scales.y.min);
430
+ query.set("y_max", chart.scales.y.max);
431
+ }
432
+ link.setAttribute("href", link.getAttribute("href").split("?")[0] + "?" + query);
433
+ }
434
+ document.addEventListener("change", function (event) {
435
+ if (!("chartFormat" in event.target.dataset)) return;
436
+ var element = event.target.closest("figure").querySelector("[data-chart]");
437
+ updateImageLink(element, charts.get(element));
438
+ });
439
+ /* no matplotlib on the server: the page draws the chart's PNG itself, a second chart
440
+ off-screen at the image's size, in the light theme whatever the page's (a figure for
441
+ a document), at the zoom shown. Its canvas, under the title, over the legend (both
442
+ HTML in uPlot, not on its canvas), on a white canvas of its own, downloaded. */
443
+ var PNG_WIDTH = 1600;
444
+ var PNG_HEIGHT = 1000;
445
+ var PNG_TITLE_HEIGHT = 80;
446
+ var PNG_LEGEND_HEIGHT = 70;
447
+ document.addEventListener("click", function (event) {
448
+ var button = event.target.closest("[data-chart-png]");
449
+ if (button !== null) savePng(button.closest("figure"), button.dataset.chartPng);
450
+ });
451
+ function savePng(figure, name) {
452
+ var element = figure.querySelector("[data-chart]");
453
+ var spec = JSON.parse(element.dataset.chart);
454
+ var shown = charts.get(element);
455
+ var zoom = shown === null || shown === undefined ? undefined : chartZoom(shown);
456
+ var titleHeight = spec.title === "" ? 0 : PNG_TITLE_HEIGHT;
457
+ var stage = document.createElement("div");
458
+ stage.dataset.theme = "composeui";
459
+ stage.dataset.chartStage = "";
460
+ stage.style.cssText = "position: fixed; left: -100000px; top: 0; width: " + PNG_WIDTH + "px";
461
+ var plot = document.createElement("div");
462
+ plot.style.height = PNG_HEIGHT - titleHeight - PNG_LEGEND_HEIGHT + "px";
463
+ stage.appendChild(plot);
464
+ document.body.appendChild(stage);
465
+ var options = chartOptions(plot, spec, PNG_WIDTH);
466
+ options.legend = { show: false };
467
+ // the screen's fonts would be tiny on an image this large
468
+ options.axes.forEach(function (axis, index) {
469
+ axis.font = "22px system-ui, sans-serif";
470
+ axis.labelFont = "600 24px system-ui, sans-serif";
471
+ axis.size = index === 0 ? 60 : 90;
472
+ axis.labelSize = 40;
473
+ });
474
+ options.series.slice(1).forEach(function (series) {
475
+ if (series.points.show) series.points.size = 14;
476
+ else if (series.paths === undefined) series.width = 3;
477
+ });
478
+ if (zoom !== undefined) {
479
+ options.scales.x.range = function () { return [zoom.min, zoom.max]; };
480
+ }
481
+ // read before uPlot takes the options over
482
+ var entries = options.series.slice(1).map(function (series, index) {
483
+ return { name: spec.series[index].name, color: series.fill || series.stroke };
484
+ });
485
+ var ink = resolveColor(plot, "var(--color-base-content)");
486
+ var isSaved = false;
487
+ // once its reference lines are drawn
488
+ options.hooks.draw.push(function (chart) {
489
+ if (isSaved) return;
490
+ isSaved = true;
491
+ var canvas = composePng(chart.ctx.canvas, spec.title, entries, ink, titleHeight);
492
+ chart.destroy();
493
+ stage.remove();
494
+ canvas.toBlob(function (blob) { download(blob, name); });
495
+ });
496
+ new uPlot(options, chartData(spec), plot);
497
+ }
498
+ function composePng(plotCanvas, title, entries, ink, titleHeight) {
499
+ var canvas = document.createElement("canvas");
500
+ canvas.width = PNG_WIDTH;
501
+ canvas.height = PNG_HEIGHT;
502
+ var context = canvas.getContext("2d");
503
+ context.fillStyle = "#ffffff";
504
+ context.fillRect(0, 0, PNG_WIDTH, PNG_HEIGHT);
505
+ context.fillStyle = ink;
506
+ context.textBaseline = "middle";
507
+ if (title !== "") {
508
+ context.font = "600 36px system-ui, sans-serif";
509
+ context.textAlign = "center";
510
+ context.fillText(title, PNG_WIDTH / 2, titleHeight / 2);
511
+ }
512
+ var plotHeight = PNG_HEIGHT - titleHeight - PNG_LEGEND_HEIGHT;
513
+ context.drawImage(plotCanvas, 0, titleHeight, PNG_WIDTH, plotHeight);
514
+ // the legend in a row, centered: each series' color, then its name
515
+ context.font = "28px system-ui, sans-serif";
516
+ context.textAlign = "left";
517
+ var widths = entries.map(function (entry) { return 36 + context.measureText(entry.name).width; });
518
+ var gap = 40;
519
+ var total = widths.reduce(function (sum, width) { return sum + width; }, 0);
520
+ var x = Math.max(0, (PNG_WIDTH - total - gap * (entries.length - 1)) / 2);
521
+ var y = PNG_HEIGHT - PNG_LEGEND_HEIGHT / 2;
522
+ entries.forEach(function (entry, index) {
523
+ context.fillStyle = entry.color;
524
+ context.fillRect(x, y - 12, 24, 24);
525
+ context.fillStyle = ink;
526
+ context.fillText(entry.name, x + 36, y);
527
+ x += widths[index] + gap;
528
+ });
529
+ return canvas;
530
+ }
531
+ function download(blob, name) {
532
+ var url = URL.createObjectURL(blob);
533
+ var link = document.createElement("a");
534
+ link.href = url;
535
+ link.download = name;
536
+ document.body.appendChild(link);
537
+ link.click();
538
+ link.remove();
539
+ // once the browser has the file
540
+ setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
541
+ }
542
+ /* the x range the user zoomed into; undefined while the chart shows all its data */
543
+ function chartZoom(chart) {
544
+ var xs = chart.data[0];
545
+ var scale = chart.scales.x;
546
+ if (xs.length === 0 || (scale.min <= xs[0] && scale.max >= xs[xs.length - 1])) return undefined;
547
+ return { min: scale.min, max: scale.max };
548
+ }
549
+ function destroyChart(element) {
550
+ var chart = charts.get(element);
551
+ charts.delete(element);
552
+ element.chartObserver.disconnect();
553
+ if (chart === null) return;
554
+ var zoom = chartZoom(chart);
555
+ if (zoom !== undefined) chartZooms.set(element.id, zoom);
556
+ chart.destroy();
557
+ }
558
+ function syncCharts() {
559
+ charts.forEach(function (chart, element) {
560
+ if (!element.isConnected) destroyChart(element);
561
+ });
562
+ if (typeof uPlot === "undefined") {
563
+ loadCharts();
564
+ return;
565
+ }
566
+ document.querySelectorAll("[data-chart]").forEach(function (element) {
567
+ if (charts.has(element)) return;
568
+ charts.set(element, null);
569
+ element.chartObserver = new ResizeObserver(function () {
570
+ var chart = charts.get(element);
571
+ var width = element.clientWidth;
572
+ if (width === 0) return;
573
+ if (chart === null) {
574
+ drawChart(element, chartZooms.get(element.id));
575
+ chartZooms.delete(element.id);
576
+ } else if (chart.width !== width) {
577
+ chart.setSize({ width: width, height: element.clientHeight });
578
+ }
579
+ });
580
+ element.chartObserver.observe(element);
581
+ });
582
+ }
583
+ /* the theme changed: each chart drawn again in its colors, at the same zoom */
584
+ function redrawCharts() {
585
+ charts.forEach(function (chart, element) {
586
+ if (chart === null) return;
587
+ var zoom = chartZoom(chart);
588
+ chart.destroy();
589
+ drawChart(element, zoom);
590
+ });
591
+ }
592
+ /* the theme picked in the header's Theme menu: Light or Dark win over the browser's
593
+ light/dark preference and are remembered by this browser; System forgets the choice
594
+ and follows the browser again. Applied here, before the body renders, so a reload
595
+ never flashes the other theme first. */
596
+ var THEMES = ["composeui", "composeui-dark"];
597
+ function storedTheme() {
598
+ var theme = null;
599
+ try {
600
+ theme = localStorage.getItem("composeui-theme");
601
+ } catch (error) {} // storage blocked: follow the browser's preference
602
+ return THEMES.indexOf(theme) >= 0 ? theme : "system";
603
+ }
604
+ function applyTheme(choice) {
605
+ if (choice === "system") delete document.documentElement.dataset.theme;
606
+ else document.documentElement.dataset.theme = choice;
607
+ document.querySelectorAll("[data-theme-option]").forEach(function (option) {
608
+ option.setAttribute("aria-pressed", String(option.dataset.themeOption === choice));
609
+ });
610
+ redrawCharts();
611
+ }
612
+ window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", redrawCharts);
613
+ function setTheme(choice) {
614
+ try {
615
+ if (choice === "system") localStorage.removeItem("composeui-theme");
616
+ else localStorage.setItem("composeui-theme", choice);
617
+ } catch (error) {}
618
+ applyTheme(choice);
619
+ }
620
+ applyTheme(storedTheme());
621
+ document.addEventListener("DOMContentLoaded", function () {
622
+ applyTheme(storedTheme()); // again, now that the menu exists: marks the current choice
623
+ });
624
+ /* menu shortcuts: Alt+Shift+<key> clicks the item marked with that key. Matched on the
625
+ physical key (`code`), so it works whatever character the keyboard layout, or the
626
+ Option key on macOS, would type. */
627
+ document.addEventListener("keydown", function (event) {
628
+ if (!event.altKey || !event.shiftKey || event.ctrlKey || event.metaKey) return;
629
+ var item = document.querySelector('[data-shortcut="' + event.code + '"]');
630
+ if (item === null) return;
631
+ event.preventDefault();
632
+ item.click();
633
+ });
634
+ /* keeps each form field's aria-invalid in step with its log: while typing, only the log
635
+ is swapped, never the field. The field is invalid while its log shows an error, or
636
+ a check of its form fails on its row (`data-form-error`). Watches the DOM, so it
637
+ runs after every htmx swap. */
638
+ function syncInvalidFields() {
639
+ document.querySelectorAll("[data-log]").forEach(function (log) {
640
+ var field = document.querySelector('[aria-describedby~="' + log.id + '"]');
641
+ if (field === null) return;
642
+ var ownError = log.dataset.log === "error" && !log.hidden;
643
+ if (ownError || "formError" in log.dataset) field.setAttribute("aria-invalid", "true");
644
+ else field.removeAttribute("aria-invalid");
645
+ });
646
+ }
647
+ /* opens each dialog the server shows: one swapped in arrives closed, and the browser
648
+ opens a modal `<dialog>` only from a script. Each element opens once: a button closes
649
+ its dialog before the server's answer swaps it closed too. htmx processes it first:
650
+ this runs as soon as it lands, maybe before htmx would, and Escape must post its
651
+ `cancel` however soon it comes. */
652
+ function openDialogs() {
653
+ document.querySelectorAll("dialog[data-dialog]:not([hidden]):not([data-opened])").forEach(
654
+ function (dialog) {
655
+ dialog.dataset.opened = "";
656
+ htmx.process(dialog);
657
+ dialog.showModal();
658
+ },
659
+ );
660
+ }
661
+
662
+ /* what the markup can't say itself: the page's policy (`headers.py`) runs no inline
663
+ handler, and no code htmx would evaluate (`hx-on`, `js:` values, trigger filters). The
664
+ values a request posts beside its element's own (see `htmx-config` in `page.py`): */
665
+ document.addEventListener("htmx:configRequest", function (event) {
666
+ var element = event.detail.elt;
667
+ var parameters = event.detail.parameters;
668
+ var triggering = event.detail.triggeringEvent;
669
+ if ("postEvent" in element.dataset) parameters.event = triggering.type;
670
+ if ("postPicked" in element.dataset) parameters.picked = triggering.target.value;
671
+ if ("postOpen" in element.dataset) parameters.open = element.open;
672
+ if ("sidebarToggle" in element.dataset) {
673
+ var sidebar = document.getElementById(element.getAttribute("aria-controls"));
674
+ parameters.expanded = !sidebar.hasAttribute("data-retracted");
675
+ }
676
+ });
677
+ document.addEventListener("htmx:afterRequest", function (event) {
678
+ if ("clearAfterPost" in event.detail.elt.dataset) event.detail.elt.value = "";
679
+ });
680
+ /* the triggers htmx would filter: Enter, Escape, and `activate` (a click, or Enter, not
681
+ from a field or a button, which take their own) for a table's rows. Captured, so they
682
+ come before the keydown's own handlers, as htmx's filters did. */
683
+ document.addEventListener("keydown", function (event) {
684
+ if (event.key === "Enter") dispatch(event.target, "enter-key");
685
+ else if (event.key === "Escape") dispatch(event.target, "escape-key");
686
+ if (event.key === "Enter" && event.target.closest("input,select,button") === null) {
687
+ dispatch(event.target, "activate");
688
+ }
689
+ }, true);
690
+ function dispatch(target, type) {
691
+ target.dispatchEvent(new CustomEvent(type, { bubbles: true }));
692
+ }
693
+ /* a dialog's button closes it first, so the focus returns where it was, then posts its
694
+ answer. A dialog Escape mustn't dismiss stays open: the browser lets its `cancel` be
695
+ refused only once per user action, then closes it anyway on the next Escape, and the
696
+ page opens it again, still shown on the server (neither event bubbles). */
697
+ document.addEventListener("click", function (event) {
698
+ if (event.target.closest("input,select,button") === null) dispatch(event.target, "activate");
699
+ var closing = event.target.closest("[data-close-dialog]");
700
+ if (closing !== null) closing.closest("dialog").close();
701
+ }, true);
702
+ document.addEventListener("cancel", function (event) {
703
+ if ("keepOpen" in event.target.dataset) event.preventDefault();
704
+ }, true);
705
+ document.addEventListener("close", function (event) {
706
+ var dialog = event.target;
707
+ // one swapped out, or swapped in closed by the server, is no longer this one shown
708
+ if ("keepOpen" in dialog.dataset && dialog.isConnected && !dialog.hidden) dialog.showModal();
709
+ }, true);
710
+ /* a menu's item, or the Theme menu's choice, closes its dropdown (daisyUI's stays open
711
+ while it has the focus) */
712
+ document.addEventListener("click", function (event) {
713
+ var option = event.target.closest("[data-theme-option]");
714
+ if (option !== null) setTheme(option.dataset.themeOption);
715
+ if (option !== null || event.target.closest("[data-close-dropdown]") !== null) {
716
+ document.activeElement.blur();
717
+ }
718
+ });
719
+ /* the answer to a request whose element a swap has since replaced (e.g. a row clicked
720
+ before the answer to a click on another row) still swaps its fragments: htmx looks
721
+ for them from the element, which is no longer in the page. Whether it is, is read
722
+ before htmx handles the answer, which may swap it out itself. */
723
+ document.addEventListener("htmx:beforeRequest", function (event) {
724
+ var element = event.detail.elt;
725
+ var request = event.detail.xhr;
726
+ var isReplaced = false;
727
+ request.addEventListener("readystatechange", function () {
728
+ if (request.readyState === XMLHttpRequest.DONE) isReplaced = !element.isConnected;
729
+ });
730
+ request.addEventListener("load", function () {
731
+ if (isReplaced && request.status === 200) {
732
+ htmx.swap(document.body, request.responseText, { swapStyle: "none" });
733
+ }
734
+ });
735
+ });
736
+ /* editing a table cell: the server swaps the table back with a field in the cell (see
737
+ `WebTableRenderer`), posting its text on Enter or when the user leaves it. F2 on a row
738
+ edits its first editable cell, as a double click on the cell does; the field comes
739
+ with its text selected, and Escape puts the text back before leaving it. */
740
+ document.addEventListener("keydown", function (event) {
741
+ var target = event.target;
742
+ if (event.key === "F2" && target.tagName === "TR") {
743
+ var cell = target.querySelector("[data-edit]");
744
+ if (cell !== null) {
745
+ event.preventDefault();
746
+ htmx.trigger(cell, "dblclick");
747
+ }
748
+ } else if (event.key === "Escape" && "cell" in target.dataset) {
749
+ target.value = target.dataset.original;
750
+ delete target.dataset.typed;
751
+ target.blur();
752
+ }
753
+ });
754
+ document.addEventListener("htmx:load", function (event) {
755
+ var field = event.detail.elt.querySelector("[data-cell]");
756
+ if (field !== null && field === document.activeElement && !("typed" in field.dataset)) {
757
+ field.select();
758
+ }
759
+ });
760
+ /* the field keeps the text the user is typing through a swap of its table (e.g. the
761
+ answer to the double click's own clicks, selecting the row, arriving after a key):
762
+ the server only gets the text at the end, so its copy of the table has the old. */
763
+ document.addEventListener("input", function (event) {
764
+ if ("cell" in event.target.dataset) event.target.dataset.typed = "";
765
+ });
766
+ document.addEventListener("htmx:oobBeforeSwap", function (event) {
767
+ var field = document.activeElement;
768
+ if (field === null || !("typed" in field.dataset)) return;
769
+ var swapped = event.detail.fragment.getElementById(field.id);
770
+ if (swapped === null) return;
771
+ swapped.setAttribute("value", field.value);
772
+ swapped.dataset.typed = "";
773
+ });
774
+ /* a table's check-all box while some of its rows are checked: only a script shows a
775
+ box partly checked */
776
+ document.addEventListener("htmx:load", function (event) {
777
+ event.detail.elt.querySelectorAll("[data-indeterminate]").forEach(function (box) {
778
+ box.indeterminate = true;
779
+ });
780
+ });
781
+ /* a table's filter editor: its regular expression toggle shows only for a column a
782
+ pattern filters, and the value's placeholder hints at what filters the column picked */
783
+ document.addEventListener("change", function (event) {
784
+ var form = event.target.form;
785
+ if (!form || form.querySelector("[data-filter-column]") === null) return;
786
+ var option = form.querySelector("[data-filter-column]").selectedOptions[0];
787
+ var toggle = form.querySelector("[data-filter-pattern]");
788
+ var patternHint = option.dataset.patternPlaceholder;
789
+ toggle.hidden = patternHint === "";
790
+ var isPattern = patternHint !== "" && toggle.querySelector("input").checked;
791
+ form.querySelector('[name="value"]').placeholder = isPattern
792
+ ? patternHint
793
+ : option.dataset.placeholder;
794
+ });
795
+ /* a slider's value follows its handle while it is dragged: the server only hears of the
796
+ moves debounced (see `WebSliderRenderer`), and its answer never swaps the slider back */
797
+ document.addEventListener("input", function (event) {
798
+ var slider = event.target;
799
+ if (!("slider" in slider.dataset)) return;
800
+ slider.parentElement.querySelector("output").value = slider.value;
801
+ });
802
+ /* the date fields' calendar, Cally's `<calendar-date>`: the page loads it only once it
803
+ shows its first date field, from a swap too, as it does uPlot */
804
+ var callyLoading = false;
805
+ function loadCalendars() {
806
+ if (callyLoading || document.querySelector("calendar-date") === null) return;
807
+ callyLoading = true;
808
+ var script = document.createElement("script");
809
+ script.type = "module";
810
+ script.src = PAGE_SCRIPT.dataset.cally;
811
+ document.head.appendChild(script);
812
+ }
813
+ /* a date field's calendar (see `WebDateInputRenderer`), a popover its button opens: it
814
+ names the months and days in the page's language, and starts on the date, focused,
815
+ so the arrows move from it. Opened before Cally has loaded, it waits for it to draw
816
+ the days: the language as an attribute, which the element keeps once Cally defines
817
+ it, unlike a property set before then. The date picked closes it, the button taking the focus
818
+ back, once its `change` has posted it: the server swaps the field back showing it. */
819
+ document.addEventListener("toggle", function (event) {
820
+ var calendar = event.target.querySelector && event.target.querySelector("[data-date-calendar]");
821
+ if (calendar === null || calendar === undefined || event.newState !== "open") return;
822
+ var language = document.documentElement.lang;
823
+ if (language) calendar.setAttribute("locale", language);
824
+ customElements.whenDefined("calendar-date").then(function () {
825
+ return calendar.updated; // its days drawn, which it focuses
826
+ }).then(function () {
827
+ if (event.target.matches(":popover-open")) calendar.focus();
828
+ });
829
+ }, true);
830
+ document.addEventListener("change", function (event) {
831
+ if (!("dateCalendar" in event.target.dataset)) return;
832
+ var popover = event.target.closest("[popover]");
833
+ popover.hidePopover();
834
+ document.getElementById(popover.dataset.dateButton).focus();
835
+ }, true);
836
+ /* a file path field's "…" button opens the browser's file picker, a hidden file input
837
+ beside it, which uploads the file picked (see `WebFilePathInputRenderer`) */
838
+ document.addEventListener("click", function (event) {
839
+ var button = event.target.closest("[data-file-browse]");
840
+ if (button === null) return;
841
+ button.parentElement.querySelector('input[type="file"]').click();
842
+ });
843
+ /* the sidebar (see `WebSidebarRenderer`): on a wide screen, its button in the header
844
+ retracts and expands the column, then posts its state; on a phone, it shows and hides
845
+ the drawer, through the drawer's checkbox, which a click beside the drawer unchecks
846
+ too. The button tells screen readers whether the column, or the drawer, shows; Escape
847
+ hides the drawer. */
848
+ var wideScreen = window.matchMedia("(width >= 64rem)");
849
+ function sidebarDrawer(toggle) {
850
+ return document.getElementById(toggle.dataset.sidebarToggle);
851
+ }
852
+ function syncSidebarToggles() {
853
+ document.querySelectorAll("[data-sidebar-toggle]").forEach(function (toggle) {
854
+ var sidebar = document.getElementById(toggle.getAttribute("aria-controls"));
855
+ var drawer = sidebarDrawer(toggle);
856
+ if (sidebar === null || drawer === null) return;
857
+ var shows = wideScreen.matches ? !sidebar.hasAttribute("data-retracted") : drawer.checked;
858
+ toggle.setAttribute("aria-expanded", String(shows));
859
+ });
860
+ }
861
+ wideScreen.addEventListener("change", syncSidebarToggles);
862
+ document.addEventListener("click", function (event) {
863
+ var toggle = event.target.closest("[data-sidebar-toggle]");
864
+ if (toggle === null) return;
865
+ if (wideScreen.matches) {
866
+ document.getElementById(toggle.getAttribute("aria-controls")).toggleAttribute("data-retracted");
867
+ htmx.trigger(toggle, "sidebar-toggle");
868
+ } else {
869
+ sidebarDrawer(toggle).click();
870
+ }
871
+ syncSidebarToggles();
872
+ });
873
+ document.addEventListener("change", function (event) {
874
+ if (event.target.classList.contains("drawer-toggle")) syncSidebarToggles();
875
+ });
876
+ document.addEventListener("keydown", function (event) {
877
+ if (event.key !== "Escape") return;
878
+ document.querySelectorAll("[data-sidebar-toggle]").forEach(function (toggle) {
879
+ var drawer = sidebarDrawer(toggle);
880
+ if (drawer !== null && drawer.checked) {
881
+ drawer.click();
882
+ toggle.focus();
883
+ }
884
+ });
885
+ });
886
+ /* splitters (see `WebSplitterRenderer`): dragging a border, or the arrow keys on it,
887
+ shares the length of the shown panes beside it between them at once: their widths,
888
+ or their heights across a horizontal border (a vertical splitter's). Each pane's
889
+ share becomes its length in pixels, so they keep it as the window resizes. The
890
+ panes' lengths are posted once the drag ends, or on each key. */
891
+ var PANE_MIN_LENGTH = 48;
892
+ var BORDER_KEY_STEP = 16;
893
+ function isAcross(border) {
894
+ return border.getAttribute("aria-orientation") === "horizontal";
895
+ }
896
+ function paneLength(border, pane) {
897
+ return isAcross(border) ? pane.offsetHeight : pane.offsetWidth;
898
+ }
899
+ function splitterPanes(border) {
900
+ return Array.prototype.filter.call(border.parentElement.children, function (child) {
901
+ return child.hasAttribute("data-pane");
902
+ });
903
+ }
904
+ function paneBeside(border, step) {
905
+ var element = step < 0 ? border.previousElementSibling : border.nextElementSibling;
906
+ while (
907
+ element !== null &&
908
+ !(element.hasAttribute("data-pane") && paneLength(border, element) > 0)
909
+ ) {
910
+ element = step < 0 ? element.previousElementSibling : element.nextElementSibling;
911
+ }
912
+ return element;
913
+ }
914
+ function paneMinimum(border, pane, total) {
915
+ // the pane's own, if larger: side by side, as wide as its text needs to be read
916
+ var style = getComputedStyle(pane);
917
+ var length = parseFloat(isAcross(border) ? style.minHeight : style.minWidth) || 0;
918
+ return Math.min(Math.max(PANE_MIN_LENGTH, length), total / 2);
919
+ }
920
+ function moveBorder(border, start, pixels) {
921
+ var before = paneBeside(border, -1);
922
+ var after = paneBeside(border, 1);
923
+ if (before === null || after === null) return;
924
+ var total = start.before + start.after;
925
+ var beforeLength = Math.min(
926
+ Math.max(start.before + pixels, paneMinimum(border, before, total)),
927
+ total - paneMinimum(border, after, total)
928
+ );
929
+ var panes = splitterPanes(border);
930
+ // every length read before any is written: each write lays the panes out again
931
+ var lengths = panes.map(function (pane) { return paneLength(border, pane); });
932
+ panes.forEach(function (pane, index) {
933
+ if (lengths[index] > 0) pane.style.setProperty("--share", lengths[index] + " 1 0%");
934
+ });
935
+ before.style.setProperty("--share", beforeLength + " 1 0%");
936
+ after.style.setProperty("--share", total - beforeLength + " 1 0%");
937
+ var shown = 0;
938
+ var lengthBefore = 0;
939
+ panes.forEach(function (pane) {
940
+ shown += paneLength(border, pane);
941
+ if (pane.compareDocumentPosition(border) & Node.DOCUMENT_POSITION_FOLLOWING) {
942
+ lengthBefore += paneLength(border, pane);
943
+ }
944
+ });
945
+ border.setAttribute("aria-valuenow", String(Math.round((100 * lengthBefore) / (shown || 1))));
946
+ }
947
+ function postPaneLengths(border) {
948
+ var splitter = border.closest("[data-splitter]");
949
+ htmx.ajax("POST", "/widget/" + splitter.id, {
950
+ source: splitter,
951
+ swap: "none",
952
+ values: {
953
+ event: "resize",
954
+ lengths: splitterPanes(border)
955
+ .map(function (pane) { return paneLength(border, pane); })
956
+ .join(","),
957
+ },
958
+ });
959
+ }
960
+ function borderStart(border) {
961
+ var before = paneBeside(border, -1);
962
+ var after = paneBeside(border, 1);
963
+ return {
964
+ before: before === null ? 0 : paneLength(border, before),
965
+ after: after === null ? 0 : paneLength(border, after),
966
+ };
967
+ }
968
+ document.addEventListener("pointerdown", function (event) {
969
+ var border = event.target.closest(".cui-splitter-border");
970
+ if (border === null || border.getAttribute("aria-disabled") === "true") return;
971
+ if (event.button !== 0) return;
972
+ event.preventDefault(); // no text selected while dragging
973
+ border.focus();
974
+ border.setPointerCapture(event.pointerId);
975
+ border.setAttribute("data-dragging", "");
976
+ var across = isAcross(border);
977
+ var from = across ? event.clientY : event.clientX;
978
+ var start = borderStart(border);
979
+ function move(moveEvent) {
980
+ moveBorder(border, start, (across ? moveEvent.clientY : moveEvent.clientX) - from);
981
+ }
982
+ function end() {
983
+ border.removeEventListener("pointermove", move);
984
+ border.removeEventListener("pointerup", end);
985
+ border.removeEventListener("pointercancel", end);
986
+ border.removeAttribute("data-dragging");
987
+ postPaneLengths(border);
988
+ }
989
+ border.addEventListener("pointermove", move);
990
+ border.addEventListener("pointerup", end);
991
+ border.addEventListener("pointercancel", end);
992
+ });
993
+ document.addEventListener("keydown", function (event) {
994
+ var border = event.target;
995
+ if (!(border instanceof HTMLElement) || !border.classList.contains("cui-splitter-border")) {
996
+ return;
997
+ }
998
+ var steps = isAcross(border)
999
+ ? { ArrowUp: -BORDER_KEY_STEP, ArrowDown: BORDER_KEY_STEP }
1000
+ : { ArrowLeft: -BORDER_KEY_STEP, ArrowRight: BORDER_KEY_STEP };
1001
+ var step = steps[event.key];
1002
+ if (step === undefined || border.getAttribute("aria-disabled") === "true") return;
1003
+ event.preventDefault();
1004
+ moveBorder(border, borderStart(border), step);
1005
+ postPaneLengths(border);
1006
+ });
1007
+ /* tabs (see `WebTabsRenderer`): a click on a tab shows its panel at once, then htmx
1008
+ posts the pick; the arrow keys, Home and End move to a tab that can be picked, and
1009
+ pick it, as an ARIA tab list does. Only the tab shown is in the tab order. */
1010
+ function showTab(tab) {
1011
+ tab.closest('[role="tablist"]').querySelectorAll('[role="tab"]').forEach(function (other) {
1012
+ var isShown = other === tab;
1013
+ other.setAttribute("aria-selected", String(isShown));
1014
+ other.tabIndex = isShown ? 0 : -1;
1015
+ other.classList.toggle("tab-active", isShown);
1016
+ document.getElementById(other.getAttribute("aria-controls")).hidden = !isShown;
1017
+ });
1018
+ }
1019
+ document.addEventListener("click", function (event) {
1020
+ var tab = event.target.closest("[data-tab]");
1021
+ if (tab !== null && !tab.disabled) showTab(tab);
1022
+ });
1023
+ document.addEventListener("keydown", function (event) {
1024
+ var tab = event.target;
1025
+ if (!(tab instanceof HTMLElement) || !("tab" in tab.dataset)) return;
1026
+ var tabs = Array.prototype.filter.call(
1027
+ tab.closest('[role="tablist"]').querySelectorAll('[role="tab"]'),
1028
+ function (other) { return !other.hidden && !other.disabled; },
1029
+ );
1030
+ var index = tabs.indexOf(tab);
1031
+ var next = {
1032
+ ArrowRight: tabs[(index + 1) % tabs.length],
1033
+ ArrowLeft: tabs[(index - 1 + tabs.length) % tabs.length],
1034
+ Home: tabs[0],
1035
+ End: tabs[tabs.length - 1],
1036
+ }[event.key];
1037
+ if (next === undefined) return;
1038
+ event.preventDefault();
1039
+ next.focus();
1040
+ next.click();
1041
+ });
1042
+ /* the page's language, for screen readers and the browser's spell checker, and its
1043
+ title: the server swaps in its marker when the app changes them (see `page_marker`) */
1044
+ function syncPage() {
1045
+ var marker = document.getElementById("cui-page");
1046
+ if (marker === null) return;
1047
+ document.documentElement.lang = marker.dataset.language;
1048
+ if (document.title !== marker.dataset.title) document.title = marker.dataset.title;
1049
+ }
1050
+ /* places a field's help, opened by its "?" button, right of the button, or under it
1051
+ when the window is too narrow (a phone), never over its field: a popover otherwise
1052
+ opens in the window's middle. Placed again once shown, its size then known.
1053
+ `data-side` points its arrow at the button (see `css/app.css`). */
1054
+ function placeHelp(event) {
1055
+ var help = event.target;
1056
+ if (!(help instanceof HTMLElement) || !help.matches("[data-help]")) return;
1057
+ if (event.newState !== "open") return;
1058
+ var button = document.querySelector('[popovertarget="' + help.id + '"]');
1059
+ if (button === null) return;
1060
+ var rect = button.getBoundingClientRect();
1061
+ var gap = 8;
1062
+ help.style.inset = "auto";
1063
+ if (rect.right + gap + help.offsetWidth <= window.innerWidth - gap) {
1064
+ help.dataset.side = "right";
1065
+ help.style.left = rect.right + gap + "px";
1066
+ help.style.top = rect.top + rect.height / 2 - help.offsetHeight / 2 + "px";
1067
+ } else {
1068
+ var left = Math.max(gap, Math.min(rect.left, window.innerWidth - help.offsetWidth - gap));
1069
+ help.dataset.side = "bottom";
1070
+ help.style.left = left + "px";
1071
+ help.style.top = rect.bottom + gap + "px";
1072
+ help.style.setProperty("--arrow-x", rect.left + rect.width / 2 - left + "px");
1073
+ }
1074
+ }
1075
+ /* a large table draws the rows around the view alone (see `WebSourceTableRenderer`): once
1076
+ the user stops scrolling its frame outside them, post the row under the headers and
1077
+ how far down the rows it is, in rows' heights, which the frame's hidden fields take.
1078
+ A filler stands for its rows, scaled past the rows the scroll bar's height stands for:
1079
+ the row is where the scroll position falls among the rows each filler stands for.
1080
+ Scrolling doesn't bubble: captured. */
1081
+ var WINDOW_SCROLL_MS = 100;
1082
+ function fetchWindow(frame) {
1083
+ var rows = frame.querySelectorAll("tr[data-window-row]");
1084
+ if (rows.length === 0) return;
1085
+ var view = frame.getBoundingClientRect();
1086
+ var head = frame.querySelector("thead").getBoundingClientRect().height;
1087
+ var first = rows[0].getBoundingClientRect();
1088
+ var last = rows[rows.length - 1].getBoundingClientRect();
1089
+ if (first.top <= view.top + head && last.bottom >= view.bottom) return; // drawn already
1090
+ var body = frame.querySelector("tbody").getBoundingClientRect();
1091
+ var count = Number(frame.querySelector("table").getAttribute("aria-rowcount")) - 1;
1092
+ var drawn = Number(rows[0].getAttribute("aria-rowindex")) - 2; /* the first row drawn */
1093
+ var at = view.top + head - body.top; /* the rows' offset under the headers */
1094
+ var above = first.top - body.top;
1095
+ var below = last.bottom - body.top;
1096
+ var shown;
1097
+ if (at < above) {
1098
+ shown = (at / above) * drawn;
1099
+ } else if (at < below) {
1100
+ shown = drawn + (at - above) / first.height;
1101
+ } else {
1102
+ var after = count - drawn - rows.length;
1103
+ shown = drawn + rows.length + (body.height > below ? ((at - below) / (body.height - below)) * after : 0);
1104
+ }
1105
+ frame.querySelector("[data-scroll-offset]").value = String(at / first.height);
1106
+ var start = frame.querySelector("[data-scroll-start]");
1107
+ start.value = String(Math.min(Math.max(Math.floor(shown), 0), Math.max(count - 1, 0)));
1108
+ dispatch(start, "scroll-window");
1109
+ }
1110
+ document.addEventListener("scroll", function (event) {
1111
+ var frame = event.target;
1112
+ if (!(frame instanceof HTMLElement) || !("virtualScroll" in frame.dataset)) return;
1113
+ clearTimeout(frame.windowTimer);
1114
+ frame.windowTimer = setTimeout(function () { fetchWindow(frame); }, WINDOW_SCROLL_MS);
1115
+ }, true);
1116
+ /* Ctrl+C in a large table's frame copies its selected rows, unless the user selected text
1117
+ there, which the browser copies: the server answers them in the hidden element posting
1118
+ the copy. Firefox and Safari let the page write the clipboard only during the user's
1119
+ Ctrl+C, before the answer: the clipboard gets an item then, its text a promise the
1120
+ answer keeps (`copyAnswers`). No row selected, it's refused: the clipboard keeps its
1121
+ text. A browser without `ClipboardItem` writes the text once answered. */
1122
+ var copyAnswers = [];
1123
+ document.addEventListener("copy", function (event) {
1124
+ var frame = event.target instanceof Element ? event.target.closest("[data-virtual-scroll]") : null;
1125
+ if (frame === null || !document.getSelection().isCollapsed) return;
1126
+ event.preventDefault();
1127
+ if (window.ClipboardItem && navigator.clipboard && navigator.clipboard.write) {
1128
+ var text = new Promise(function (resolve, reject) {
1129
+ copyAnswers.push({ resolve: resolve, reject: reject });
1130
+ });
1131
+ var item = new ClipboardItem({
1132
+ "text/plain": text.then(function (copied) {
1133
+ return new Blob([copied], { type: "text/plain" });
1134
+ }),
1135
+ });
1136
+ navigator.clipboard.write([item]).catch(function () {}); /* nothing to copy */
1137
+ }
1138
+ dispatch(frame.parentElement.querySelector("[data-copied]"), "copy-rows");
1139
+ });
1140
+ document.addEventListener("htmx:load", function (event) {
1141
+ /* the answer comes on its own, or in the whole table swapped in */
1142
+ var elt = event.detail.elt;
1143
+ var copied = elt.matches("[data-copy-answer]") ? elt : elt.querySelector("[data-copy-answer]");
1144
+ if (copied === null) return;
1145
+ var answer = copyAnswers.shift();
1146
+ var text = copied.textContent;
1147
+ if (answer !== undefined) {
1148
+ if (text !== "") answer.resolve(text);
1149
+ else answer.reject(new Error("no row selected"));
1150
+ } else if (text !== "" && navigator.clipboard) {
1151
+ navigator.clipboard.writeText(text).catch(function () {});
1152
+ }
1153
+ });
1154
+ /* neither bubbles */
1155
+ document.addEventListener("beforetoggle", placeHelp, true);
1156
+ document.addEventListener("toggle", placeHelp, true);
1157
+ document.addEventListener("DOMContentLoaded", function () {
1158
+ openDialogs();
1159
+ syncCharts();
1160
+ syncMaps();
1161
+ loadCalendars();
1162
+ syncSidebarToggles();
1163
+ new MutationObserver(function () {
1164
+ syncInvalidFields();
1165
+ syncSidebarToggles();
1166
+ openDialogs();
1167
+ syncPage();
1168
+ syncCharts();
1169
+ syncMaps();
1170
+ loadCalendars();
1171
+ }).observe(document.body, {
1172
+ childList: true,
1173
+ subtree: true,
1174
+ });
1175
+ });