@yuneta/gobj-ui 3.0.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +324 -6
- package/dist/gobj-ui.cjs.js +10891 -5006
- package/dist/gobj-ui.es.js +10915 -5064
- package/index.js +45 -1
- package/package.json +4 -4
- package/src/c_g6_nodes_tree.js +128 -40
- package/src/c_yui_form.js +112 -53
- package/src/c_yui_gobj_tree_js.js +58 -36
- package/src/c_yui_json.css +139 -0
- package/src/c_yui_json.js +928 -0
- package/src/c_yui_json_graph.js +110 -20
- package/src/c_yui_map.js +18 -36
- package/src/c_yui_nav.js +6 -9
- package/src/c_yui_period.css +184 -0
- package/src/c_yui_period.js +1441 -0
- package/src/c_yui_shell.css +33 -0
- package/src/c_yui_shell.js +672 -111
- package/src/c_yui_treedb_graph.js +639 -37
- package/src/c_yui_treedb_schema.js +478 -0
- package/src/c_yui_treedb_topic_with_form.css +7 -42
- package/src/c_yui_treedb_topic_with_form.js +152 -103
- package/src/c_yui_treedb_topics.css +73 -0
- package/src/c_yui_treedb_topics.js +1070 -29
- package/src/c_yui_window.js +180 -97
- package/src/c_yui_window_manager.js +38 -4
- package/src/json_view_helpers.js +214 -0
- package/src/json_view_helpers.test.js +135 -0
- package/src/route_map_model.js +317 -0
- package/src/route_map_model.test.js +209 -0
- package/src/route_resolver.js +24 -1
- package/src/route_resolver.test.js +40 -1
- package/src/shell_modals.js +117 -39
- package/src/shell_route_map.css +225 -0
- package/src/shell_route_map.js +363 -0
- package/src/tabulator.css +67 -0
- package/src/yui_dev.js +130 -8
- package/src/yui_frontend_view.js +106 -0
- package/src/yui_icons.css +62 -0
- package/src/yui_inputs.css +8 -3
- package/src/yui_inputs.js +51 -8
- package/src/yui_tabulator_i18n.js +128 -0
- package/src/yui_theme.js +147 -0
- package/src/yui_time.js +666 -0
- package/src/yui_time.test.js +330 -0
- package/src/yui_toolbar.js +86 -43
|
@@ -0,0 +1,1441 @@
|
|
|
1
|
+
/***********************************************************************
|
|
2
|
+
* c_yui_period.js
|
|
3
|
+
*
|
|
4
|
+
* The date navigator: pick a GRANULARITY, then walk it.
|
|
5
|
+
*
|
|
6
|
+
* [ All | Hour | Day | Week | Year | ⋯ | Custom ]
|
|
7
|
+
* |< < Week 27 > >|
|
|
8
|
+
* 2026-07-13 00:00:00 → 2026-07-19 23:59:59
|
|
9
|
+
*
|
|
10
|
+
* Two rows, and the second one is the point: once the user says "week",
|
|
11
|
+
* moving through time is ONE tap — no calendar, no typing two
|
|
12
|
+
* timestamps that must agree with each other. The calendar is still
|
|
13
|
+
* there (the label opens it) for the jump that is not one step away.
|
|
14
|
+
*
|
|
15
|
+
* Container-agnostic, like C_YUI_PAGER: the gclass owns its chrome and
|
|
16
|
+
* the parent mounts `gobj_read_attr(period, "$container")` wherever it
|
|
17
|
+
* wants — a modal, a toolbar, a card head.
|
|
18
|
+
*
|
|
19
|
+
* WHAT IT KNOWS NOTHING ABOUT is what a period is: that lives in
|
|
20
|
+
* yui_time.js as (unit, count), so an app that wants quarters, semesters,
|
|
21
|
+
* bimesters or 15-minute buckets DECLARES them:
|
|
22
|
+
*
|
|
23
|
+
* gobj_create("period", C_YUI_PERIOD, {
|
|
24
|
+
* periods: ["hour", "day", "week", "month", "year"],
|
|
25
|
+
* more_periods: ["bimester", "quarter", "semester", "decade"],
|
|
26
|
+
* rolling: ["1h", "24h", "7d"],
|
|
27
|
+
* with_span: true,
|
|
28
|
+
* with_custom: true,
|
|
29
|
+
* ms: false // the consumer's time unit
|
|
30
|
+
* }, parent);
|
|
31
|
+
*
|
|
32
|
+
* and gets the arrows, the labels and the calendar for free.
|
|
33
|
+
*
|
|
34
|
+
* It publishes ONE event:
|
|
35
|
+
*
|
|
36
|
+
* EV_PERIOD_CHANGED {mode, anchor, from, to}
|
|
37
|
+
*
|
|
38
|
+
* `from`/`to` are in the CONSUMER's unit (seconds, or milliseconds when
|
|
39
|
+
* `ms`), 0 meaning "unbounded" — which is exactly how a match condition
|
|
40
|
+
* reads a missing end, so a query builder can forward them untouched.
|
|
41
|
+
* A rolling window ("last 24h") deliberately leaves `to` at 0: pinning
|
|
42
|
+
* it to the instant of the click would freeze a live card.
|
|
43
|
+
*
|
|
44
|
+
* Modes that are NOT buckets — "span" (everything), "custom" (the host's
|
|
45
|
+
* own from/to inputs, mounted in the optional `$custom` slot) and the
|
|
46
|
+
* rolling windows — live in ST_FLAT, where the arrows do not exist. That
|
|
47
|
+
* is why they are a STATE and not an `if`: an EV_NEXT arriving there is
|
|
48
|
+
* a bug in the caller, and the FSM says so out loud instead of quietly
|
|
49
|
+
* doing nothing.
|
|
50
|
+
*
|
|
51
|
+
* Copyright (c) 2026, ArtGins.
|
|
52
|
+
* All Rights Reserved.
|
|
53
|
+
***********************************************************************/
|
|
54
|
+
import {
|
|
55
|
+
SDATA,
|
|
56
|
+
SDATA_END,
|
|
57
|
+
data_type_t,
|
|
58
|
+
event_flag_t,
|
|
59
|
+
gclass_create,
|
|
60
|
+
log_error,
|
|
61
|
+
createElement2,
|
|
62
|
+
gobj_read_attr,
|
|
63
|
+
gobj_write_attr,
|
|
64
|
+
gobj_read_pointer_attr,
|
|
65
|
+
gobj_read_bool_attr,
|
|
66
|
+
gobj_read_integer_attr,
|
|
67
|
+
gobj_parent,
|
|
68
|
+
gobj_subscribe_event,
|
|
69
|
+
gobj_unsubscribe_event,
|
|
70
|
+
gobj_send_event,
|
|
71
|
+
gobj_publish_event,
|
|
72
|
+
gobj_change_state,
|
|
73
|
+
gobj_short_name,
|
|
74
|
+
} from "@yuneta/gobj-js";
|
|
75
|
+
|
|
76
|
+
import {yui_shell_of} from "./c_yui_shell.js";
|
|
77
|
+
|
|
78
|
+
import {
|
|
79
|
+
YUI_PERIODS_DEFAULT,
|
|
80
|
+
YUI_ROLLING,
|
|
81
|
+
safe_locale,
|
|
82
|
+
epoch_to_ms,
|
|
83
|
+
period_spec,
|
|
84
|
+
period_start,
|
|
85
|
+
period_shift,
|
|
86
|
+
period_bounds,
|
|
87
|
+
period_bounds_epoch,
|
|
88
|
+
rolling_bounds,
|
|
89
|
+
fmt_epoch,
|
|
90
|
+
period_name,
|
|
91
|
+
period_label,
|
|
92
|
+
day_number,
|
|
93
|
+
start_of_iso_week,
|
|
94
|
+
iso_week,
|
|
95
|
+
} from "./yui_time.js";
|
|
96
|
+
|
|
97
|
+
import i18next, {t} from "i18next";
|
|
98
|
+
|
|
99
|
+
import "./c_yui_period.css";
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
/***************************************************************
|
|
103
|
+
* Constants
|
|
104
|
+
***************************************************************/
|
|
105
|
+
const GCLASS_NAME = "C_YUI_PERIOD";
|
|
106
|
+
|
|
107
|
+
/* The modes with no bucket behind them. They share ST_FLAT. */
|
|
108
|
+
const MODE_SPAN = "span";
|
|
109
|
+
const MODE_CUSTOM = "custom";
|
|
110
|
+
|
|
111
|
+
/***************************************************************
|
|
112
|
+
* Data
|
|
113
|
+
***************************************************************/
|
|
114
|
+
const attrs_table = [
|
|
115
|
+
SDATA(data_type_t.DTP_POINTER, "subscriber", 0, null, "Subscriber of output events"),
|
|
116
|
+
|
|
117
|
+
SDATA(data_type_t.DTP_JSON, "periods", 0, null, "Granularities in the control: ids of yui_time's catalog, or specs {id,unit,count}"),
|
|
118
|
+
SDATA(data_type_t.DTP_JSON, "more_periods", 0, null, "Granularities behind the overflow menu (quarter, semester, …)"),
|
|
119
|
+
SDATA(data_type_t.DTP_JSON, "rolling", 0, null, "Rolling windows offered as modes: ids of yui_time's YUI_ROLLING"),
|
|
120
|
+
SDATA(data_type_t.DTP_BOOLEAN, "with_span", 0, false, "Offer a 'span' mode: no bounds at all"),
|
|
121
|
+
SDATA(data_type_t.DTP_BOOLEAN, "with_custom", 0, false, "Offer a 'custom' mode: reveals the $custom slot"),
|
|
122
|
+
SDATA(data_type_t.DTP_BOOLEAN, "with_resolved",0, true, "Print the two timestamps the current mode resolves to"),
|
|
123
|
+
|
|
124
|
+
SDATA(data_type_t.DTP_STRING, "mode", 0, "", "Current mode: a period id, 'span', 'custom' or a rolling id"),
|
|
125
|
+
SDATA(data_type_t.DTP_INTEGER, "anchor", 0, 0, "Instant the current bucket holds, in MILLISECONDS (0 = now)"),
|
|
126
|
+
SDATA(data_type_t.DTP_BOOLEAN, "ms", 0, false, "The CONSUMER keeps its timestamps in milliseconds (else seconds)"),
|
|
127
|
+
SDATA(data_type_t.DTP_INTEGER, "min", 0, 0, "Oldest instant the data holds, in the consumer's unit (0 = unknown)"),
|
|
128
|
+
SDATA(data_type_t.DTP_INTEGER, "max", 0, 0, "Newest instant the data holds, in the consumer's unit (0 = unknown)"),
|
|
129
|
+
|
|
130
|
+
/* OUTPUT, read-only: the bounds the current mode asks for, in the
|
|
131
|
+
* consumer's unit, 0 = unbounded. Kept in sync with every change, so a
|
|
132
|
+
* host that reads the picker when the user confirms a dialog (instead of
|
|
133
|
+
* reacting to EV_PERIOD_CHANGED) needs no accessor of its own. */
|
|
134
|
+
SDATA(data_type_t.DTP_INTEGER, "from", 0, 0, "Current lower bound (read-only output)"),
|
|
135
|
+
SDATA(data_type_t.DTP_INTEGER, "to", 0, 0, "Current upper bound, inclusive (read-only output)"),
|
|
136
|
+
|
|
137
|
+
/*---------------- UI ----------------*/
|
|
138
|
+
SDATA(data_type_t.DTP_POINTER, "$container", 0, null, "HTMLElement root, mounted by the parent"),
|
|
139
|
+
SDATA(data_type_t.DTP_POINTER, "$custom", 0, null, "HTMLElement of the host's own from/to block, shown in 'custom' mode"),
|
|
140
|
+
SDATA_END()
|
|
141
|
+
];
|
|
142
|
+
|
|
143
|
+
let PRIVATE_DATA = {
|
|
144
|
+
modes: null, /* every mode of the control, in order */
|
|
145
|
+
$modes: null,
|
|
146
|
+
$more: null,
|
|
147
|
+
$nav: null,
|
|
148
|
+
$first: null,
|
|
149
|
+
$prev: null,
|
|
150
|
+
$next: null,
|
|
151
|
+
$latest: null,
|
|
152
|
+
$label: null,
|
|
153
|
+
$resolved: null,
|
|
154
|
+
$calendar: null, /* the popover, null when closed */
|
|
155
|
+
cal_view: 0, /* the month/year the popover is BROWSING */
|
|
156
|
+
on_dismiss: null, /* document listener that closes the popover */
|
|
157
|
+
on_more_dismiss: null, /* same, for the overflow menu */
|
|
158
|
+
modes_ro: null /* ResizeObserver re-checking the strip's fades */
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
let __gclass__ = null;
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
/******************************
|
|
167
|
+
* Framework Methods
|
|
168
|
+
******************************/
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
/***************************************************************
|
|
174
|
+
* Framework Method: Create
|
|
175
|
+
***************************************************************/
|
|
176
|
+
function mt_create(gobj)
|
|
177
|
+
{
|
|
178
|
+
let priv = gobj.priv;
|
|
179
|
+
|
|
180
|
+
priv.modes = collect_modes(gobj);
|
|
181
|
+
|
|
182
|
+
if(!gobj_read_attr(gobj, "mode")) {
|
|
183
|
+
/* No mode asked for: the first one of the control. */
|
|
184
|
+
gobj_write_attr(gobj, "mode", priv.modes.length? priv.modes[0].id : MODE_SPAN);
|
|
185
|
+
}
|
|
186
|
+
if(!gobj_read_integer_attr(gobj, "anchor")) {
|
|
187
|
+
gobj_write_attr(gobj, "anchor", Date.now());
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
build_ui(gobj);
|
|
191
|
+
|
|
192
|
+
/*
|
|
193
|
+
* CHILD subscription model
|
|
194
|
+
*/
|
|
195
|
+
let subscriber = gobj_read_pointer_attr(gobj, "subscriber");
|
|
196
|
+
if(!subscriber) {
|
|
197
|
+
subscriber = gobj_parent(gobj);
|
|
198
|
+
}
|
|
199
|
+
gobj_subscribe_event(gobj, null, {}, subscriber);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/***************************************************************
|
|
203
|
+
* Framework Method: Start
|
|
204
|
+
***************************************************************/
|
|
205
|
+
function mt_start(gobj)
|
|
206
|
+
{
|
|
207
|
+
let priv = gobj.priv;
|
|
208
|
+
let mode = cur_mode(gobj);
|
|
209
|
+
|
|
210
|
+
/* The FSM starts in the first state declared; the mode we start ON
|
|
211
|
+
* decides whether that is where we belong. */
|
|
212
|
+
gobj_change_state(gobj, (mode && mode.kind === "bucket")? "ST_BUCKET" : "ST_FLAT");
|
|
213
|
+
|
|
214
|
+
/* The composed labels ("Week 27", month names) are t()-built at render
|
|
215
|
+
* time, so a language switch must repaint them. Subscribe to the shell
|
|
216
|
+
* DIRECTLY, like C_YUI_TREEDB_TOPIC_WITH_FORM: a host that mounts a
|
|
217
|
+
* bare picker gets the repaint without having to forward the event
|
|
218
|
+
* (a host that forwards anyway just repaints twice, harmlessly). */
|
|
219
|
+
let shell = yui_shell_of(gobj);
|
|
220
|
+
if(shell) {
|
|
221
|
+
gobj_subscribe_event(shell, "EV_LANGUAGE_CHANGED", {}, gobj);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/* The strip's overflow depends on the width the PARENT gives it, which
|
|
225
|
+
* is unknown until mounted — and changes with the viewport and with the
|
|
226
|
+
* language. Observe it instead of guessing. */
|
|
227
|
+
if(typeof ResizeObserver !== "undefined") {
|
|
228
|
+
priv.modes_ro = new ResizeObserver(() => {
|
|
229
|
+
update_modes_fade(gobj);
|
|
230
|
+
});
|
|
231
|
+
priv.modes_ro.observe(priv.$modes);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/* Bounds, but NO event: nobody asked for anything yet, and a host
|
|
235
|
+
* that fires a query on every EV_PERIOD_CHANGED would run one before
|
|
236
|
+
* the user touched the picker. */
|
|
237
|
+
refresh_bounds(gobj);
|
|
238
|
+
repaint(gobj);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/***************************************************************
|
|
242
|
+
* Framework Method: Stop
|
|
243
|
+
***************************************************************/
|
|
244
|
+
function mt_stop(gobj)
|
|
245
|
+
{
|
|
246
|
+
let priv = gobj.priv;
|
|
247
|
+
|
|
248
|
+
let shell = yui_shell_of(gobj);
|
|
249
|
+
if(shell) {
|
|
250
|
+
gobj_unsubscribe_event(shell, "EV_LANGUAGE_CHANGED", {}, gobj);
|
|
251
|
+
}
|
|
252
|
+
if(priv.modes_ro) {
|
|
253
|
+
priv.modes_ro.disconnect();
|
|
254
|
+
priv.modes_ro = null;
|
|
255
|
+
}
|
|
256
|
+
close_calendar(gobj);
|
|
257
|
+
close_more(gobj);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/***************************************************************
|
|
261
|
+
* Framework Method: Destroy
|
|
262
|
+
***************************************************************/
|
|
263
|
+
function mt_destroy(gobj)
|
|
264
|
+
{
|
|
265
|
+
close_calendar(gobj);
|
|
266
|
+
close_more(gobj);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
/***************************
|
|
273
|
+
* Local Methods
|
|
274
|
+
***************************/
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
/***************************************************************
|
|
280
|
+
* Every mode the control offers, in the order it shows them:
|
|
281
|
+
* span, the buckets, the rolling windows, the overflow ones, custom.
|
|
282
|
+
*
|
|
283
|
+
* A mode is {id, kind, spec}: `kind` is what the FSM cares about
|
|
284
|
+
* ("bucket" walks, everything else is flat), `spec` is the (unit,count)
|
|
285
|
+
* of a bucket and null for the rest.
|
|
286
|
+
***************************************************************/
|
|
287
|
+
function collect_modes(gobj)
|
|
288
|
+
{
|
|
289
|
+
let modes = [];
|
|
290
|
+
|
|
291
|
+
if(gobj_read_bool_attr(gobj, "with_span")) {
|
|
292
|
+
modes.push({id: MODE_SPAN, kind: "flat", spec: null, overflow: false});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
let add_buckets = (list, overflow) => {
|
|
296
|
+
for(let p of (list || [])) {
|
|
297
|
+
let spec = period_spec(p);
|
|
298
|
+
if(!spec) {
|
|
299
|
+
log_error(`${gobj_short_name(gobj)}: not a period: ${JSON.stringify(p)}`);
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
modes.push({id: spec.id, kind: "bucket", spec: spec, overflow: overflow});
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
let periods = gobj_read_attr(gobj, "periods");
|
|
307
|
+
add_buckets((periods && periods.length)? periods : YUI_PERIODS_DEFAULT, false);
|
|
308
|
+
|
|
309
|
+
for(let r of (gobj_read_attr(gobj, "rolling") || [])) {
|
|
310
|
+
let roll = (typeof r === "string")? YUI_ROLLING[r] : r;
|
|
311
|
+
if(!roll || !roll.secs) {
|
|
312
|
+
log_error(`${gobj_short_name(gobj)}: not a rolling window: ${JSON.stringify(r)}`);
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
modes.push({id: roll.id, kind: "rolling", spec: null, roll: roll, overflow: false});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
add_buckets(gobj_read_attr(gobj, "more_periods"), true);
|
|
319
|
+
|
|
320
|
+
/* "custom" is always a valid STATE, even when it gets no button: a range
|
|
321
|
+
* that matches no bucket — typed by hand, or restored from a saved view —
|
|
322
|
+
* has to be representable, and falling back to another mode would claim
|
|
323
|
+
* the user asked for something they did not ("All" highlighted while the
|
|
324
|
+
* query carries a week). `with_custom` decides whether it is also
|
|
325
|
+
* OFFERED; without a button it is simply the state where no granularity
|
|
326
|
+
* is lit and the arrows are dead. */
|
|
327
|
+
modes.push({
|
|
328
|
+
id: MODE_CUSTOM,
|
|
329
|
+
kind: "flat",
|
|
330
|
+
spec: null,
|
|
331
|
+
overflow: false,
|
|
332
|
+
hidden: !gobj_read_bool_attr(gobj, "with_custom")
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
return modes;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function find_mode(gobj, id)
|
|
339
|
+
{
|
|
340
|
+
for(let m of gobj.priv.modes) {
|
|
341
|
+
if(m.id === id) {
|
|
342
|
+
return m;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function cur_mode(gobj)
|
|
349
|
+
{
|
|
350
|
+
return find_mode(gobj, gobj_read_attr(gobj, "mode"));
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/***************************************************************
|
|
354
|
+
* The locale the picker FORMATS with: the app's language — the one
|
|
355
|
+
* t() answers in — so "Semana" never sits next to "July 2026" (a
|
|
356
|
+
* browser in one language under a UI switched to another mixes the
|
|
357
|
+
* two otherwise). safe_locale falls back to the browser's when
|
|
358
|
+
* i18next has no language yet, and guards Firefox's literal
|
|
359
|
+
* "undefined" string.
|
|
360
|
+
***************************************************************/
|
|
361
|
+
function ui_locale()
|
|
362
|
+
{
|
|
363
|
+
return safe_locale(i18next.language);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/***************************************************************
|
|
367
|
+
* The i18n name of a mode, for the segmented control.
|
|
368
|
+
***************************************************************/
|
|
369
|
+
function mode_name(mode)
|
|
370
|
+
{
|
|
371
|
+
if(!mode) {
|
|
372
|
+
return "";
|
|
373
|
+
}
|
|
374
|
+
if(mode.kind === "rolling") {
|
|
375
|
+
return t(mode.roll.label || mode.id);
|
|
376
|
+
}
|
|
377
|
+
if(mode.kind === "bucket") {
|
|
378
|
+
return period_name(mode.spec, t);
|
|
379
|
+
}
|
|
380
|
+
return t(mode.id); /* span / custom */
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/***************************************************************
|
|
384
|
+
* The newest instant the navigator may reach, in MILLISECONDS.
|
|
385
|
+
*
|
|
386
|
+
* `now`, unless the data ends earlier: walking a key that stops in
|
|
387
|
+
* march into "next month" ten times only paints empty buckets, and the
|
|
388
|
+
* "latest" arrow would land nowhere near the last record.
|
|
389
|
+
***************************************************************/
|
|
390
|
+
function limit_ms(gobj)
|
|
391
|
+
{
|
|
392
|
+
let now = Date.now();
|
|
393
|
+
let max = gobj_read_integer_attr(gobj, "max");
|
|
394
|
+
if(!max) {
|
|
395
|
+
return now;
|
|
396
|
+
}
|
|
397
|
+
let max_ms = epoch_to_ms(max, gobj_read_bool_attr(gobj, "ms"));
|
|
398
|
+
return Math.min(max_ms, now);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/***************************************************************
|
|
402
|
+
* The bounds the current mode asks for, in the CONSUMER's unit
|
|
403
|
+
* (0 = unbounded). This is the whole payload of EV_PERIOD_CHANGED.
|
|
404
|
+
***************************************************************/
|
|
405
|
+
function cur_bounds(gobj)
|
|
406
|
+
{
|
|
407
|
+
let mode = cur_mode(gobj);
|
|
408
|
+
let ms = gobj_read_bool_attr(gobj, "ms");
|
|
409
|
+
|
|
410
|
+
if(!mode || mode.id === MODE_SPAN) {
|
|
411
|
+
return {from: 0, to: 0};
|
|
412
|
+
}
|
|
413
|
+
if(mode.kind === "rolling") {
|
|
414
|
+
return rolling_bounds(mode.roll, ms);
|
|
415
|
+
}
|
|
416
|
+
if(mode.kind === "bucket") {
|
|
417
|
+
return period_bounds_epoch(mode.spec, gobj_read_integer_attr(gobj, "anchor"), ms);
|
|
418
|
+
}
|
|
419
|
+
/* custom: the host owns the values in its $custom slot; the picker
|
|
420
|
+
* claims no bounds of its own. */
|
|
421
|
+
return {from: 0, to: 0};
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/***************************************************************
|
|
425
|
+
* Publish the bounds as ATTRS, for the host that reads the picker when
|
|
426
|
+
* its dialog is confirmed instead of reacting to every keystroke of the
|
|
427
|
+
* navigator.
|
|
428
|
+
***************************************************************/
|
|
429
|
+
function refresh_bounds(gobj)
|
|
430
|
+
{
|
|
431
|
+
let b = cur_bounds(gobj);
|
|
432
|
+
gobj_write_attr(gobj, "from", b.from);
|
|
433
|
+
gobj_write_attr(gobj, "to", b.to);
|
|
434
|
+
return b;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/***************************************************************
|
|
438
|
+
* Tell the world where we are. The kw is plain JSON on purpose (the
|
|
439
|
+
* machine trace dumps it, and a DOM node in there would break the very
|
|
440
|
+
* trace the FSM exists to feed).
|
|
441
|
+
***************************************************************/
|
|
442
|
+
function publish_period(gobj)
|
|
443
|
+
{
|
|
444
|
+
let b = refresh_bounds(gobj);
|
|
445
|
+
gobj_publish_event(gobj, "EV_PERIOD_CHANGED", {
|
|
446
|
+
mode: gobj_read_attr(gobj, "mode"),
|
|
447
|
+
anchor: gobj_read_integer_attr(gobj, "anchor"),
|
|
448
|
+
from: b.from,
|
|
449
|
+
to: b.to
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
/*---------------------------------------------*
|
|
455
|
+
* UI
|
|
456
|
+
*---------------------------------------------*/
|
|
457
|
+
|
|
458
|
+
/***************************************************************
|
|
459
|
+
* The chrome, built once: the segmented control, the overflow menu
|
|
460
|
+
* and the navigator. What CHANGES on every mode/anchor lives in
|
|
461
|
+
* repaint().
|
|
462
|
+
***************************************************************/
|
|
463
|
+
function build_ui(gobj)
|
|
464
|
+
{
|
|
465
|
+
let priv = gobj.priv;
|
|
466
|
+
|
|
467
|
+
priv.$modes = createElement2(
|
|
468
|
+
["div", {class: "buttons has-addons is-flex-wrap-nowrap mb-0 YUI_PERIOD_MODES"}, []]);
|
|
469
|
+
|
|
470
|
+
for(let mode of priv.modes) {
|
|
471
|
+
if(mode.overflow || mode.hidden) {
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
priv.$modes.appendChild(build_mode_button(gobj, mode));
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/* Scroll plumbing, not an action: it changes nothing outside the strip.
|
|
478
|
+
* The fade classes are the "there is more" hint a 4px scrollbar fails
|
|
479
|
+
* to give on a phone. */
|
|
480
|
+
priv.$modes.addEventListener("scroll", () => {
|
|
481
|
+
update_modes_fade(gobj);
|
|
482
|
+
}, {passive: true});
|
|
483
|
+
|
|
484
|
+
/* The strip SCROLLS when the granularities do not fit (see the css), and
|
|
485
|
+
* a scrolling box CLIPS what hangs out of it — so the overflow menu is a
|
|
486
|
+
* sibling of the strip, not a member of it. */
|
|
487
|
+
let $modes_row = createElement2(
|
|
488
|
+
["div", {class: "is-flex is-align-items-center mb-0 YUI_PERIOD_MODES_ROW"},
|
|
489
|
+
[priv.$modes]]);
|
|
490
|
+
|
|
491
|
+
if(priv.modes.some((m) => m.overflow)) {
|
|
492
|
+
$modes_row.appendChild(build_more_menu(gobj));
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/* |< < LABEL > >| — the two ends and the two steps. The ends are
|
|
496
|
+
* what makes a long key navigable at all: the oldest record of a topic
|
|
497
|
+
* can be years back, and stepping there one bucket at a time is not a
|
|
498
|
+
* navigation, it is a punishment. */
|
|
499
|
+
priv.$first = nav_button(gobj, "YUI_PERIOD_FIRST", "yi-backward-step", "oldest period",
|
|
500
|
+
() => gobj_send_event(gobj, "EV_FIRST", {}, gobj));
|
|
501
|
+
priv.$prev = nav_button(gobj, "YUI_PERIOD_PREV", "yi-chevron-left", "previous period",
|
|
502
|
+
() => gobj_send_event(gobj, "EV_PREV", {}, gobj));
|
|
503
|
+
priv.$next = nav_button(gobj, "YUI_PERIOD_NEXT", "yi-chevron-right", "next period",
|
|
504
|
+
() => gobj_send_event(gobj, "EV_NEXT", {}, gobj));
|
|
505
|
+
priv.$latest = nav_button(gobj, "YUI_PERIOD_LATEST", "yi-forward-step", "latest period",
|
|
506
|
+
() => gobj_send_event(gobj, "EV_LATEST", {}, gobj));
|
|
507
|
+
|
|
508
|
+
/* The label is the biggest thing on screen and it is a BUTTON: it says
|
|
509
|
+
* where you are ("Yesterday", "Week 27", "July") and it opens the
|
|
510
|
+
* calendar. Everything else in the row is chrome around it. */
|
|
511
|
+
/* The small calendar glyph is the AFFORDANCE: nothing else says "this
|
|
512
|
+
* opens a calendar" before the first click — desktop has a tooltip,
|
|
513
|
+
* a phone has neither hover nor title. */
|
|
514
|
+
priv.$label = createElement2(
|
|
515
|
+
["button", {class: "button is-ghost YUI_PERIOD_LABEL", type: "button",
|
|
516
|
+
title: t("pick a date"), "aria-label": t("pick a date"),
|
|
517
|
+
"data-i18n-title": "pick a date", "data-i18n-aria-label": "pick a date"},
|
|
518
|
+
[["span", {class: "icon YUI_PERIOD_LABEL_ICON"},
|
|
519
|
+
[["i", {class: "yi-calendar-days"}]]],
|
|
520
|
+
["span", {class: "YUI_PERIOD_LABEL_TEXT"}, ""]]
|
|
521
|
+
]);
|
|
522
|
+
priv.$label.addEventListener("click", () => {
|
|
523
|
+
gobj_send_event(gobj, "EV_PICK_DATE", {}, gobj);
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
/* What the label RESOLVES to. A name is for the user; the two timestamps
|
|
527
|
+
* below it are what the query actually carries, and the user is entitled
|
|
528
|
+
* to see them before asking for them. */
|
|
529
|
+
/* A host that puts the resolved range in its OWN (editable) inputs asks
|
|
530
|
+
* for it to be left out — the same two timestamps twice, one of them
|
|
531
|
+
* read-only, is noise. */
|
|
532
|
+
if(gobj_read_bool_attr(gobj, "with_resolved")) {
|
|
533
|
+
priv.$resolved = createElement2(
|
|
534
|
+
["p", {class: "has-text-centered is-family-monospace YUI_PERIOD_RESOLVED"}, ""]);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/* NO `is-flex` here, and that is not a style choice: `.is-flex` and
|
|
538
|
+
* `.is-hidden` are BOTH `!important` in Bulma, and is-flex wins — the
|
|
539
|
+
* navigator stayed on screen in the modes that have nothing to walk,
|
|
540
|
+
* offering arrows for a period that did not exist. The row is laid out
|
|
541
|
+
* from the css instead (no !important), so `is-hidden` can win. */
|
|
542
|
+
priv.$nav = createElement2(
|
|
543
|
+
["div", {class: "mt-2 YUI_PERIOD_NAV"},
|
|
544
|
+
[priv.$first, priv.$prev, priv.$label, priv.$next, priv.$latest]]);
|
|
545
|
+
|
|
546
|
+
let $container = createElement2(
|
|
547
|
+
["div", {class: `${GCLASS_NAME} YUI_PERIOD`}, [$modes_row, priv.$nav]]);
|
|
548
|
+
if(priv.$resolved) {
|
|
549
|
+
$container.appendChild(priv.$resolved);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
let $custom = gobj_read_pointer_attr(gobj, "$custom");
|
|
553
|
+
if($custom) {
|
|
554
|
+
$container.appendChild($custom);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
gobj_write_attr(gobj, "$container", $container);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function build_mode_button(gobj, mode)
|
|
561
|
+
{
|
|
562
|
+
let $btn = createElement2(
|
|
563
|
+
["button", {class: `button YUI_PERIOD_MODE YUI_PERIOD_MODE_${mode.id.toUpperCase()}`,
|
|
564
|
+
type: "button"},
|
|
565
|
+
[["span", {class: "YUI_PERIOD_MODE_TEXT"}, mode_name(mode)]]
|
|
566
|
+
]);
|
|
567
|
+
$btn.addEventListener("click", () => {
|
|
568
|
+
gobj_send_event(gobj, "EV_SET_MODE", {mode: mode.id}, gobj);
|
|
569
|
+
});
|
|
570
|
+
mode.$btn = $btn;
|
|
571
|
+
return $btn;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/***************************************************************
|
|
575
|
+
* The overflow menu: the granularities an app enabled but that would
|
|
576
|
+
* push the control off a phone screen (quarter, semester, decade…).
|
|
577
|
+
***************************************************************/
|
|
578
|
+
function build_more_menu(gobj)
|
|
579
|
+
{
|
|
580
|
+
let priv = gobj.priv;
|
|
581
|
+
|
|
582
|
+
let $items = createElement2(["div", {class: "dropdown-content YUI_PERIOD_MORE_ITEMS"}, []]);
|
|
583
|
+
for(let mode of priv.modes) {
|
|
584
|
+
if(!mode.overflow) {
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
let $item = createElement2(
|
|
588
|
+
["a", {class: `dropdown-item YUI_PERIOD_MODE ` +
|
|
589
|
+
`YUI_PERIOD_MODE_${mode.id.toUpperCase()}`, href: "#"},
|
|
590
|
+
[["span", {class: "YUI_PERIOD_MODE_TEXT"}, mode_name(mode)]]
|
|
591
|
+
]);
|
|
592
|
+
$item.addEventListener("click", (ev) => {
|
|
593
|
+
ev.preventDefault();
|
|
594
|
+
close_more(gobj);
|
|
595
|
+
gobj_send_event(gobj, "EV_SET_MODE", {mode: mode.id}, gobj);
|
|
596
|
+
});
|
|
597
|
+
mode.$btn = $item;
|
|
598
|
+
$items.appendChild($item);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
let $trigger = createElement2(
|
|
602
|
+
["button", {class: "button YUI_PERIOD_MORE_BTN", type: "button",
|
|
603
|
+
title: t("more periods"), "aria-label": t("more periods"),
|
|
604
|
+
"data-i18n-title": "more periods", "data-i18n-aria-label": "more periods"},
|
|
605
|
+
[["span", {class: "icon"}, [["i", {class: "yi-ellipsis"}]]]]
|
|
606
|
+
]);
|
|
607
|
+
|
|
608
|
+
priv.$more = createElement2(
|
|
609
|
+
["div", {class: "dropdown is-right YUI_PERIOD_MORE"},
|
|
610
|
+
[
|
|
611
|
+
["div", {class: "dropdown-trigger"}, [$trigger]],
|
|
612
|
+
["div", {class: "dropdown-menu"}, [$items]]
|
|
613
|
+
]
|
|
614
|
+
]);
|
|
615
|
+
|
|
616
|
+
$trigger.addEventListener("click", () => {
|
|
617
|
+
if(priv.$more.classList.contains("is-active")) {
|
|
618
|
+
close_more(gobj);
|
|
619
|
+
} else {
|
|
620
|
+
open_more(gobj);
|
|
621
|
+
}
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
return priv.$more;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/***************************************************************
|
|
628
|
+
* The overflow menu dismisses like the calendar popover: anything
|
|
629
|
+
* outside closes it, Escape included — and that Escape stops there
|
|
630
|
+
* (capture phase), for the same reason as the calendar's: one
|
|
631
|
+
* Escape, one thing closed. Without this the menu only closed by
|
|
632
|
+
* re-clicking the trigger, and on a phone its open items sat over
|
|
633
|
+
* the navigator swallowing taps meant for the label.
|
|
634
|
+
***************************************************************/
|
|
635
|
+
function open_more(gobj)
|
|
636
|
+
{
|
|
637
|
+
let priv = gobj.priv;
|
|
638
|
+
if(!priv.$more) {
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
close_calendar(gobj); /* one popover at a time */
|
|
642
|
+
priv.$more.classList.add("is-active");
|
|
643
|
+
|
|
644
|
+
if(priv.on_more_dismiss) {
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
priv.on_more_dismiss = (ev) => {
|
|
648
|
+
if(ev.type === "keydown") {
|
|
649
|
+
if(ev.key !== "Escape") {
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
ev.preventDefault();
|
|
653
|
+
ev.stopPropagation();
|
|
654
|
+
}
|
|
655
|
+
if(ev.type === "pointerdown" && priv.$more.contains(ev.target)) {
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
close_more(gobj);
|
|
659
|
+
};
|
|
660
|
+
document.addEventListener("pointerdown", priv.on_more_dismiss, true);
|
|
661
|
+
document.addEventListener("keydown", priv.on_more_dismiss, true);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function close_more(gobj)
|
|
665
|
+
{
|
|
666
|
+
let priv = gobj.priv;
|
|
667
|
+
|
|
668
|
+
if(priv.on_more_dismiss) {
|
|
669
|
+
document.removeEventListener("pointerdown", priv.on_more_dismiss, true);
|
|
670
|
+
document.removeEventListener("keydown", priv.on_more_dismiss, true);
|
|
671
|
+
priv.on_more_dismiss = null;
|
|
672
|
+
}
|
|
673
|
+
if(priv.$more) {
|
|
674
|
+
priv.$more.classList.remove("is-active");
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function nav_button(gobj, logical, icon, key, on_click)
|
|
679
|
+
{
|
|
680
|
+
let $btn = createElement2(
|
|
681
|
+
["button", {class: `button is-ghost ${logical}`, type: "button",
|
|
682
|
+
title: t(key), "aria-label": t(key),
|
|
683
|
+
"data-i18n-title": key, "data-i18n-aria-label": key},
|
|
684
|
+
[["span", {class: "icon"}, [["i", {class: icon}]]]]
|
|
685
|
+
]);
|
|
686
|
+
$btn.addEventListener("click", on_click);
|
|
687
|
+
return $btn;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/***************************************************************
|
|
691
|
+
* Paint what the state says: which mode is active, what the navigator
|
|
692
|
+
* is parked on, and which arrows lead anywhere.
|
|
693
|
+
*
|
|
694
|
+
* Bulma's helpers carry !important, so visibility is a CLASS
|
|
695
|
+
* (`is-hidden`) — an inline style.display would lose to them.
|
|
696
|
+
***************************************************************/
|
|
697
|
+
function repaint(gobj)
|
|
698
|
+
{
|
|
699
|
+
let priv = gobj.priv;
|
|
700
|
+
let mode = cur_mode(gobj);
|
|
701
|
+
|
|
702
|
+
for(let m of priv.modes) {
|
|
703
|
+
if(!m.$btn) {
|
|
704
|
+
continue;
|
|
705
|
+
}
|
|
706
|
+
m.$btn.classList.toggle("is-active", m === mode);
|
|
707
|
+
m.$btn.classList.toggle("is-link", m === mode && !m.overflow);
|
|
708
|
+
}
|
|
709
|
+
if(mode && mode.$btn) {
|
|
710
|
+
scroll_mode_into_view(gobj, mode.$btn);
|
|
711
|
+
}
|
|
712
|
+
if(priv.$more) {
|
|
713
|
+
/* An overflow granularity in use must SAY so on the trigger: the
|
|
714
|
+
* control shows no active segment otherwise, and the user cannot
|
|
715
|
+
* tell a quarter from a month by the arrows alone. */
|
|
716
|
+
priv.$more.classList.toggle("is-link", !!(mode && mode.overflow));
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/* The navigator STAYS, disabled, in the modes with nothing to walk: it
|
|
720
|
+
* used to disappear, and every mode change re-flowed the whole card under
|
|
721
|
+
* the cursor. A control that greys out says "not now"; one that vanishes
|
|
722
|
+
* makes you re-find everything else. */
|
|
723
|
+
let is_bucket = !!(mode && mode.kind === "bucket");
|
|
724
|
+
priv.$nav.classList.toggle("yui-period-nav-off", !is_bucket);
|
|
725
|
+
priv.$label.disabled = !is_bucket;
|
|
726
|
+
|
|
727
|
+
let $custom = gobj_read_pointer_attr(gobj, "$custom");
|
|
728
|
+
if($custom) {
|
|
729
|
+
$custom.classList.toggle("is-hidden", !(mode && mode.id === MODE_CUSTOM));
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
let ms = gobj_read_bool_attr(gobj, "ms");
|
|
733
|
+
|
|
734
|
+
/* The flat modes have no bucket to name, but they still ASK for
|
|
735
|
+
* something, and the user is entitled to read it. */
|
|
736
|
+
if(!is_bucket) {
|
|
737
|
+
priv.$first.disabled = true;
|
|
738
|
+
priv.$prev.disabled = true;
|
|
739
|
+
priv.$next.disabled = true;
|
|
740
|
+
priv.$latest.disabled = true;
|
|
741
|
+
|
|
742
|
+
let $text = priv.$label.querySelector(".YUI_PERIOD_LABEL_TEXT");
|
|
743
|
+
if($text) {
|
|
744
|
+
/* No bucket, so no date to name — and naming one anyway ("Today")
|
|
745
|
+
* next to a mode that asks for EVERYTHING would be a lie. */
|
|
746
|
+
$text.textContent = "—";
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
if(!priv.$resolved) {
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
if(mode && mode.kind === "rolling") {
|
|
753
|
+
let r = cur_bounds(gobj);
|
|
754
|
+
priv.$resolved.textContent = `${fmt_epoch(r.from, ms)} → …`;
|
|
755
|
+
} else if(mode && mode.id === MODE_CUSTOM) {
|
|
756
|
+
priv.$resolved.textContent = t("the range typed below");
|
|
757
|
+
} else {
|
|
758
|
+
priv.$resolved.textContent = t("no time limits");
|
|
759
|
+
}
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
let anchor = gobj_read_integer_attr(gobj, "anchor");
|
|
764
|
+
let $text = priv.$label.querySelector(".YUI_PERIOD_LABEL_TEXT");
|
|
765
|
+
if($text) {
|
|
766
|
+
$text.textContent = period_label(mode.spec, anchor, t, ui_locale());
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
if(priv.$resolved) {
|
|
770
|
+
let b = period_bounds_epoch(mode.spec, anchor, ms);
|
|
771
|
+
priv.$resolved.textContent = `${fmt_epoch(b.from, ms)} → ${fmt_epoch(b.to, ms)}`;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
let limit = limit_ms(gobj);
|
|
775
|
+
let at_end = period_start(mode.spec, anchor).getTime() >=
|
|
776
|
+
period_start(mode.spec, limit).getTime();
|
|
777
|
+
priv.$next.disabled = at_end;
|
|
778
|
+
priv.$latest.disabled = at_end;
|
|
779
|
+
|
|
780
|
+
let min = gobj_read_integer_attr(gobj, "min");
|
|
781
|
+
if(min) {
|
|
782
|
+
let min_ms = epoch_to_ms(min, ms);
|
|
783
|
+
let at_start = period_start(mode.spec, anchor).getTime() <=
|
|
784
|
+
period_start(mode.spec, min_ms).getTime();
|
|
785
|
+
priv.$prev.disabled = at_start;
|
|
786
|
+
priv.$first.disabled = at_start;
|
|
787
|
+
} else {
|
|
788
|
+
/* The data's extent is unknown (an old backend, a key nobody has
|
|
789
|
+
* reported yet): there is no oldest bucket to jump to, so the jump
|
|
790
|
+
* is dead — but stepping back is not. */
|
|
791
|
+
priv.$prev.disabled = false;
|
|
792
|
+
priv.$first.disabled = true;
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
/***************************************************************
|
|
798
|
+
* Bring the active granularity inside the visible part of the strip.
|
|
799
|
+
*
|
|
800
|
+
* The strip only overflows on a narrow screen, and there the active mode
|
|
801
|
+
* can sit past either edge — "Custom" at the right, "Hour" at the left of
|
|
802
|
+
* a strip the user already pushed. Scrolling the CONTAINER (not
|
|
803
|
+
* scrollIntoView, which walks up and drags the page/dialog with it) is the
|
|
804
|
+
* only part of this that must not be guessed.
|
|
805
|
+
***************************************************************/
|
|
806
|
+
function scroll_mode_into_view(gobj, $btn)
|
|
807
|
+
{
|
|
808
|
+
let $m = gobj.priv.$modes;
|
|
809
|
+
if(!$m || $m.scrollWidth <= $m.clientWidth + 1) {
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
/* Rects, NOT offsetLeft: the strip is not a positioned element, so the
|
|
813
|
+
* button's offsetParent is some ancestor of it and offsetLeft would be
|
|
814
|
+
* measured from the wrong origin. */
|
|
815
|
+
let strip = $m.getBoundingClientRect();
|
|
816
|
+
let btn = $btn.getBoundingClientRect();
|
|
817
|
+
let left = btn.left - strip.left + $m.scrollLeft;
|
|
818
|
+
let right = left + btn.width;
|
|
819
|
+
if(left < $m.scrollLeft) {
|
|
820
|
+
$m.scrollLeft = left;
|
|
821
|
+
} else if(right > $m.scrollLeft + $m.clientWidth) {
|
|
822
|
+
$m.scrollLeft = right - $m.clientWidth;
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/***************************************************************
|
|
827
|
+
* The "there is more" hint of the scrolling strip: fade the edge
|
|
828
|
+
* that hides content. Only the classes live here; the mask is css.
|
|
829
|
+
***************************************************************/
|
|
830
|
+
function update_modes_fade(gobj)
|
|
831
|
+
{
|
|
832
|
+
let $m = gobj.priv.$modes;
|
|
833
|
+
if(!$m) {
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
let overflow = $m.scrollWidth > $m.clientWidth + 1;
|
|
837
|
+
let at_start = $m.scrollLeft <= 1;
|
|
838
|
+
let at_end = $m.scrollLeft + $m.clientWidth >= $m.scrollWidth - 1;
|
|
839
|
+
$m.classList.toggle("yui-period-fade-left", overflow && !at_start);
|
|
840
|
+
$m.classList.toggle("yui-period-fade-right", overflow && !at_end);
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
/*---------------------------------------------*
|
|
845
|
+
* The calendar popover
|
|
846
|
+
*---------------------------------------------*/
|
|
847
|
+
|
|
848
|
+
/***************************************************************
|
|
849
|
+
* Which grid the popover shows is decided by the bucket's UNIT: you
|
|
850
|
+
* pick a quarter by pointing at a month, and a decade by pointing at a
|
|
851
|
+
* year — a day grid would be asking the wrong question.
|
|
852
|
+
***************************************************************/
|
|
853
|
+
function grid_kind(spec)
|
|
854
|
+
{
|
|
855
|
+
if(spec.unit === "year") {
|
|
856
|
+
return "years";
|
|
857
|
+
}
|
|
858
|
+
if(spec.unit === "month") {
|
|
859
|
+
return "months";
|
|
860
|
+
}
|
|
861
|
+
return "days";
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/***************************************************************
|
|
865
|
+
* Browsing the grid (‹ July 2026 ›) is PLUMBING, not an action — it
|
|
866
|
+
* changes nothing outside the popover. PICKING is the action, and it
|
|
867
|
+
* leaves through EV_DATE_PICKED like everything else.
|
|
868
|
+
***************************************************************/
|
|
869
|
+
function open_calendar(gobj)
|
|
870
|
+
{
|
|
871
|
+
let priv = gobj.priv;
|
|
872
|
+
let mode = cur_mode(gobj);
|
|
873
|
+
if(!mode || mode.kind !== "bucket") {
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
close_calendar(gobj);
|
|
877
|
+
close_more(gobj); /* one popover at a time */
|
|
878
|
+
|
|
879
|
+
priv.cal_view = gobj_read_integer_attr(gobj, "anchor");
|
|
880
|
+
|
|
881
|
+
priv.$calendar = createElement2(["div", {class: "box p-2 YUI_PERIOD_CALENDAR"}, []]);
|
|
882
|
+
render_calendar(gobj);
|
|
883
|
+
|
|
884
|
+
priv.$nav.appendChild(priv.$calendar);
|
|
885
|
+
|
|
886
|
+
/* Anything outside closes it, Escape included — and the Escape that
|
|
887
|
+
* closes the popover STOPS THERE. We listen in the capture phase, so we
|
|
888
|
+
* run before whatever hosts us: without swallowing it, the same keypress
|
|
889
|
+
* travelled on to the shell's escape chain and closed the whole dialog
|
|
890
|
+
* the calendar was opened from. One Escape, one thing closed. */
|
|
891
|
+
priv.on_dismiss = (ev) => {
|
|
892
|
+
if(ev.type === "keydown") {
|
|
893
|
+
if(ev.key !== "Escape") {
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
ev.preventDefault();
|
|
897
|
+
ev.stopPropagation();
|
|
898
|
+
}
|
|
899
|
+
if(ev.type === "pointerdown" && priv.$calendar && priv.$calendar.contains(ev.target)) {
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
if(ev.type === "pointerdown" && priv.$label.contains(ev.target)) {
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
close_calendar(gobj);
|
|
906
|
+
};
|
|
907
|
+
document.addEventListener("pointerdown", priv.on_dismiss, true);
|
|
908
|
+
document.addEventListener("keydown", priv.on_dismiss, true);
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
function close_calendar(gobj)
|
|
912
|
+
{
|
|
913
|
+
let priv = gobj.priv;
|
|
914
|
+
|
|
915
|
+
if(priv.on_dismiss) {
|
|
916
|
+
document.removeEventListener("pointerdown", priv.on_dismiss, true);
|
|
917
|
+
document.removeEventListener("keydown", priv.on_dismiss, true);
|
|
918
|
+
priv.on_dismiss = null;
|
|
919
|
+
}
|
|
920
|
+
if(priv.$calendar) {
|
|
921
|
+
priv.$calendar.remove();
|
|
922
|
+
priv.$calendar = null;
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
function render_calendar(gobj)
|
|
927
|
+
{
|
|
928
|
+
let priv = gobj.priv;
|
|
929
|
+
let mode = cur_mode(gobj);
|
|
930
|
+
if(!priv.$calendar || !mode || mode.kind !== "bucket") {
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
let kind = grid_kind(mode.spec);
|
|
934
|
+
let view = new Date(priv.cal_view);
|
|
935
|
+
let locale = ui_locale();
|
|
936
|
+
|
|
937
|
+
let step_view = (delta) => {
|
|
938
|
+
let d = new Date(priv.cal_view);
|
|
939
|
+
if(kind === "days") {
|
|
940
|
+
priv.cal_view = new Date(d.getFullYear(), d.getMonth() + delta, 1).getTime();
|
|
941
|
+
} else if(kind === "months") {
|
|
942
|
+
priv.cal_view = new Date(d.getFullYear() + delta, 0, 1).getTime();
|
|
943
|
+
} else {
|
|
944
|
+
priv.cal_view = new Date(d.getFullYear() + delta * 12, 0, 1).getTime();
|
|
945
|
+
}
|
|
946
|
+
render_calendar(gobj);
|
|
947
|
+
};
|
|
948
|
+
|
|
949
|
+
let heading;
|
|
950
|
+
if(kind === "days") {
|
|
951
|
+
heading = new Intl.DateTimeFormat(locale, {month: "long", year: "numeric"}).format(view);
|
|
952
|
+
} else if(kind === "months") {
|
|
953
|
+
heading = String(view.getFullYear());
|
|
954
|
+
} else {
|
|
955
|
+
let base = Math.floor(view.getFullYear() / 12) * 12;
|
|
956
|
+
heading = `${base} – ${base + 11}`;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
let $back = nav_button(gobj, "YUI_PERIOD_CAL_PREV", "yi-chevron-left", "previous period",
|
|
960
|
+
() => step_view(-1));
|
|
961
|
+
let $fwd = nav_button(gobj, "YUI_PERIOD_CAL_NEXT", "yi-chevron-right", "next period",
|
|
962
|
+
() => step_view(1));
|
|
963
|
+
|
|
964
|
+
let $head = createElement2(
|
|
965
|
+
["div", {class: "is-flex is-align-items-center is-justify-content-space-between " +
|
|
966
|
+
"mb-2 YUI_PERIOD_CAL_HEAD"},
|
|
967
|
+
[$back,
|
|
968
|
+
["span", {class: "has-text-weight-semibold YUI_PERIOD_CAL_TITLE"}, heading],
|
|
969
|
+
$fwd]
|
|
970
|
+
]);
|
|
971
|
+
|
|
972
|
+
let $grid = (kind === "days")? build_days_grid(gobj, view, locale)
|
|
973
|
+
: (kind === "months")? build_months_grid(gobj, view, locale)
|
|
974
|
+
: build_years_grid(gobj, view);
|
|
975
|
+
|
|
976
|
+
let $today = createElement2(
|
|
977
|
+
["button", {class: "button is-ghost mt-2 YUI_PERIOD_CAL_TODAY", type: "button"},
|
|
978
|
+
[["span", {"data-i18n": "today"}, t("today")]]
|
|
979
|
+
]);
|
|
980
|
+
$today.addEventListener("click", () => {
|
|
981
|
+
gobj_send_event(gobj, "EV_DATE_PICKED", {anchor: Date.now()}, gobj);
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
priv.$calendar.replaceChildren($head, $grid, $today);
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
function cell_button(gobj, label, anchor_ms, selected, logical, aria)
|
|
988
|
+
{
|
|
989
|
+
/* `aria` is the full name of the instant ("14 July 2026"): the visible
|
|
990
|
+
* label is a bare number, which a screen reader hears with no month or
|
|
991
|
+
* year around it. */
|
|
992
|
+
let $btn = createElement2(
|
|
993
|
+
/* The ONE place `is-small` is earned: 42 cells in a popover. */
|
|
994
|
+
["button", {class: `button is-small ${selected? "is-link" : "is-ghost"} ${logical}`,
|
|
995
|
+
type: "button", style: "width:100%;",
|
|
996
|
+
title: aria || "", "aria-label": aria || ""}, String(label)]);
|
|
997
|
+
$btn.addEventListener("click", () => {
|
|
998
|
+
gobj_send_event(gobj, "EV_DATE_PICKED", {anchor: anchor_ms}, gobj);
|
|
999
|
+
});
|
|
1000
|
+
return $btn;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
/***************************************************************
|
|
1004
|
+
* Hovering a cell previews the BUCKET a click would pick: a week
|
|
1005
|
+
* rings its whole row, a quarter its three months — what you are
|
|
1006
|
+
* about to get, before committing. Pointer plumbing, not an
|
|
1007
|
+
* action: it changes nothing outside the popover.
|
|
1008
|
+
***************************************************************/
|
|
1009
|
+
function wire_bucket_preview(spec, cells)
|
|
1010
|
+
{
|
|
1011
|
+
for(let c of cells) {
|
|
1012
|
+
c.$btn.addEventListener("mouseenter", () => {
|
|
1013
|
+
let b = period_bounds(spec, c.ts);
|
|
1014
|
+
for(let o of cells) {
|
|
1015
|
+
o.$btn.classList.toggle("yui-period-preview",
|
|
1016
|
+
o.ts >= b.from && o.ts <= b.to);
|
|
1017
|
+
}
|
|
1018
|
+
});
|
|
1019
|
+
c.$btn.addEventListener("mouseleave", () => {
|
|
1020
|
+
for(let o of cells) {
|
|
1021
|
+
o.$btn.classList.remove("yui-period-preview");
|
|
1022
|
+
}
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/***************************************************************
|
|
1028
|
+
* The day grid: Monday-first (ISO, like the week bucket), the days of
|
|
1029
|
+
* the neighbouring months greyed but LIVE — clicking one is how you
|
|
1030
|
+
* reach the last week of the previous month without going back first.
|
|
1031
|
+
***************************************************************/
|
|
1032
|
+
function build_days_grid(gobj, view, locale)
|
|
1033
|
+
{
|
|
1034
|
+
let mode = cur_mode(gobj);
|
|
1035
|
+
let anchor = gobj_read_integer_attr(gobj, "anchor");
|
|
1036
|
+
let sel = period_bounds(mode.spec, anchor);
|
|
1037
|
+
|
|
1038
|
+
/* The week-number gutter earns its place only when the bucket IS a
|
|
1039
|
+
* week: the number is then the NAME of what a click picks. */
|
|
1040
|
+
let with_weeks = (mode.spec.unit === "week");
|
|
1041
|
+
let columns = with_weeks
|
|
1042
|
+
? "grid-template-columns:2.5ch repeat(7, 1fr);"
|
|
1043
|
+
: "grid-template-columns:repeat(7, 1fr);";
|
|
1044
|
+
|
|
1045
|
+
let first = new Date(view.getFullYear(), view.getMonth(), 1);
|
|
1046
|
+
let start = start_of_iso_week(first);
|
|
1047
|
+
let today = day_number(new Date());
|
|
1048
|
+
|
|
1049
|
+
let $rows = [];
|
|
1050
|
+
let cells = [];
|
|
1051
|
+
|
|
1052
|
+
let $dow = [];
|
|
1053
|
+
if(with_weeks) {
|
|
1054
|
+
$dow.push(["div", {class: "YUI_PERIOD_CAL_DOW"}, ""]);
|
|
1055
|
+
}
|
|
1056
|
+
for(let i = 0; i < 7; i++) {
|
|
1057
|
+
let d = new Date(start.getFullYear(), start.getMonth(), start.getDate() + i);
|
|
1058
|
+
$dow.push(["div", {class: "has-text-centered is-size-7 has-text-grey YUI_PERIOD_CAL_DOW"},
|
|
1059
|
+
new Intl.DateTimeFormat(locale, {weekday: "narrow"}).format(d)]);
|
|
1060
|
+
}
|
|
1061
|
+
$rows.push(["div", {class: "YUI_PERIOD_CAL_WEEK",
|
|
1062
|
+
style: `display:grid; ${columns} gap:2px;`},
|
|
1063
|
+
$dow]);
|
|
1064
|
+
|
|
1065
|
+
let day_name = new Intl.DateTimeFormat(locale,
|
|
1066
|
+
{day: "numeric", month: "long", year: "numeric"});
|
|
1067
|
+
|
|
1068
|
+
for(let w = 0; w < 6; w++) {
|
|
1069
|
+
let $cells = [];
|
|
1070
|
+
if(with_weeks) {
|
|
1071
|
+
let monday = new Date(start.getFullYear(), start.getMonth(),
|
|
1072
|
+
start.getDate() + w * 7);
|
|
1073
|
+
let wk = iso_week(monday).week;
|
|
1074
|
+
let wk_name = t("week {{n}}", {n: wk});
|
|
1075
|
+
let $wk = createElement2(
|
|
1076
|
+
["button", {class: "button is-small is-ghost is-size-7 has-text-grey " +
|
|
1077
|
+
"YUI_PERIOD_CAL_WEEKNUM",
|
|
1078
|
+
type: "button", style: "width:100%;",
|
|
1079
|
+
title: wk_name, "aria-label": wk_name}, String(wk)]);
|
|
1080
|
+
$wk.addEventListener("click", () => {
|
|
1081
|
+
gobj_send_event(gobj, "EV_DATE_PICKED", {anchor: monday.getTime()}, gobj);
|
|
1082
|
+
});
|
|
1083
|
+
$cells.push($wk);
|
|
1084
|
+
}
|
|
1085
|
+
for(let i = 0; i < 7; i++) {
|
|
1086
|
+
let d = new Date(start.getFullYear(), start.getMonth(),
|
|
1087
|
+
start.getDate() + w * 7 + i);
|
|
1088
|
+
let ts = d.getTime();
|
|
1089
|
+
/* Selected = this day falls INSIDE the current bucket, so a
|
|
1090
|
+
* week bucket lights its whole row and a 15-minute one lights
|
|
1091
|
+
* the single day that holds it. */
|
|
1092
|
+
let selected = ts >= sel.from && ts <= sel.to;
|
|
1093
|
+
let $btn = cell_button(gobj, d.getDate(), ts, selected, "YUI_PERIOD_CAL_DAY",
|
|
1094
|
+
day_name.format(d));
|
|
1095
|
+
if(d.getMonth() !== view.getMonth()) {
|
|
1096
|
+
$btn.classList.add("has-text-grey-light");
|
|
1097
|
+
}
|
|
1098
|
+
if(day_number(d) === today) {
|
|
1099
|
+
$btn.classList.add("has-text-weight-bold", "YUI_PERIOD_CAL_TODAY_CELL");
|
|
1100
|
+
}
|
|
1101
|
+
cells.push({ts: ts, $btn: $btn});
|
|
1102
|
+
$cells.push($btn);
|
|
1103
|
+
}
|
|
1104
|
+
$rows.push(["div", {class: "YUI_PERIOD_CAL_WEEK",
|
|
1105
|
+
style: `display:grid; ${columns} gap:2px;`},
|
|
1106
|
+
$cells]);
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
wire_bucket_preview(mode.spec, cells);
|
|
1110
|
+
return createElement2(["div", {class: "YUI_PERIOD_CAL_GRID"}, $rows]);
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
function build_months_grid(gobj, view, locale)
|
|
1114
|
+
{
|
|
1115
|
+
let mode = cur_mode(gobj);
|
|
1116
|
+
let sel = period_bounds(mode.spec, gobj_read_integer_attr(gobj, "anchor"));
|
|
1117
|
+
|
|
1118
|
+
let short_name = new Intl.DateTimeFormat(locale, {month: "short"});
|
|
1119
|
+
let full_name = new Intl.DateTimeFormat(locale, {month: "long", year: "numeric"});
|
|
1120
|
+
|
|
1121
|
+
let cells = [];
|
|
1122
|
+
let $cells = [];
|
|
1123
|
+
for(let m = 0; m < 12; m++) {
|
|
1124
|
+
let d = new Date(view.getFullYear(), m, 1);
|
|
1125
|
+
let ts = d.getTime();
|
|
1126
|
+
let selected = ts >= sel.from && ts <= sel.to;
|
|
1127
|
+
let $btn = cell_button(gobj, short_name.format(d), ts, selected,
|
|
1128
|
+
"YUI_PERIOD_CAL_MONTH", full_name.format(d));
|
|
1129
|
+
cells.push({ts: ts, $btn: $btn});
|
|
1130
|
+
$cells.push($btn);
|
|
1131
|
+
}
|
|
1132
|
+
wire_bucket_preview(mode.spec, cells);
|
|
1133
|
+
return createElement2(
|
|
1134
|
+
["div", {class: "YUI_PERIOD_CAL_GRID",
|
|
1135
|
+
style: "display:grid; grid-template-columns:repeat(3, 1fr); gap:4px;"},
|
|
1136
|
+
$cells]);
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
function build_years_grid(gobj, view)
|
|
1140
|
+
{
|
|
1141
|
+
let mode = cur_mode(gobj);
|
|
1142
|
+
let sel = period_bounds(mode.spec, gobj_read_integer_attr(gobj, "anchor"));
|
|
1143
|
+
|
|
1144
|
+
let base = Math.floor(view.getFullYear() / 12) * 12;
|
|
1145
|
+
let cells = [];
|
|
1146
|
+
let $cells = [];
|
|
1147
|
+
for(let i = 0; i < 12; i++) {
|
|
1148
|
+
let d = new Date(base + i, 0, 1);
|
|
1149
|
+
let ts = d.getTime();
|
|
1150
|
+
let selected = ts >= sel.from && ts <= sel.to;
|
|
1151
|
+
let $btn = cell_button(gobj, base + i, ts, selected,
|
|
1152
|
+
"YUI_PERIOD_CAL_YEAR", String(base + i));
|
|
1153
|
+
cells.push({ts: ts, $btn: $btn});
|
|
1154
|
+
$cells.push($btn);
|
|
1155
|
+
}
|
|
1156
|
+
wire_bucket_preview(mode.spec, cells);
|
|
1157
|
+
return createElement2(
|
|
1158
|
+
["div", {class: "YUI_PERIOD_CAL_GRID",
|
|
1159
|
+
style: "display:grid; grid-template-columns:repeat(3, 1fr); gap:4px;"},
|
|
1160
|
+
$cells]);
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
|
|
1164
|
+
|
|
1165
|
+
|
|
1166
|
+
/***************************
|
|
1167
|
+
* Actions
|
|
1168
|
+
***************************/
|
|
1169
|
+
|
|
1170
|
+
|
|
1171
|
+
|
|
1172
|
+
|
|
1173
|
+
/***************************************************************
|
|
1174
|
+
* A granularity was chosen. The anchor SURVIVES the change — switching
|
|
1175
|
+
* from "week 27" to "month" must land on the month that holds week 27,
|
|
1176
|
+
* not jump back to today: the user is looking at march for a reason.
|
|
1177
|
+
***************************************************************/
|
|
1178
|
+
function ac_set_mode(gobj, event, kw, src)
|
|
1179
|
+
{
|
|
1180
|
+
let mode = find_mode(gobj, kw.mode);
|
|
1181
|
+
if(!mode) {
|
|
1182
|
+
log_error(`${gobj_short_name(gobj)}: unknown mode: ${kw.mode}`);
|
|
1183
|
+
return -1;
|
|
1184
|
+
}
|
|
1185
|
+
close_calendar(gobj);
|
|
1186
|
+
close_more(gobj);
|
|
1187
|
+
gobj_write_attr(gobj, "mode", mode.id);
|
|
1188
|
+
|
|
1189
|
+
/* A rolling window is always "now", and coming back from one with a
|
|
1190
|
+
* stale anchor would silently show an old bucket. */
|
|
1191
|
+
if(mode.kind === "rolling") {
|
|
1192
|
+
gobj_write_attr(gobj, "anchor", Date.now());
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
gobj_change_state(gobj, (mode.kind === "bucket")? "ST_BUCKET" : "ST_FLAT");
|
|
1196
|
+
repaint(gobj);
|
|
1197
|
+
publish_period(gobj);
|
|
1198
|
+
return 0;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
/***************************************************************
|
|
1202
|
+
*
|
|
1203
|
+
***************************************************************/
|
|
1204
|
+
function ac_prev(gobj, event, kw, src)
|
|
1205
|
+
{
|
|
1206
|
+
let mode = cur_mode(gobj);
|
|
1207
|
+
gobj_write_attr(gobj, "anchor",
|
|
1208
|
+
period_shift(mode.spec, gobj_read_integer_attr(gobj, "anchor"), -1));
|
|
1209
|
+
close_calendar(gobj);
|
|
1210
|
+
repaint(gobj);
|
|
1211
|
+
publish_period(gobj);
|
|
1212
|
+
return 0;
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
/***************************************************************
|
|
1216
|
+
*
|
|
1217
|
+
***************************************************************/
|
|
1218
|
+
function ac_next(gobj, event, kw, src)
|
|
1219
|
+
{
|
|
1220
|
+
let mode = cur_mode(gobj);
|
|
1221
|
+
gobj_write_attr(gobj, "anchor",
|
|
1222
|
+
period_shift(mode.spec, gobj_read_integer_attr(gobj, "anchor"), 1));
|
|
1223
|
+
close_calendar(gobj);
|
|
1224
|
+
repaint(gobj);
|
|
1225
|
+
publish_period(gobj);
|
|
1226
|
+
return 0;
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
/***************************************************************
|
|
1230
|
+
* Home: the bucket holding the newest thing there is to look at.
|
|
1231
|
+
***************************************************************/
|
|
1232
|
+
function ac_latest(gobj, event, kw, src)
|
|
1233
|
+
{
|
|
1234
|
+
gobj_write_attr(gobj, "anchor", limit_ms(gobj));
|
|
1235
|
+
close_calendar(gobj);
|
|
1236
|
+
repaint(gobj);
|
|
1237
|
+
publish_period(gobj);
|
|
1238
|
+
return 0;
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
/***************************************************************
|
|
1242
|
+
* The other end: the bucket holding the OLDEST thing there is. It only
|
|
1243
|
+
* exists when the host said where the data starts (`min`) — without it
|
|
1244
|
+
* there is no "first", and the button is dead rather than lying.
|
|
1245
|
+
***************************************************************/
|
|
1246
|
+
function ac_first(gobj, event, kw, src)
|
|
1247
|
+
{
|
|
1248
|
+
let min = gobj_read_integer_attr(gobj, "min");
|
|
1249
|
+
if(!min) {
|
|
1250
|
+
log_error(`${gobj_short_name(gobj)}: EV_FIRST with no known start of data`);
|
|
1251
|
+
return -1;
|
|
1252
|
+
}
|
|
1253
|
+
gobj_write_attr(gobj, "anchor", epoch_to_ms(min, gobj_read_bool_attr(gobj, "ms")));
|
|
1254
|
+
close_calendar(gobj);
|
|
1255
|
+
repaint(gobj);
|
|
1256
|
+
publish_period(gobj);
|
|
1257
|
+
return 0;
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
/***************************************************************
|
|
1261
|
+
*
|
|
1262
|
+
***************************************************************/
|
|
1263
|
+
function ac_pick_date(gobj, event, kw, src)
|
|
1264
|
+
{
|
|
1265
|
+
if(gobj.priv.$calendar) {
|
|
1266
|
+
close_calendar(gobj);
|
|
1267
|
+
return 0;
|
|
1268
|
+
}
|
|
1269
|
+
open_calendar(gobj);
|
|
1270
|
+
return 0;
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
/***************************************************************
|
|
1274
|
+
* A cell of the calendar was clicked: it names an INSTANT, and the
|
|
1275
|
+
* bucket that holds it is the one we move to.
|
|
1276
|
+
***************************************************************/
|
|
1277
|
+
function ac_date_picked(gobj, event, kw, src)
|
|
1278
|
+
{
|
|
1279
|
+
let anchor = parseInt(kw.anchor, 10);
|
|
1280
|
+
if(Number.isNaN(anchor)) {
|
|
1281
|
+
log_error(`${gobj_short_name(gobj)}: EV_DATE_PICKED without an anchor`);
|
|
1282
|
+
return -1;
|
|
1283
|
+
}
|
|
1284
|
+
gobj_write_attr(gobj, "anchor", anchor);
|
|
1285
|
+
close_calendar(gobj);
|
|
1286
|
+
repaint(gobj);
|
|
1287
|
+
publish_period(gobj);
|
|
1288
|
+
return 0;
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
/***************************************************************
|
|
1292
|
+
* The HOST changed what the picker is pointing at: its time unit (`ms`),
|
|
1293
|
+
* or the extent of the data (`min`/`max`) — a Rows dialog that switches
|
|
1294
|
+
* from the `t` axis to `tm` is looking at the same key through a
|
|
1295
|
+
* different clock, with a different span.
|
|
1296
|
+
*
|
|
1297
|
+
* The attrs are the host's to write; this is how it says "now re-read
|
|
1298
|
+
* them". Without it the arrows would still be armed against the OLD
|
|
1299
|
+
* extent, and the bounds published in the OLD unit.
|
|
1300
|
+
***************************************************************/
|
|
1301
|
+
function ac_refresh(gobj, event, kw, src)
|
|
1302
|
+
{
|
|
1303
|
+
let mode = cur_mode(gobj);
|
|
1304
|
+
if(!mode) {
|
|
1305
|
+
log_error(`${gobj_short_name(gobj)}: unknown mode: ${gobj_read_attr(gobj, "mode")}`);
|
|
1306
|
+
return -1;
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
/* The host may have written `mode` too — it is an attr, and a re-aimed
|
|
1310
|
+
* picker usually lands on a different one. Deliberately SILENT: it
|
|
1311
|
+
* publishes nothing, because nobody asked for a query yet; the host is
|
|
1312
|
+
* re-arming the picker, not answering with it. */
|
|
1313
|
+
gobj_change_state(gobj, (mode.kind === "bucket")? "ST_BUCKET" : "ST_FLAT");
|
|
1314
|
+
|
|
1315
|
+
close_calendar(gobj);
|
|
1316
|
+
refresh_bounds(gobj);
|
|
1317
|
+
repaint(gobj);
|
|
1318
|
+
return 0;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
/***************************************************************
|
|
1322
|
+
* The app switched language. Every label here is composed at build
|
|
1323
|
+
* time (a month name, "Week 27") and carries no key, so `refresh_language`
|
|
1324
|
+
* cannot reach it: it has to be re-rendered.
|
|
1325
|
+
***************************************************************/
|
|
1326
|
+
function ac_language_changed(gobj, event, kw, src)
|
|
1327
|
+
{
|
|
1328
|
+
let priv = gobj.priv;
|
|
1329
|
+
|
|
1330
|
+
for(let mode of priv.modes) {
|
|
1331
|
+
if(!mode.$btn) {
|
|
1332
|
+
continue;
|
|
1333
|
+
}
|
|
1334
|
+
let $text = mode.$btn.querySelector(".YUI_PERIOD_MODE_TEXT");
|
|
1335
|
+
if($text) {
|
|
1336
|
+
$text.textContent = mode_name(mode);
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
repaint(gobj);
|
|
1340
|
+
if(priv.$calendar) {
|
|
1341
|
+
render_calendar(gobj);
|
|
1342
|
+
}
|
|
1343
|
+
return 0;
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
/***************************************************************
|
|
1347
|
+
* FSM
|
|
1348
|
+
***************************************************************/
|
|
1349
|
+
/*---------------------------------------------*
|
|
1350
|
+
* Global methods table
|
|
1351
|
+
*---------------------------------------------*/
|
|
1352
|
+
const gmt = {
|
|
1353
|
+
mt_create: mt_create,
|
|
1354
|
+
mt_start: mt_start,
|
|
1355
|
+
mt_stop: mt_stop,
|
|
1356
|
+
mt_destroy: mt_destroy
|
|
1357
|
+
};
|
|
1358
|
+
|
|
1359
|
+
/***************************************************************
|
|
1360
|
+
* Create the GClass
|
|
1361
|
+
***************************************************************/
|
|
1362
|
+
function create_gclass(gclass_name)
|
|
1363
|
+
{
|
|
1364
|
+
if(__gclass__) {
|
|
1365
|
+
log_error(`GClass ALREADY created: ${gclass_name}`);
|
|
1366
|
+
return -1;
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
/*---------------------------------------------*
|
|
1370
|
+
* States
|
|
1371
|
+
*
|
|
1372
|
+
* ST_BUCKET a period is selected: it can be walked.
|
|
1373
|
+
* ST_FLAT span / custom / a rolling window: there is nothing to
|
|
1374
|
+
* walk, and an arrow arriving here is a caller's bug —
|
|
1375
|
+
* the FSM says so instead of no-op'ing.
|
|
1376
|
+
*---------------------------------------------*/
|
|
1377
|
+
const states = [
|
|
1378
|
+
["ST_BUCKET", [
|
|
1379
|
+
["EV_SET_MODE", ac_set_mode, null],
|
|
1380
|
+
["EV_PREV", ac_prev, null],
|
|
1381
|
+
["EV_NEXT", ac_next, null],
|
|
1382
|
+
["EV_FIRST", ac_first, null],
|
|
1383
|
+
["EV_LATEST", ac_latest, null],
|
|
1384
|
+
["EV_PICK_DATE", ac_pick_date, null],
|
|
1385
|
+
["EV_DATE_PICKED", ac_date_picked, null],
|
|
1386
|
+
["EV_REFRESH", ac_refresh, null],
|
|
1387
|
+
["EV_LANGUAGE_CHANGED", ac_language_changed, null]
|
|
1388
|
+
]],
|
|
1389
|
+
["ST_FLAT", [
|
|
1390
|
+
["EV_SET_MODE", ac_set_mode, null],
|
|
1391
|
+
["EV_REFRESH", ac_refresh, null],
|
|
1392
|
+
["EV_LANGUAGE_CHANGED", ac_language_changed, null]
|
|
1393
|
+
]]
|
|
1394
|
+
];
|
|
1395
|
+
|
|
1396
|
+
/*---------------------------------------------*
|
|
1397
|
+
* Events
|
|
1398
|
+
*---------------------------------------------*/
|
|
1399
|
+
const event_types = [
|
|
1400
|
+
["EV_SET_MODE", 0],
|
|
1401
|
+
["EV_PREV", 0],
|
|
1402
|
+
["EV_NEXT", 0],
|
|
1403
|
+
["EV_FIRST", 0],
|
|
1404
|
+
["EV_LATEST", 0],
|
|
1405
|
+
["EV_PICK_DATE", 0],
|
|
1406
|
+
["EV_DATE_PICKED", 0],
|
|
1407
|
+
["EV_REFRESH", 0],
|
|
1408
|
+
["EV_LANGUAGE_CHANGED", 0],
|
|
1409
|
+
["EV_PERIOD_CHANGED", event_flag_t.EVF_OUTPUT_EVENT]
|
|
1410
|
+
];
|
|
1411
|
+
|
|
1412
|
+
__gclass__ = gclass_create(
|
|
1413
|
+
gclass_name,
|
|
1414
|
+
event_types,
|
|
1415
|
+
states,
|
|
1416
|
+
gmt,
|
|
1417
|
+
0, // lmt,
|
|
1418
|
+
attrs_table,
|
|
1419
|
+
PRIVATE_DATA,
|
|
1420
|
+
0, // authz_table,
|
|
1421
|
+
0, // command_table,
|
|
1422
|
+
0, // s_user_trace_level
|
|
1423
|
+
0 // gclass_flag
|
|
1424
|
+
);
|
|
1425
|
+
|
|
1426
|
+
if(!__gclass__) {
|
|
1427
|
+
return -1;
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
return 0;
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
/***************************************************************
|
|
1434
|
+
* Register GClass
|
|
1435
|
+
***************************************************************/
|
|
1436
|
+
function register_c_yui_period()
|
|
1437
|
+
{
|
|
1438
|
+
return create_gclass(GCLASS_NAME);
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
export {register_c_yui_period};
|