maplibre-preview 1.8.0 → 1.9.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 61c7dd8ecf3753aa43ba5272cc06dab258cbb643824e74336e21e07680795cd6
4
- data.tar.gz: 6a008af2ffe1b2f3331dc752ab36041767299cc38271f9f9433567f268952198
3
+ metadata.gz: 2c43caaa2de2305aa8211b7d1de99aaf7337b6162d7d99a448f73007c717591a
4
+ data.tar.gz: b16f170dc316e26844ffa6dd44d8401d38ba9128a9cf0dc13bd3abd1b30d0507
5
5
  SHA512:
6
- metadata.gz: c75eede1968d1ddfaa8c388de5c827bf6f8e27f5bcc628938aca1c61f7ccf2896cb70f9a498f6262f352a4c1a211881a87ac09133da2445cd3e6c02f6fa42aac
7
- data.tar.gz: 435d1192991bacd4730d547eb3f580f99c8d287fabb29e221432a44b309c6b73e25c198f6abea453e0a9ab0ba5d4de6b83adff1fcd4e68cbd73ec03c67e9f22e
6
+ metadata.gz: 2401ce59b8a156a2b34ddeecc36a3c6ad4ae161f41fcf998d3e2a6c788cf096f62fde591327948bdf18f1625b8eb9d0e55f226f8d758f7d6b0d8524087bc43f1
7
+ data.tar.gz: ad6e8ab6f576675903edda6b0859df42ef467dffff70e651eaacb36bbb8c906806b7d3bfec2d29545bcad663bcc99a114d88b912edd22caf115d5b7ecbdada0f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.9.0] - 2026-05-14
4
+
5
+ ### Added
6
+ - **Temporal parameter picker** - added a custom calendar and time picker for date/time-like style parameters
7
+ - **Style parameter context** - show source/layer counts and localized usage hints for each detected style parameter
8
+
9
+ ### Changed
10
+ - **Style parameter URL matching** - track source-specific parameterized URL rules and append only the parameters declared for the matching source
11
+ - **Temporal parameter inputs** - use the custom picker for temporal parameters while keeping query values normalized to epoch seconds
12
+
13
+ ### Fixed
14
+ - **Source metadata inspection** - fetch source metadata without pre-appending parameter values so metadata-declared parameters can be discovered reliably
15
+ - **Parameterized tile requests** - removed the broad `/rb_tiles/` heuristic in favor of explicit source metadata and URL prefix matching
16
+
3
17
  ## [1.8.0] - 2026-05-14
4
18
 
5
19
  ### Added
