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.
- composeui/__init__.py +11 -0
- composeui/core/__init__.py +0 -0
- composeui/core/application.py +368 -0
- composeui/core/chartcolors.py +23 -0
- composeui/core/chartimage.py +336 -0
- composeui/core/dataframesource.py +307 -0
- composeui/core/jobs.py +426 -0
- composeui/core/model.py +66 -0
- composeui/core/registry.py +66 -0
- composeui/core/reload.py +476 -0
- composeui/core/renderer.py +193 -0
- composeui/core/signal.py +358 -0
- composeui/core/sqlitemodel.py +1183 -0
- composeui/core/study.py +121 -0
- composeui/core/translation.py +221 -0
- composeui/core/translationcheck.py +254 -0
- composeui/core/view.py +5508 -0
- composeui/images/composeui.svg +15 -0
- composeui/locales/fr.json +73 -0
- composeui/mypy.py +46 -0
- composeui/py.typed +0 -0
- composeui/qtcore/__init__.py +0 -0
- composeui/qtcore/maprenderer.py +115 -0
- composeui/qtcore/qtapplication.py +222 -0
- composeui/qtcore/qtrenderer.py +2862 -0
- composeui/qtcore/widgets/__init__.py +0 -0
- composeui/qtcore/widgets/map.qml +123 -0
- composeui/qtcore/widgets/qchart.py +393 -0
- composeui/qtcore/widgets/qdatefield.py +101 -0
- composeui/qtcore/widgets/qexpander.py +60 -0
- composeui/qtcore/widgets/qfilepathfield.py +34 -0
- composeui/qtcore/widgets/qfiltertable.py +394 -0
- composeui/qtcore/widgets/qformdialog.py +64 -0
- composeui/qtcore/widgets/qformlabel.py +53 -0
- composeui/qtcore/widgets/qmap.py +68 -0
- composeui/qtcore/widgets/qmarkicon.py +37 -0
- composeui/qtcore/widgets/qmetric.py +44 -0
- composeui/qtcore/widgets/qplaintext.py +29 -0
- composeui/qtcore/widgets/qradiogroup.py +57 -0
- composeui/qtcore/widgets/qsidebar.py +116 -0
- composeui/qtcore/widgets/qsourcetable.py +216 -0
- composeui/qtcore/widgets/qsplitter.py +69 -0
- composeui/qtcore/widgets/qtexttable.py +703 -0
- composeui/qtcore/widgets/qvaluecombobox.py +41 -0
- composeui/qtcore/widgets/qvalueslider.py +64 -0
- composeui/qtcore/widgets/qwindowrelease.py +32 -0
- composeui/textualcore/__init__.py +0 -0
- composeui/textualcore/textualapplication.py +121 -0
- composeui/textualcore/textualrenderer.py +2893 -0
- composeui/textualcore/widgets/__init__.py +0 -0
- composeui/textualcore/widgets/composebutton.py +17 -0
- composeui/textualcore/widgets/composechart.py +212 -0
- composeui/textualcore/widgets/composecheckbox.py +35 -0
- composeui/textualcore/widgets/composecollapsible.py +41 -0
- composeui/textualcore/widgets/composecombobox.py +54 -0
- composeui/textualcore/widgets/composedateinput.py +325 -0
- composeui/textualcore/widgets/composedialog.py +90 -0
- composeui/textualcore/widgets/composefilepathinput.py +85 -0
- composeui/textualcore/widgets/composefiltertable.py +367 -0
- composeui/textualcore/widgets/composeformdialog.py +86 -0
- composeui/textualcore/widgets/composeinput.py +50 -0
- composeui/textualcore/widgets/composelist.py +20 -0
- composeui/textualcore/widgets/composemarkdown.py +17 -0
- composeui/textualcore/widgets/composemetric.py +60 -0
- composeui/textualcore/widgets/composeradioset.py +54 -0
- composeui/textualcore/widgets/composesidebar.py +111 -0
- composeui/textualcore/widgets/composeslider.py +144 -0
- composeui/textualcore/widgets/composesourcetable.py +288 -0
- composeui/textualcore/widgets/composesplitter.py +211 -0
- composeui/textualcore/widgets/composetabbedcontent.py +35 -0
- composeui/textualcore/widgets/composetable.py +476 -0
- composeui/textualcore/widgets/composetoolbar.py +62 -0
- composeui/textualcore/widgets/composetree.py +139 -0
- composeui/webcore/__init__.py +0 -0
- composeui/webcore/headers.py +57 -0
- composeui/webcore/icons.py +58 -0
- composeui/webcore/limits.py +55 -0
- composeui/webcore/origin.py +63 -0
- composeui/webcore/page.py +146 -0
- composeui/webcore/routes.py +303 -0
- composeui/webcore/session.py +436 -0
- composeui/webcore/static/app.css +2 -0
- composeui/webcore/static/cally.js +1312 -0
- composeui/webcore/static/daisyui.LICENSE +21 -0
- composeui/webcore/static/deckgl.LICENSE +19 -0
- composeui/webcore/static/deckgl.min.js +6423 -0
- composeui/webcore/static/htmx.min.js +1 -0
- composeui/webcore/static/page.js +1175 -0
- composeui/webcore/static/uPlot.LICENSE +21 -0
- composeui/webcore/static/uPlot.iife.min.js +2 -0
- composeui/webcore/webapplication.py +495 -0
- composeui/webcore/webrenderer.py +5113 -0
- composeui-0.1.0.dist-info/METADATA +222 -0
- composeui-0.1.0.dist-info/RECORD +97 -0
- composeui-0.1.0.dist-info/WHEEL +5 -0
- composeui-0.1.0.dist-info/licenses/LICENSE +9 -0
- 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
|
+
});
|