maplibre-preview 1.8.0 → 1.9.1

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: 4804849d0779e4a60b497a251fa9ad350615671a328d446b383c991b6fda041a
4
+ data.tar.gz: 3349b9e73f70f57d3dd83d3c3586c44a5180ec9cc5d93d5932e4b246f82a1003
5
5
  SHA512:
6
- metadata.gz: c75eede1968d1ddfaa8c388de5c827bf6f8e27f5bcc628938aca1c61f7ccf2896cb70f9a498f6262f352a4c1a211881a87ac09133da2445cd3e6c02f6fa42aac
7
- data.tar.gz: 435d1192991bacd4730d547eb3f580f99c8d287fabb29e221432a44b309c6b73e25c198f6abea453e0a9ab0ba5d4de6b83adff1fcd4e68cbd73ec03c67e9f22e
6
+ metadata.gz: c8e02627ba370010afa83daf7e0d2770d6d0ea3dc919761b976789cbcc9f60f88735196c1546c50467e2eca8b468fe0212160b6dd20b88c0982b5787a4313a92
7
+ data.tar.gz: 823cfad1bf6bb3d5f2602d4a592654189b40fb7d594430409bf3d8610827e04069867901a5044abf9408049b29698b2bcd7fa72ff6f36e3e8be8db83e48a6a19
data/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.9.1] - 2026-05-15
4
+
5
+ ### Security
6
+ - **Feature popup tooltips** - render style and feature tooltip values as DOM text instead of raw HTML to prevent script execution from untrusted style or tile data
7
+
8
+ ## [1.9.0] - 2026-05-14
9
+
10
+ ### Added
11
+ - **Temporal parameter picker** - added a custom calendar and time picker for date/time-like style parameters
12
+ - **Style parameter context** - show source/layer counts and localized usage hints for each detected style parameter
13
+
14
+ ### Changed
15
+ - **Style parameter URL matching** - track source-specific parameterized URL rules and append only the parameters declared for the matching source
16
+ - **Temporal parameter inputs** - use the custom picker for temporal parameters while keeping query values normalized to epoch seconds
17
+
18
+ ### Fixed
19
+ - **Source metadata inspection** - fetch source metadata without pre-appending parameter values so metadata-declared parameters can be discovered reliably
20
+ - **Parameterized tile requests** - removed the broad `/rb_tiles/` heuristic in favor of explicit source metadata and URL prefix matching
21
+
3
22
  ## [1.8.0] - 2026-05-14
4
23
 
5
24
  ### 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.1'
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;
@@ -645,20 +774,39 @@ javascript:
645
774
  return modifiedStyle;
646
775
  };
647
776
 
648
- const popupFeature = (features, e, popup) => {
649
- const tt = (tooltip, feat) => tooltip?.replace(/\{([^}]+)\}/g, (match, prop) => {
650
- let value = feat;
651
- for (const p of prop.split('.')) {
652
- value = value?.[p];
653
- }
654
- return typeof value === 'object' ? JSON.stringify(value, null, 2) : (value || '');
777
+ const tooltipValue = (feat, prop) => {
778
+ let value = feat;
779
+ for (const p of prop.split('.')) {
780
+ value = value?.[p];
781
+ }
782
+ return typeof value === 'object' ? JSON.stringify(value, null, 2) : (value || '');
783
+ };
784
+
785
+ const tooltipText = (tooltip, feat) => tooltip?.replace(/\{([^}]+)\}/g, (match, prop) => tooltipValue(feat, prop));
786
+
787
+ const defaultTooltipText = (feat) => [
788
+ `"id": ${tooltipValue(feat, 'id')}`,
789
+ `"source": ${tooltipValue(feat, 'source')}`,
790
+ `"source-layer": ${tooltipValue(feat, 'sourceLayer')}`,
791
+ `"properties": ${tooltipValue(feat, 'properties')}`
792
+ ].join('\n');
793
+
794
+ const createPopupContent = (tooltips) => {
795
+ const fragment = document.createDocumentFragment();
796
+ tooltips.forEach((tooltip, index) => {
797
+ if (index > 0) fragment.appendChild(document.createElement('br'));
798
+ const item = document.createElement('pre');
799
+ item.textContent = tooltip;
800
+ fragment.appendChild(item);
655
801
  });
802
+ return fragment;
803
+ };
656
804
 
805
+ const popupFeature = (features, e, popup) => {
657
806
  const tooltips = features.map((feat) =>
658
- tt(feat.layer.metadata?.tooltip, feat) ||
659
- tt(`<pre>"id": {id},\n"source": {source},\n"source-layer": {sourceLayer},\n"properties": {properties}</pre>`, feat)
807
+ tooltipText(feat.layer.metadata?.tooltip, feat) || defaultTooltipText(feat)
660
808
  );
661
- popup.setLngLat(e.lngLat).setHTML(tooltips.join('<br>')).addTo(map);
809
+ popup.setLngLat(e.lngLat).setDOMContent(createPopupContent(tooltips)).addTo(map);
662
810
  };
663
811
 
664
812
  const initializeMap = async () => {
@@ -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')
@@ -67,8 +81,18 @@ RSpec.describe MapLibrePreview do
67
81
  expect(last_response.body).to include('window.toggleStyleParametersPanel')
68
82
  end
69
83
 
84
+ it 'renders feature popup tooltips as DOM text instead of raw HTML' do
85
+ get '/?style_url=https://example.com/style.json'
86
+ expect(last_response).to be_ok
87
+
88
+ expect(last_response.body).to include('createPopupContent')
89
+ expect(last_response.body).to include('item.textContent = tooltip')
90
+ expect(last_response.body).to include('setDOMContent(createPopupContent(tooltips))')
91
+ expect(last_response.body).not_to include('setHTML(tooltips')
92
+ end
93
+
70
94
  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|
95
+ %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
96
  get js_file
73
97
  expect(last_response).to be_ok
74
98
  expect(last_response.content_type).to include('javascript')
@@ -77,11 +101,13 @@ RSpec.describe MapLibrePreview do
77
101
  end
78
102
 
79
103
  it 'serves required stylesheets' do
80
- get '/vendor/maplibre-gl/maplibre-gl.css'
104
+ %w[/vendor/maplibre-gl/maplibre-gl.css /css/temporal_picker.css].each do |css_file|
105
+ get css_file
81
106
 
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
107
+ expect(last_response).to be_ok
108
+ expect(last_response.content_type).to include('text/css')
109
+ expect(last_response.body).not_to be_empty
110
+ end
85
111
  end
86
112
  end
87
113
 
@@ -93,10 +119,12 @@ RSpec.describe MapLibrePreview do
93
119
  body = last_response.body
94
120
 
95
121
  expect(body).to include('/vendor/maplibre-gl/maplibre-gl.css')
122
+ expect(body).to include('/css/temporal_picker.css')
96
123
  expect(body).to include('/vendor/maplibre-gl/maplibre-gl.js')
97
124
  expect(body).to include('/vendor/maplibre-contour/index.min.js')
98
125
  expect(body).to include('/vendor/d3/d3.v7.min.js')
99
126
  expect(body).to include('/js/overlay_layout.js')
127
+ expect(body).to include('/js/temporal_picker.js')
100
128
  expect(body).not_to include('unpkg.com')
101
129
  expect(body).not_to include('d3js.org')
102
130
  end
metadata CHANGED
@@ -1,14 +1,14 @@
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.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alexander Ludov
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-05-14 00:00:00.000000000 Z
11
+ date: 2026-05-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack
@@ -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