@@ -0,0 +1,164 @@
1
+ .temporal-picker-popover {
2
+ position: fixed;
3
+ z-index: 1300;
4
+ display: flex;
5
+ flex-direction: column;
6
+ gap: 8px;
7
+ background: rgba(49, 51, 53, 0.98);
8
+ border: 1px solid #555555;
9
+ border-radius: 4px;
10
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
11
+ color: #a9b7c6;
12
+ padding: 12px;
13
+ }
14
+
15
+ .temporal-picker-header {
16
+ display: flex;
17
+ align-items: center;
18
+ justify-content: space-between;
19
+ gap: 8px;
20
+ }
21
+
22
+ .temporal-picker-title {
23
+ color: #ffc66d;
24
+ font-size: 11px;
25
+ font-weight: bold;
26
+ text-transform: uppercase;
27
+ }
28
+
29
+ .temporal-picker-nav {
30
+ display: inline-flex;
31
+ gap: 4px;
32
+ }
33
+
34
+ .temporal-picker-nav-button {
35
+ display: inline-flex;
36
+ align-items: center;
37
+ justify-content: center;
38
+ width: 24px;
39
+ height: 24px;
40
+ background: #3c3f41;
41
+ color: #a9b7c6;
42
+ border: 1px solid #555555;
43
+ border-radius: 3px;
44
+ font-size: 16px;
45
+ line-height: 1;
46
+ cursor: pointer;
47
+ }
48
+
49
+ .temporal-picker-nav-button:hover {
50
+ background: #5a5d5f;
51
+ border-color: #666666;
52
+ }
53
+
54
+ .temporal-picker-calendar {
55
+ display: grid;
56
+ grid-template-columns: repeat(7, minmax(0, 1fr));
57
+ gap: 4px;
58
+ }
59
+
60
+ .temporal-picker-weekday {
61
+ color: #808080;
62
+ font-size: 10px;
63
+ font-weight: bold;
64
+ text-align: center;
65
+ text-transform: uppercase;
66
+ }
67
+
68
+ .temporal-picker-day {
69
+ height: 28px;
70
+ background: transparent;
71
+ color: #a9b7c6;
72
+ border: 1px solid transparent;
73
+ border-radius: 3px;
74
+ font-size: 11px;
75
+ cursor: pointer;
76
+ }
77
+
78
+ .temporal-picker-day:hover {
79
+ background: #4b4d4f;
80
+ border-color: #555555;
81
+ }
82
+
83
+ .temporal-picker-day.muted {
84
+ color: #666666;
85
+ }
86
+
87
+ .temporal-picker-day.today {
88
+ border-color: #6897bb;
89
+ color: #ffffff;
90
+ }
91
+
92
+ .temporal-picker-day.selected {
93
+ background: #6897bb;
94
+ border-color: #7aa8cc;
95
+ color: #ffffff;
96
+ font-weight: bold;
97
+ }
98
+
99
+ .temporal-picker-time {
100
+ display: grid;
101
+ grid-template-columns: 1fr auto 1fr;
102
+ align-items: center;
103
+ gap: 6px;
104
+ color: #808080;
105
+ font-family: 'Courier New', monospace;
106
+ font-size: 12px;
107
+ }
108
+
109
+ .temporal-picker-select {
110
+ width: 100%;
111
+ background: #3c3f41;
112
+ color: #a9b7c6;
113
+ border: 1px solid #555555;
114
+ border-radius: 3px;
115
+ padding: 6px 8px;
116
+ font-family: 'Courier New', monospace;
117
+ font-size: 11px;
118
+ color-scheme: dark;
119
+ }
120
+
121
+ .temporal-picker-select:focus {
122
+ outline: none;
123
+ border-color: #6897bb;
124
+ box-shadow: 0 0 0 1px #6897bb;
125
+ }
126
+
127
+ .temporal-picker-footer {
128
+ display: grid;
129
+ grid-template-columns: 1fr 1fr 1fr;
130
+ gap: 6px;
131
+ }
132
+
133
+ .temporal-picker-button {
134
+ background: #3c3f41;
135
+ color: #a9b7c6;
136
+ border: 1px solid #555555;
137
+ border-radius: 3px;
138
+ padding: 6px 8px;
139
+ font-size: 11px;
140
+ font-weight: bold;
141
+ cursor: pointer;
142
+ }
143
+
144
+ .temporal-picker-button:hover {
145
+ background: #5a5d5f;
146
+ border-color: #666666;
147
+ }
148
+
149
+ .temporal-picker-button:focus {
150
+ outline: none;
151
+ border-color: #6897bb;
152
+ box-shadow: 0 0 0 1px #6897bb;
153
+ }
154
+
155
+ .temporal-picker-button.primary {
156
+ background: #6897bb;
157
+ border-color: #7aa8cc;
158
+ color: #ffffff;
159
+ }
160
+
161
+ .temporal-picker-button.primary:hover {
162
+ background: #7aa8cc;
163
+ border-color: #8bb9dd;
164
+ }
@@ -0,0 +1,247 @@
1
+ (function () {
2
+ let activePicker = null;
3
+
4
+ const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
5
+
6
+ const parseInputDate = (value) => {
7
+ const parsed = value ? new Date(value) : new Date();
8
+ return Number.isNaN(parsed.getTime()) ? new Date() : parsed;
9
+ };
10
+
11
+ const formatInputDate = (date) => {
12
+ const pad = number => String(number).padStart(2, '0');
13
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
14
+ };
15
+
16
+ const close = () => {
17
+ if (!activePicker) return;
18
+
19
+ activePicker.cleanup?.();
20
+ activePicker.element.remove();
21
+ activePicker = null;
22
+ };
23
+
24
+ const position = (input, picker) => {
25
+ const rect = input.getBoundingClientRect();
26
+ const pickerWidth = Math.min(336, window.innerWidth - 16);
27
+ const left = clamp(rect.left, 8, window.innerWidth - pickerWidth - 8);
28
+ const aboveTop = rect.top - picker.offsetHeight - 8;
29
+ const belowTop = rect.bottom + 8;
30
+ const top = aboveTop >= 8 ? aboveTop : Math.min(belowTop, window.innerHeight - picker.offsetHeight - 8);
31
+
32
+ picker.style.width = `${pickerWidth}px`;
33
+ picker.style.left = `${left}px`;
34
+ picker.style.top = `${Math.max(8, top)}px`;
35
+ };
36
+
37
+ const render = (state) => {
38
+ const {element, input, onApply, onClear} = state;
39
+ const monthDate = state.visibleMonth;
40
+ const selected = state.selectedDate;
41
+ const today = new Date();
42
+ const monthLabel = monthDate.toLocaleDateString('en-US', {month: 'long', year: 'numeric'});
43
+ const monthStart = new Date(monthDate.getFullYear(), monthDate.getMonth(), 1);
44
+ const firstOffset = (monthStart.getDay() + 6) % 7;
45
+ const firstCell = new Date(monthStart);
46
+ firstCell.setDate(monthStart.getDate() - firstOffset);
47
+
48
+ element.innerHTML = '';
49
+
50
+ const header = document.createElement('div');
51
+ header.className = 'temporal-picker-header';
52
+
53
+ const title = document.createElement('div');
54
+ title.className = 'temporal-picker-title';
55
+ title.textContent = monthLabel;
56
+
57
+ const nav = document.createElement('div');
58
+ nav.className = 'temporal-picker-nav';
59
+ [
60
+ ['Previous month', -1, '<'],
61
+ ['Next month', 1, '>']
62
+ ].forEach(([label, delta, text]) => {
63
+ const button = document.createElement('button');
64
+ button.type = 'button';
65
+ button.className = 'temporal-picker-nav-button';
66
+ button.title = label;
67
+ button.setAttribute('aria-label', label);
68
+ button.textContent = text;
69
+ button.addEventListener('click', () => {
70
+ state.visibleMonth = new Date(monthDate.getFullYear(), monthDate.getMonth() + delta, 1);
71
+ render(state);
72
+ });
73
+ nav.appendChild(button);
74
+ });
75
+
76
+ header.appendChild(title);
77
+ header.appendChild(nav);
78
+ element.appendChild(header);
79
+
80
+ const calendar = document.createElement('div');
81
+ calendar.className = 'temporal-picker-calendar';
82
+ ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].forEach(day => {
83
+ const cell = document.createElement('div');
84
+ cell.className = 'temporal-picker-weekday';
85
+ cell.textContent = day;
86
+ calendar.appendChild(cell);
87
+ });
88
+
89
+ for (let index = 0; index < 42; index += 1) {
90
+ const dayDate = new Date(firstCell);
91
+ dayDate.setDate(firstCell.getDate() + index);
92
+
93
+ const button = document.createElement('button');
94
+ button.type = 'button';
95
+ button.className = 'temporal-picker-day';
96
+ dayDate.getMonth() !== monthDate.getMonth() && button.classList.add('muted');
97
+ dayDate.toDateString() === today.toDateString() && button.classList.add('today');
98
+ dayDate.toDateString() === selected.toDateString() && button.classList.add('selected');
99
+ button.textContent = String(dayDate.getDate());
100
+ button.addEventListener('click', () => {
101
+ state.selectedDate = new Date(
102
+ dayDate.getFullYear(),
103
+ dayDate.getMonth(),
104
+ dayDate.getDate(),
105
+ selected.getHours(),
106
+ selected.getMinutes()
107
+ );
108
+ state.visibleMonth = new Date(dayDate.getFullYear(), dayDate.getMonth(), 1);
109
+ render(state);
110
+ });
111
+ calendar.appendChild(button);
112
+ }
113
+
114
+ element.appendChild(calendar);
115
+
116
+ const timeRow = document.createElement('div');
117
+ timeRow.className = 'temporal-picker-time';
118
+
119
+ const hourSelect = document.createElement('select');
120
+ hourSelect.className = 'temporal-picker-select';
121
+ hourSelect.setAttribute('aria-label', 'Hour');
122
+
123
+ const minuteSelect = document.createElement('select');
124
+ minuteSelect.className = 'temporal-picker-select';
125
+ minuteSelect.setAttribute('aria-label', 'Minute');
126
+
127
+ for (let hour = 0; hour < 24; hour += 1) {
128
+ const option = document.createElement('option');
129
+ option.value = String(hour);
130
+ option.textContent = String(hour).padStart(2, '0');
131
+ hourSelect.appendChild(option);
132
+ }
133
+ for (let minute = 0; minute < 60; minute += 1) {
134
+ const option = document.createElement('option');
135
+ option.value = String(minute);
136
+ option.textContent = String(minute).padStart(2, '0');
137
+ minuteSelect.appendChild(option);
138
+ }
139
+
140
+ hourSelect.value = String(selected.getHours());
141
+ minuteSelect.value = String(selected.getMinutes());
142
+
143
+ const updateTime = () => {
144
+ state.selectedDate = new Date(
145
+ state.selectedDate.getFullYear(),
146
+ state.selectedDate.getMonth(),
147
+ state.selectedDate.getDate(),
148
+ Number(hourSelect.value),
149
+ Number(minuteSelect.value)
150
+ );
151
+ };
152
+
153
+ hourSelect.addEventListener('change', updateTime);
154
+ minuteSelect.addEventListener('change', updateTime);
155
+
156
+ timeRow.appendChild(hourSelect);
157
+ timeRow.appendChild(document.createTextNode(':'));
158
+ timeRow.appendChild(minuteSelect);
159
+ element.appendChild(timeRow);
160
+
161
+ const footer = document.createElement('div');
162
+ footer.className = 'temporal-picker-footer';
163
+
164
+ const clearButton = document.createElement('button');
165
+ clearButton.type = 'button';
166
+ clearButton.className = 'temporal-picker-button secondary';
167
+ clearButton.textContent = 'Clear';
168
+ clearButton.addEventListener('click', () => {
169
+ onClear?.();
170
+ close();
171
+ });
172
+
173
+ const todayButton = document.createElement('button');
174
+ todayButton.type = 'button';
175
+ todayButton.className = 'temporal-picker-button secondary';
176
+ todayButton.textContent = 'Today';
177
+ todayButton.addEventListener('click', () => {
178
+ state.selectedDate = new Date();
179
+ state.visibleMonth = new Date(state.selectedDate.getFullYear(), state.selectedDate.getMonth(), 1);
180
+ render(state);
181
+ });
182
+
183
+ const applyButton = document.createElement('button');
184
+ applyButton.type = 'button';
185
+ applyButton.className = 'temporal-picker-button primary';
186
+ applyButton.textContent = 'Apply';
187
+ applyButton.addEventListener('click', () => {
188
+ onApply?.(formatInputDate(state.selectedDate));
189
+ close();
190
+ });
191
+
192
+ footer.appendChild(clearButton);
193
+ footer.appendChild(todayButton);
194
+ footer.appendChild(applyButton);
195
+ element.appendChild(footer);
196
+
197
+ position(input, element);
198
+ };
199
+
200
+ const open = (input, options = {}) => {
201
+ if (activePicker?.input === input) return;
202
+ close();
203
+
204
+ const picker = document.createElement('div');
205
+ picker.className = 'temporal-picker-popover';
206
+ picker.setAttribute('role', 'dialog');
207
+ picker.setAttribute('aria-label', `Select ${input.dataset.parameterName || 'date and time'}`);
208
+ document.body.appendChild(picker);
209
+
210
+ const selectedDate = parseInputDate(input.value);
211
+ const state = {
212
+ element: picker,
213
+ input,
214
+ selectedDate,
215
+ visibleMonth: new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1),
216
+ onApply: options.onApply || (value => { input.value = value; }),
217
+ onClear: options.onClear || (() => { input.value = ''; })
218
+ };
219
+
220
+ const outsideHandler = (event) => {
221
+ if (picker.contains(event.target) || event.target === input) return;
222
+ close();
223
+ };
224
+ const keyHandler = (event) => event.key === 'Escape' && close();
225
+ const repositionHandler = () => position(input, picker);
226
+
227
+ activePicker = {
228
+ element: picker,
229
+ input,
230
+ cleanup: () => {
231
+ document.removeEventListener('mousedown', outsideHandler);
232
+ document.removeEventListener('keydown', keyHandler);
233
+ window.removeEventListener('resize', repositionHandler);
234
+ window.removeEventListener('scroll', repositionHandler, true);
235
+ }
236
+ };
237
+
238
+ document.addEventListener('mousedown', outsideHandler);
239
+ document.addEventListener('keydown', keyHandler);
240
+ window.addEventListener('resize', repositionHandler);
241
+ window.addEventListener('scroll', repositionHandler, true);
242
+
243
+ render(state);
244
+ };
245
+
246
+ window.TemporalPicker = {open, close};
247
+ })();
@@ -1,3 +1,3 @@
1
1
  module MapLibrePreview
2
- VERSION = '1.8.0'
2
+ VERSION = '1.9.0'
3
3
  end
@@ -7,6 +7,7 @@ html
7
7
  - base_path = request.script_name
8
8
  link[rel="icon" type="image/png" href="#{base_path}/icons/favicon.png"]
9
9
  link[href="#{base_path}/vendor/maplibre-gl/maplibre-gl.css" rel='stylesheet']
10
+ link[href="#{base_path}/css/temporal_picker.css" rel='stylesheet']
10
11
  script[src="#{base_path}/vendor/maplibre-gl/maplibre-gl.js"]
11
12
  script[src="#{base_path}/vendor/maplibre-contour/index.min.js"]
12
13
  script[src="#{base_path}/vendor/d3/d3.v7.min.js"]
@@ -14,6 +15,7 @@ html
14
15
  script[src="#{base_path}/js/filters.js"]
15
16
  script[src="#{base_path}/js/contour.js"]
16
17
  script[src="#{base_path}/js/tilegrid.js"]
18
+ script[src="#{base_path}/js/temporal_picker.js"]
17
19
  style
18
20
  | * { margin: 0; padding: 0; box-sizing: border-box; }
19
21
  | html { height: 100%; }
@@ -387,17 +389,44 @@ html
387
389
  | .style-parameter-row {
388
390
  | display: flex; flex-direction: column; gap: 4px;
389
391
  | }
392
+ | .style-parameter-header {
393
+ | display: flex; align-items: center; justify-content: space-between; gap: 8px;
394
+ | }
390
395
  | .style-parameter-label {
391
396
  | color: #808080; font-size: 10px; font-weight: bold;
392
397
  | text-transform: uppercase;
393
398
  | }
399
+ | .style-parameter-counts {
400
+ | color: #6897bb; font-family: 'Courier New', monospace; font-size: 10px;
401
+ | white-space: nowrap;
402
+ | }
403
+ | .style-parameter-input-group {
404
+ | display: grid; grid-template-columns: minmax(0, 1fr); gap: 6px;
405
+ | }
406
+ | .style-parameter-input-group.temporal {
407
+ | grid-template-columns: minmax(0, 1fr);
408
+ | }
394
409
  | .style-parameter-input {
395
410
  | width: 100%; background: #313335; color: #a9b7c6; border: 1px solid #555555;
396
411
  | border-radius: 3px; padding: 6px 8px; font-size: 11px; box-sizing: border-box;
412
+ | color-scheme: dark;
397
413
  | }
398
414
  | .style-parameter-input:focus {
399
415
  | outline: none; border-color: #6897bb; box-shadow: 0 0 0 1px #6897bb;
400
416
  | }
417
+ | .style-parameter-input.temporal {
418
+ | font-family: 'Courier New', monospace; font-size: 11px; cursor: pointer;
419
+ | }
420
+ | .style-parameter-context {
421
+ | display: flex; flex-direction: column; gap: 2px;
422
+ | color: #999999; font-size: 10px; line-height: 1.25;
423
+ | }
424
+ | .style-parameter-context-row {
425
+ | display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
426
+ | }
427
+ | .style-parameter-context-key {
428
+ | color: #808080; text-transform: uppercase; margin-right: 4px;
429
+ | }
401
430
  | .style-parameter-actions {
402
431
  | display: grid; grid-template-columns: 1fr 1fr; gap: 6px;
403
432
  | }
@@ -405,13 +434,18 @@ html
405
434
  | flex: 1; min-height: 0; max-height: calc(80vh - 245px); width: max-content; max-width: 100%;
406
435
  | overflow: hidden;
407
436
  | }
408
- | .control-panel::-webkit-scrollbar { width: 8px; }
409
- | .control-panel::-webkit-scrollbar-track { background: #3c3f41; border-radius: 4px; }
410
- | .control-panel::-webkit-scrollbar-thumb {
437
+ | .control-panel::-webkit-scrollbar,
438
+ | .style-parameters-body::-webkit-scrollbar { width: 8px; }
439
+ | .control-panel::-webkit-scrollbar-track,
440
+ | .style-parameters-body::-webkit-scrollbar-track { background: #3c3f41; border-radius: 4px; }
441
+ | .control-panel::-webkit-scrollbar-thumb,
442
+ | .style-parameters-body::-webkit-scrollbar-thumb {
411
443
  | background: #555555; border-radius: 4px; border: 1px solid #3c3f41;
412
444
  | }
413
- | .control-panel::-webkit-scrollbar-thumb:hover { background: #666666; }
414
- | .control-panel { scrollbar-width: thin; scrollbar-color: #555555 #3c3f41; }
445
+ | .control-panel::-webkit-scrollbar-thumb:hover,
446
+ | .style-parameters-body::-webkit-scrollbar-thumb:hover { background: #666666; }
447
+ | .control-panel,
448
+ | .style-parameters-body { scrollbar-width: thin; scrollbar-color: #555555 #3c3f41; }
415
449
  | .control-panel.active { display: flex; }
416
450
  | .control-panel-header {
417
451
  | flex-shrink: 0; border-bottom: 1px solid #555555; padding-bottom: 8px; margin-bottom: 8px;
@@ -156,7 +156,7 @@ javascript:
156
156
  let originalStyle = null;
157
157
  let styleParameterDefinitions = new Map();
158
158
  let styleParameterValues = {};
159
- let parameterizedUrlPrefixes = new Set();
159
+ let parameterizedUrlRules = [];
160
160
 
161
161
  const toDomId = (prefix, id) => `${prefix}-${String(id).replace(/[^a-zA-Z0-9_-]/g, '_')}`;
162
162
  const noCacheRequestOptions = () => mapCacheDisabled ? {cache: 'no-store'} : undefined;
@@ -169,7 +169,8 @@ javascript:
169
169
  return [];
170
170
  };
171
171
 
172
- const inferParameterInputType = (name) => /(^|_|-)(time|date|datetime)($|_|-)/i.test(name) ? 'datetime-local' : 'text';
172
+ const isTemporalParameter = (name) => /(^|_|-)(time|date|datetime)($|_|-)/i.test(name);
173
+ const inferParameterInputType = (name) => isTemporalParameter(name) ? 'datetime-local' : 'text';
173
174
 
174
175
  const parameterInputToQueryValue = (name, value) => {
175
176
  if (!value) return '';
@@ -211,9 +212,24 @@ javascript:
211
212
  return prefix ? new URL(prefix, window.location.href).toString() : null;
212
213
  };
213
214
 
214
- const rememberParameterizedUrl = (template) => {
215
+ const rememberParameterizedUrl = (template, parameterNames) => {
216
+ const names = normalizeQueryParams(parameterNames);
217
+ if (!names.length) return;
218
+
215
219
  const prefix = urlPrefixFromTemplate(template);
216
- prefix && parameterizedUrlPrefixes.add(prefix);
220
+ if (!prefix) return;
221
+
222
+ const existing = parameterizedUrlRules.find(rule => rule.prefix === prefix);
223
+ if (existing) {
224
+ names.forEach(name => existing.parameterNames.add(name));
225
+ } else {
226
+ parameterizedUrlRules.push({prefix, parameterNames: new Set(names)});
227
+ }
228
+ };
229
+
230
+ const rememberParameterizedSourceUrls = (sourceDef, parameterNames) => {
231
+ [sourceDef?.url, sourceDef?.meta_url, sourceDef?.data].forEach(url => rememberParameterizedUrl(url, parameterNames));
232
+ (sourceDef?.tiles || []).forEach(tileUrl => rememberParameterizedUrl(tileUrl, parameterNames));
217
233
  };
218
234
 
219
235
  const appendStyleParametersToUrl = (resourceUrl, values = styleParameterValues, parameterNames = Object.keys(values)) => {
@@ -242,16 +258,13 @@ javascript:
242
258
  const params = sourceDeclaredParameters(sourceDef);
243
259
  params.forEach(name => mergeStyleParameterDefinition(name, sourceName));
244
260
 
245
- if (params.length) {
246
- [sourceDef.url, sourceDef.meta_url, sourceDef.data].forEach(rememberParameterizedUrl);
247
- (sourceDef.tiles || []).forEach(rememberParameterizedUrl);
248
- }
261
+ rememberParameterizedSourceUrls(sourceDef, params);
249
262
  });
250
263
  };
251
264
 
252
265
  const fetchSourceMetadata = async (url) => {
253
266
  try {
254
- const response = await fetch(appendStyleParametersToUrl(url), noCacheRequestOptions());
267
+ const response = await fetch(url, noCacheRequestOptions());
255
268
  return response.ok ? response.json() : null;
256
269
  } catch (e) {
257
270
  console.warn('Could not inspect source metadata:', url, e);
@@ -272,7 +285,9 @@ javascript:
272
285
 
273
286
  const params = normalizeQueryParams(metadata.query_params || metadata.queryParams);
274
287
  params.forEach(name => mergeStyleParameterDefinition(name, sourceName));
275
- (metadata.tiles || []).forEach(rememberParameterizedUrl);
288
+ const parameterNames = getSourceParameterNames(sourceName, sourceDef);
289
+ rememberParameterizedSourceUrls(sourceDef, parameterNames);
290
+ (metadata.tiles || []).forEach(tileUrl => rememberParameterizedUrl(tileUrl, parameterNames));
276
291
  }
277
292
  }));
278
293
  };
@@ -297,6 +312,53 @@ javascript:
297
312
  return values;
298
313
  };
299
314
 
315
+ const normalizeLocalizedLabel = (value) => {
316
+ if (!value) return null;
317
+ if (typeof value === 'string') return value;
318
+ if (typeof value === 'object') return value.title || value.name || value.label || null;
319
+ return String(value);
320
+ };
321
+
322
+ const getLocalizedMetadataLabel = (id, style = originalStyle) => {
323
+ const locale = style?.metadata?.locale;
324
+ if (!locale || !id) return id;
325
+
326
+ for (const lang of ['en-US', 'en', 'ru']) {
327
+ const label = normalizeLocalizedLabel(locale[lang]?.[id]);
328
+ if (label) return label;
329
+ }
330
+
331
+ for (const lang in locale) {
332
+ const label = normalizeLocalizedLabel(locale[lang]?.[id]);
333
+ if (label) return label;
334
+ }
335
+
336
+ return id;
337
+ };
338
+
339
+ const compactList = (items, limit = 3) => {
340
+ const unique = [...new Set(items.filter(Boolean))];
341
+ return unique.length > limit
342
+ ? `${unique.slice(0, limit).join(', ')} +${unique.length - limit}`
343
+ : unique.join(', ');
344
+ };
345
+
346
+ const getStyleParameterContext = (definition) => {
347
+ const style = originalStyle || currentStyle;
348
+ const sources = [...definition.sources].sort();
349
+ const layers = (style?.layers || []).filter(layer => sources.includes(layer.source));
350
+ const filterIds = [...new Set(layers.map(layer => layer.metadata?.filter_id).filter(Boolean))].sort();
351
+ const filterLabels = filterIds.map(id => getLocalizedMetadataLabel(id, style));
352
+
353
+ return {
354
+ sources,
355
+ layers: layers.map(layer => layer.id).sort(),
356
+ filterLabels: filterLabels.length ? filterLabels : sources,
357
+ sourceSummary: compactList(sources),
358
+ filterSummary: compactList(filterLabels.length ? filterLabels : sources)
359
+ };
360
+ };
361
+
300
362
  const renderStyleParameterControls = () => {
301
363
  const panel = document.getElementById('style-parameters-panel');
302
364
  const fields = document.getElementById('style-parameter-fields');
@@ -309,24 +371,79 @@ javascript:
309
371
  fields.innerHTML = '';
310
372
 
311
373
  definitions.forEach(definition => {
312
- const row = document.createElement('label');
374
+ const row = document.createElement('div');
313
375
  row.className = 'style-parameter-row';
314
- row.htmlFor = `style-param-${definition.name}`;
376
+ const context = getStyleParameterContext(definition);
315
377
 
316
- const label = document.createElement('span');
378
+ const header = document.createElement('div');
379
+ header.className = 'style-parameter-header';
380
+ const label = document.createElement('label');
317
381
  label.className = 'style-parameter-label';
382
+ label.htmlFor = `style-param-${definition.name}`;
318
383
  label.textContent = definition.name;
384
+ const counts = document.createElement('span');
385
+ counts.className = 'style-parameter-counts';
386
+ counts.textContent = `${context.sources.length} src / ${context.layers.length} lyr`;
387
+ counts.title = `${context.sources.length} sources, ${context.layers.length} layers`;
388
+ header.appendChild(label);
389
+ header.appendChild(counts);
319
390
 
320
391
  const input = document.createElement('input');
321
392
  input.className = 'style-parameter-input';
322
393
  input.id = `style-param-${definition.name}`;
323
- input.type = inferParameterInputType(definition.name);
394
+ input.type = isTemporalParameter(definition.name) ? 'text' : inferParameterInputType(definition.name);
324
395
  input.value = queryValueToParameterInput(definition.name, styleParameterValues[definition.name]);
325
396
  input.dataset.parameterName = definition.name;
326
- input.title = [...definition.sources].join(', ');
397
+ input.title = [
398
+ `Sources: ${context.sources.join(', ')}`,
399
+ `Layers: ${context.layers.join(', ')}`
400
+ ].join('\n');
401
+ if (isTemporalParameter(definition.name)) {
402
+ input.classList.add('temporal');
403
+ input.readOnly = true;
404
+ input.placeholder = 'Select date and time';
405
+ input.setAttribute('aria-haspopup', 'dialog');
406
+ input.addEventListener('click', () => window.TemporalPicker.open(input));
407
+ input.addEventListener('focus', () => window.TemporalPicker.open(input));
408
+ input.addEventListener('keydown', event => {
409
+ if (event.key === 'Enter' || event.key === ' ') {
410
+ event.preventDefault();
411
+ window.TemporalPicker.open(input);
412
+ }
413
+ });
414
+ }
327
415
 
328
- row.appendChild(label);
329
- row.appendChild(input);
416
+ const parameterControl = document.createElement('div');
417
+ parameterControl.className = isTemporalParameter(definition.name)
418
+ ? 'style-parameter-input-group temporal'
419
+ : 'style-parameter-input-group';
420
+ parameterControl.appendChild(input);
421
+
422
+ const contextBlock = document.createElement('div');
423
+ contextBlock.className = 'style-parameter-context';
424
+ contextBlock.title = input.title;
425
+
426
+ const usedBy = document.createElement('span');
427
+ usedBy.className = 'style-parameter-context-row';
428
+ const usedByKey = document.createElement('span');
429
+ usedByKey.className = 'style-parameter-context-key';
430
+ usedByKey.textContent = 'Used by';
431
+ usedBy.appendChild(usedByKey);
432
+ usedBy.appendChild(document.createTextNode(` ${context.filterSummary || 'No linked layers'}`));
433
+ contextBlock.appendChild(usedBy);
434
+
435
+ const sourceInfo = document.createElement('span');
436
+ sourceInfo.className = 'style-parameter-context-row';
437
+ const sourceKey = document.createElement('span');
438
+ sourceKey.className = 'style-parameter-context-key';
439
+ sourceKey.textContent = 'Sources';
440
+ sourceInfo.appendChild(sourceKey);
441
+ sourceInfo.appendChild(document.createTextNode(` ${context.sourceSummary || 'None'}`));
442
+ contextBlock.appendChild(sourceInfo);
443
+
444
+ row.appendChild(header);
445
+ row.appendChild(parameterControl);
446
+ row.appendChild(contextBlock);
330
447
  fields.appendChild(row);
331
448
  });
332
449
 
@@ -348,7 +465,7 @@ javascript:
348
465
  const initializeStyleParameters = async (style) => {
349
466
  styleParameterDefinitions = new Map();
350
467
  styleParameterValues = {};
351
- parameterizedUrlPrefixes = new Set();
468
+ parameterizedUrlRules = [];
352
469
 
353
470
  collectStyleSourceParameters(style);
354
471
  styleParameterDefinitions.forEach((definition, name) => {
@@ -389,13 +506,25 @@ javascript:
389
506
  return modifiedStyle;
390
507
  };
391
508
 
392
- const shouldPatchRequestUrl = (resourceUrl) => {
393
- if (!Object.values(styleParameterValues).some(value => value !== undefined && value !== null && value !== '')) return false;
509
+ const parameterizedUrlRuleFor = (resourceUrl) => {
394
510
  const absolute = new URL(resourceUrl, window.location.href).toString();
395
- return absolute.includes('/rb_tiles/') || [...parameterizedUrlPrefixes].some(prefix => absolute.startsWith(prefix));
511
+ return parameterizedUrlRules
512
+ .filter(rule => absolute.startsWith(rule.prefix))
513
+ .sort((a, b) => b.prefix.length - a.prefix.length)[0] || null;
396
514
  };
397
515
 
398
- const patchRequestUrl = (resourceUrl) => shouldPatchRequestUrl(resourceUrl) ? appendStyleParametersToUrl(resourceUrl) : resourceUrl;
516
+ const patchRequestUrl = (resourceUrl) => {
517
+ const rule = parameterizedUrlRuleFor(resourceUrl);
518
+ if (!rule) return resourceUrl;
519
+
520
+ const parameterNames = [...rule.parameterNames];
521
+ if (!parameterNames.some(name => {
522
+ const value = styleParameterValues[name];
523
+ return value !== undefined && value !== null && value !== '';
524
+ })) return resourceUrl;
525
+
526
+ return appendStyleParametersToUrl(resourceUrl, styleParameterValues, parameterNames);
527
+ };
399
528
 
400
529
  const BOTTOM_OVERLAY_IDS = ['loading-indicator'];
401
530
  const BOTTOM_OVERLAY_BASE_OFFSET = 20;
@@ -50,9 +50,23 @@ RSpec.describe MapLibrePreview do
50
50
  expect(last_response.body).to include('basemapOpacity')
51
51
  expect(last_response.body).to include('terrainExaggeration')
52
52
  expect(last_response.body).to include('styleParameterDefinitions')
53
+ expect(last_response.body).to include('parameterizedUrlRules')
53
54
  expect(last_response.body).to include('sourceDeclaredParameters')
54
55
  expect(last_response.body).to include('collectSourceMetadataParameters')
55
56
  expect(last_response.body).to include('applyStyleParametersToStyle')
57
+ expect(last_response.body).to include('rememberParameterizedSourceUrls')
58
+ expect(last_response.body).to include('parameterizedUrlRuleFor')
59
+ expect(last_response.body).to include('getStyleParameterContext')
60
+ expect(last_response.body).to include('isTemporalParameter')
61
+ expect(last_response.body).to include('/css/temporal_picker.css')
62
+ expect(last_response.body).to include('/js/temporal_picker.js')
63
+ expect(last_response.body).to include('window.TemporalPicker.open')
64
+ expect(last_response.body).to include('style-parameter-input-group')
65
+ expect(last_response.body).to include('style-parameter-counts')
66
+ expect(last_response.body).to include('style-parameter-context')
67
+ expect(last_response.body).to include('Used by')
68
+ expect(last_response.body).to include('Sources:')
69
+ expect(last_response.body).not_to include("absolute.includes('/rb_tiles/')")
56
70
  expect(last_response.body).to include('layoutBottomOverlays')
57
71
  expect(last_response.body).to include('showCollisionBoxes')
58
72
  expect(last_response.body).to include('showOverdrawInspector')
@@ -68,7 +82,7 @@ RSpec.describe MapLibrePreview do
68
82
  end
69
83
 
70
84
  it 'serves all required JavaScript modules' do
71
- %w[/js/overlay_layout.js /js/filters.js /js/contour.js /js/tilegrid.js /vendor/maplibre-gl/maplibre-gl.js /vendor/maplibre-contour/index.min.js /vendor/d3/d3.v7.min.js].each do |js_file|
85
+ %w[/js/overlay_layout.js /js/filters.js /js/contour.js /js/tilegrid.js /js/temporal_picker.js /vendor/maplibre-gl/maplibre-gl.js /vendor/maplibre-contour/index.min.js /vendor/d3/d3.v7.min.js].each do |js_file|
72
86
  get js_file
73
87
  expect(last_response).to be_ok
74
88
  expect(last_response.content_type).to include('javascript')
@@ -77,11 +91,13 @@ RSpec.describe MapLibrePreview do
77
91
  end
78
92
 
79
93
  it 'serves required stylesheets' do
80
- get '/vendor/maplibre-gl/maplibre-gl.css'
94
+ %w[/vendor/maplibre-gl/maplibre-gl.css /css/temporal_picker.css].each do |css_file|
95
+ get css_file
81
96
 
82
- expect(last_response).to be_ok
83
- expect(last_response.content_type).to include('text/css')
84
- expect(last_response.body).not_to be_empty
97
+ expect(last_response).to be_ok
98
+ expect(last_response.content_type).to include('text/css')
99
+ expect(last_response.body).not_to be_empty
100
+ end
85
101
  end
86
102
  end
87
103
 
@@ -93,10 +109,12 @@ RSpec.describe MapLibrePreview do
93
109
  body = last_response.body
94
110
 
95
111
  expect(body).to include('/vendor/maplibre-gl/maplibre-gl.css')
112
+ expect(body).to include('/css/temporal_picker.css')
96
113
  expect(body).to include('/vendor/maplibre-gl/maplibre-gl.js')
97
114
  expect(body).to include('/vendor/maplibre-contour/index.min.js')
98
115
  expect(body).to include('/vendor/d3/d3.v7.min.js')
99
116
  expect(body).to include('/js/overlay_layout.js')
117
+ expect(body).to include('/js/temporal_picker.js')
100
118
  expect(body).not_to include('unpkg.com')
101
119
  expect(body).not_to include('d3js.org')
102
120
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: maplibre-preview
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.8.0
4
+ version: 1.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alexander Ludov
@@ -201,9 +201,11 @@ files:
201
201
  - bin/maplibre-preview
202
202
  - docs/README_RU.md
203
203
  - lib/maplibre-preview.rb
204
+ - lib/maplibre-preview/public/css/temporal_picker.css
204
205
  - lib/maplibre-preview/public/js/contour.js
205
206
  - lib/maplibre-preview/public/js/filters.js
206
207
  - lib/maplibre-preview/public/js/overlay_layout.js
208
+ - lib/maplibre-preview/public/js/temporal_picker.js
207
209
  - lib/maplibre-preview/public/js/tilegrid.js
208
210
  - lib/maplibre-preview/public/vendor/d3/d3.v7.min.js
209
211
  - lib/maplibre-preview/public/vendor/maplibre-contour/index.min.js