maplibre-preview 1.7.2 → 1.8.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: ab6c314ff00f531f6088b82f2d11b44206fe947f407d512f725ea5efd4df10d1
4
- data.tar.gz: 2698dbd6a6c458f4c475d71b5dc549bbfde888c96357c6d18c74060b666ffca7
3
+ metadata.gz: 61c7dd8ecf3753aa43ba5272cc06dab258cbb643824e74336e21e07680795cd6
4
+ data.tar.gz: 6a008af2ffe1b2f3331dc752ab36041767299cc38271f9f9433567f268952198
5
5
  SHA512:
6
- metadata.gz: deefd8864adfd9cdd6189f10834bab986c11cfd50e1413eca36f57238ff3d6d3a98eda71bb370e0c14699542abcc9bb8bdde4b271f2693bd802777184539dc9f
7
- data.tar.gz: 599cdcbc90b314f3bdc21a765fe6817094cec77073843896fca53b77a7e9be90d5a8ccbfe331dc969dd50fd7dcccb3e3e5721095acead56ee958833d13867a6a
6
+ metadata.gz: c75eede1968d1ddfaa8c388de5c827bf6f8e27f5bcc628938aca1c61f7ccf2896cb70f9a498f6262f352a4c1a211881a87ac09133da2445cd3e6c02f6fa42aac
7
+ data.tar.gz: 435d1192991bacd4730d547eb3f580f99c8d287fabb29e221432a44b309c6b73e25c198f6abea453e0a9ab0ba5d4de6b83adff1fcd4e68cbd73ec03c67e9f22e
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.8.0] - 2026-05-14
4
+
5
+ ### Added
6
+ - **Movable overlay windows** - added `OverlayLayoutManager` for shared drag, edge clamping, snapping and persisted positions of movable map UI panels
7
+ - **Window layout reset** - added `Reset window layout` action in Map Settings to clear saved panel positions from local storage and restore defaults
8
+
9
+ ### Changed
10
+ - **Panel layout foundation** - moved Map Settings, Style Controls, Style Parameters, Performance, Elevation Profile and Tile Boundaries to the shared overlay layout manager
11
+ - **Default panel positions** - kept static map controls attached to map edges and placed Map Settings in the top-left default position
12
+
13
+ ### Fixed
14
+ - **Overlay visibility** - fixed managed panel z-index so map settings, filter controls and static MapLibre controls remain visible
15
+ - **Elevation profile frame** - fixed profile window positioning and sizing while dragging near the bottom edge
16
+
3
17
  ## [1.7.2] - 2026-05-14
4
18
 
5
19
  ### Changed
@@ -0,0 +1,415 @@
1
+ class OverlayLayoutManager {
2
+ constructor(options = {}) {
3
+ this.storageKey = options.storageKey || 'maplibre-preview:overlay-layout:v3';
4
+ this.snapThreshold = options.snapThreshold || 32;
5
+ this.mobileBreakpoint = options.mobileBreakpoint || 768;
6
+ this.edgeGap = options.edgeGap || 10;
7
+ this.getReservedBounds = options.getReservedBounds || (() => ({}));
8
+ this.panels = new Map();
9
+ this.state = this.loadState();
10
+ this.drag = null;
11
+ this.zIndex = 1100;
12
+ this.resizeHandler = () => this.refreshBounds();
13
+ window.addEventListener('resize', this.resizeHandler);
14
+ }
15
+
16
+ registerPanel(config) {
17
+ const element = this.resolveElement(config.element);
18
+ if (!element) return null;
19
+
20
+ const id = config.id || element.id;
21
+ if (!id) return null;
22
+
23
+ const existing = this.panels.get(id);
24
+ if (existing) this.unregisterPanel(id);
25
+
26
+ const panel = {
27
+ id,
28
+ element,
29
+ handle: this.resolveHandle(element, config.handleSelector),
30
+ defaultAnchor: config.defaultAnchor || 'left',
31
+ defaultOffset: config.defaultOffset || {x: 0, y: 0},
32
+ snap: config.snap !== false,
33
+ lockSizeOnDrag: config.lockSizeOnDrag === true,
34
+ movable: config.movable !== false,
35
+ cleanup: []
36
+ };
37
+
38
+ element.dataset.overlayPanelId = id;
39
+ element.classList.add('overlay-managed-panel');
40
+ this.panels.set(id, panel);
41
+
42
+ if (panel.movable && panel.handle) {
43
+ this.prepareHandle(panel);
44
+ }
45
+
46
+ this.applyStoredOrDefaultPosition(panel);
47
+ return panel;
48
+ }
49
+
50
+ unregisterPanel(id) {
51
+ const panel = this.panels.get(id);
52
+ if (!panel) return;
53
+
54
+ panel.cleanup.forEach(fn => fn());
55
+ panel.element.classList.remove('overlay-managed-panel', 'overlay-dragging');
56
+ panel.element.removeAttribute('data-overlay-panel-id');
57
+ this.panels.delete(id);
58
+ }
59
+
60
+ movePanelTo(id, anchor) {
61
+ const panel = this.panels.get(id);
62
+ if (!panel) return;
63
+
64
+ const nextState = {mode: 'anchor', anchor, offset: {x: 0, y: 0}};
65
+ this.setPanelState(panel, nextState);
66
+ }
67
+
68
+ resetPanel(id) {
69
+ const panel = this.panels.get(id);
70
+ if (!panel) return;
71
+
72
+ delete this.state[id];
73
+ this.persistState();
74
+ this.applyStoredOrDefaultPosition(panel);
75
+ }
76
+
77
+ resetLayout() {
78
+ this.state = {};
79
+ this.clearStoredState();
80
+ this.panels.forEach(panel => this.applyStoredOrDefaultPosition(panel));
81
+ }
82
+
83
+ refreshPanel(id) {
84
+ const panel = this.panels.get(id);
85
+ if (!panel) return;
86
+ if (this.drag?.panel?.id === id) return;
87
+
88
+ const panelState = this.state[id] || this.defaultState(panel);
89
+ this.applyPanelState(panel, panelState);
90
+ }
91
+
92
+ refreshBounds() {
93
+ if (this.drag) return;
94
+ this.panels.forEach(panel => this.refreshPanel(panel.id));
95
+ }
96
+
97
+ destroy() {
98
+ window.removeEventListener('resize', this.resizeHandler);
99
+ [...this.panels.keys()].forEach(id => this.unregisterPanel(id));
100
+ }
101
+
102
+ resolveElement(elementOrSelector) {
103
+ if (!elementOrSelector) return null;
104
+ return typeof elementOrSelector === 'string'
105
+ ? document.querySelector(elementOrSelector)
106
+ : elementOrSelector;
107
+ }
108
+
109
+ resolveHandle(element, selector) {
110
+ return selector ? element.querySelector(selector) : element;
111
+ }
112
+
113
+ prepareHandle(panel) {
114
+ const pointerDown = event => this.onPointerDown(event, panel);
115
+ const keyDown = event => this.onHandleKeyDown(event, panel);
116
+
117
+ panel.handle.classList.add('overlay-panel-handle');
118
+ panel.handle.tabIndex = panel.handle.tabIndex >= 0 ? panel.handle.tabIndex : 0;
119
+ if (!panel.handle.querySelector('button, input, select, textarea, a')) {
120
+ panel.handle.setAttribute('role', panel.handle.getAttribute('role') || 'button');
121
+ }
122
+ panel.handle.setAttribute('aria-label', panel.handle.getAttribute('aria-label') || `Move ${panel.id} panel`);
123
+ panel.handle.addEventListener('pointerdown', pointerDown);
124
+ panel.handle.addEventListener('keydown', keyDown);
125
+ panel.cleanup.push(() => panel.handle.removeEventListener('pointerdown', pointerDown));
126
+ panel.cleanup.push(() => panel.handle.removeEventListener('keydown', keyDown));
127
+ }
128
+
129
+ onPointerDown(event, panel) {
130
+ if (event.button !== 0 || !panel.movable || this.isInteractiveTarget(event.target)) return;
131
+
132
+ const rect = panel.element.getBoundingClientRect();
133
+ const pointerMove = moveEvent => this.onPointerMove(moveEvent);
134
+ const pointerUp = upEvent => this.onPointerUp(upEvent);
135
+
136
+ if (panel.lockSizeOnDrag) {
137
+ panel.element.style.width = `${Math.round(rect.width)}px`;
138
+ panel.element.style.height = `${Math.round(rect.height)}px`;
139
+ }
140
+
141
+ this.drag = {
142
+ panel,
143
+ pointerId: event.pointerId,
144
+ startX: event.clientX,
145
+ startY: event.clientY,
146
+ originX: rect.left,
147
+ originY: rect.top,
148
+ pointerMove,
149
+ pointerUp
150
+ };
151
+
152
+ panel.element.classList.add('overlay-dragging');
153
+ this.bringToFront(panel);
154
+ try {
155
+ panel.handle.setPointerCapture?.(event.pointerId);
156
+ } catch (e) {
157
+ // Some synthetic pointer events do not create an active pointer capture target.
158
+ }
159
+
160
+ panel.handle.addEventListener('pointermove', pointerMove);
161
+ panel.handle.addEventListener('pointerup', pointerUp);
162
+ panel.handle.addEventListener('pointercancel', pointerUp);
163
+ event.preventDefault();
164
+ }
165
+
166
+ onPointerMove(event) {
167
+ if (!this.drag || event.pointerId !== this.drag.pointerId) return;
168
+
169
+ const nextX = this.drag.originX + event.clientX - this.drag.startX;
170
+ const nextY = this.drag.originY + event.clientY - this.drag.startY;
171
+ const position = this.clampPosition(this.drag.panel, nextX, nextY);
172
+
173
+ this.applyPosition(this.drag.panel, position.x, position.y);
174
+ }
175
+
176
+ onPointerUp(event) {
177
+ if (!this.drag || event.pointerId !== this.drag.pointerId) return;
178
+
179
+ const panel = this.drag.panel;
180
+ const rect = panel.element.getBoundingClientRect();
181
+ const nextState = this.stateFromPosition(panel, rect.left, rect.top);
182
+ this.cleanupDrag();
183
+ this.setPanelState(panel, nextState);
184
+ }
185
+
186
+ cleanupDrag() {
187
+ if (!this.drag) return null;
188
+
189
+ const drag = this.drag;
190
+ try {
191
+ drag.panel.handle.releasePointerCapture?.(drag.pointerId);
192
+ } catch (e) {
193
+ // Pointer capture may already be released by the browser.
194
+ }
195
+ drag.panel.handle.removeEventListener('pointermove', drag.pointerMove);
196
+ drag.panel.handle.removeEventListener('pointerup', drag.pointerUp);
197
+ drag.panel.handle.removeEventListener('pointercancel', drag.pointerUp);
198
+ drag.panel.element.classList.remove('overlay-dragging');
199
+ this.drag = null;
200
+ return drag;
201
+ }
202
+
203
+ onHandleKeyDown(event, panel) {
204
+ if (event.key === 'Escape' && this.drag) {
205
+ this.cleanupDrag();
206
+ this.refreshPanel(panel.id);
207
+ return;
208
+ }
209
+
210
+ if (!event.shiftKey || !['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)) return;
211
+
212
+ const rect = panel.element.getBoundingClientRect();
213
+ const step = event.altKey ? 32 : 8;
214
+ const delta = {
215
+ ArrowLeft: [-step, 0],
216
+ ArrowRight: [step, 0],
217
+ ArrowUp: [0, -step],
218
+ ArrowDown: [0, step]
219
+ }[event.key];
220
+ const position = this.clampPosition(panel, rect.left + delta[0], rect.top + delta[1]);
221
+
222
+ this.setPanelState(panel, {mode: 'free', x: position.x, y: position.y});
223
+ event.preventDefault();
224
+ }
225
+
226
+ isInteractiveTarget(target) {
227
+ return target?.closest?.('button, input, select, textarea, a, label, [data-overlay-ignore-drag="true"]');
228
+ }
229
+
230
+ bringToFront(panel) {
231
+ this.zIndex += 1;
232
+ panel.element.style.zIndex = String(this.zIndex);
233
+ }
234
+
235
+ loadState() {
236
+ try {
237
+ return JSON.parse(localStorage.getItem(this.storageKey) || '{}');
238
+ } catch (e) {
239
+ console.warn('OverlayLayoutManager: could not load layout state', e);
240
+ return {};
241
+ }
242
+ }
243
+
244
+ persistState() {
245
+ try {
246
+ localStorage.setItem(this.storageKey, JSON.stringify(this.state));
247
+ } catch (e) {
248
+ console.warn('OverlayLayoutManager: could not persist layout state', e);
249
+ }
250
+ }
251
+
252
+ clearStoredState() {
253
+ try {
254
+ localStorage.removeItem(this.storageKey);
255
+ } catch (e) {
256
+ console.warn('OverlayLayoutManager: could not clear layout state', e);
257
+ }
258
+ }
259
+
260
+ defaultState(panel) {
261
+ const offset = typeof panel.defaultOffset === 'function'
262
+ ? panel.defaultOffset(panel)
263
+ : panel.defaultOffset;
264
+
265
+ return {
266
+ mode: 'anchor',
267
+ anchor: panel.defaultAnchor,
268
+ offset: {...offset}
269
+ };
270
+ }
271
+
272
+ applyStoredOrDefaultPosition(panel) {
273
+ const panelState = this.state[panel.id] || this.defaultState(panel);
274
+ this.applyPanelState(panel, panelState);
275
+ }
276
+
277
+ setPanelState(panel, panelState) {
278
+ this.state[panel.id] = panelState;
279
+ this.persistState();
280
+ this.applyPanelState(panel, panelState);
281
+ }
282
+
283
+ applyPanelState(panel, panelState) {
284
+ const responsiveState = this.isConstrainedViewport() && panelState.mode === 'free'
285
+ ? this.defaultState(panel)
286
+ : panelState;
287
+ const position = responsiveState.mode === 'free'
288
+ ? this.clampPosition(panel, responsiveState.x, responsiveState.y)
289
+ : this.positionForAnchor(panel, responsiveState.anchor, responsiveState.offset || {x: 0, y: 0});
290
+
291
+ this.applyPosition(panel, position.x, position.y);
292
+ }
293
+
294
+ applyPosition(panel, x, y) {
295
+ panel.element.style.position = 'fixed';
296
+ panel.element.style.left = `${Math.round(x)}px`;
297
+ panel.element.style.top = `${Math.round(y)}px`;
298
+ panel.element.style.right = 'auto';
299
+ panel.element.style.bottom = 'auto';
300
+ panel.element.style.transform = 'none';
301
+ }
302
+
303
+ positionForAnchor(panel, anchor, offset) {
304
+ const rect = this.panelRect(panel);
305
+ const viewport = this.viewportBounds();
306
+ const centerX = (viewport.width - rect.width) / 2;
307
+ const centerY = (viewport.height - viewport.reservedBottom - rect.height) / 2;
308
+ let x = centerX;
309
+ let y = centerY;
310
+
311
+ if (anchor === 'left') {
312
+ x = this.edgeGap + (offset.x || 0);
313
+ y = centerY + (offset.y || 0);
314
+ } else if (anchor === 'right') {
315
+ x = viewport.width - rect.width - this.edgeGap + (offset.x || 0);
316
+ y = centerY + (offset.y || 0);
317
+ } else if (anchor === 'top') {
318
+ x = centerX + (offset.x || 0);
319
+ y = this.edgeGap + (offset.y || 0);
320
+ } else if (anchor === 'bottom') {
321
+ x = centerX + (offset.x || 0);
322
+ y = viewport.height - viewport.reservedBottom - rect.height - this.edgeGap + (offset.y || 0);
323
+ } else if (anchor === 'top-left') {
324
+ x = this.edgeGap + (offset.x || 0);
325
+ y = this.edgeGap + (offset.y || 0);
326
+ } else if (anchor === 'top-right') {
327
+ x = viewport.width - rect.width - this.edgeGap + (offset.x || 0);
328
+ y = this.edgeGap + (offset.y || 0);
329
+ } else if (anchor === 'bottom-left') {
330
+ x = this.edgeGap + (offset.x || 0);
331
+ y = viewport.height - viewport.reservedBottom - rect.height - this.edgeGap + (offset.y || 0);
332
+ } else if (anchor === 'bottom-right') {
333
+ x = viewport.width - rect.width - this.edgeGap + (offset.x || 0);
334
+ y = viewport.height - viewport.reservedBottom - rect.height - this.edgeGap + (offset.y || 0);
335
+ }
336
+
337
+ return this.clampPosition(panel, x, y);
338
+ }
339
+
340
+ stateFromPosition(panel, x, y) {
341
+ if (this.isConstrainedViewport()) {
342
+ return {mode: 'anchor', anchor: this.nearestAnchor(panel, x, y).anchor, offset: {x: 0, y: 0}};
343
+ }
344
+
345
+ const nearest = this.nearestAnchor(panel, x, y);
346
+ if (!panel.snap || nearest.distance > this.snapThreshold) {
347
+ const clamped = this.clampPosition(panel, x, y);
348
+ return {mode: 'free', x: clamped.x, y: clamped.y};
349
+ }
350
+
351
+ const rect = this.panelRect(panel);
352
+ const viewport = this.viewportBounds();
353
+ const centerX = (viewport.width - rect.width) / 2;
354
+ const centerY = (viewport.height - viewport.reservedBottom - rect.height) / 2;
355
+ const offset = {x: 0, y: 0};
356
+
357
+ if (nearest.anchor === 'left' || nearest.anchor === 'right') {
358
+ offset.y = y - centerY;
359
+ } else {
360
+ offset.x = x - centerX;
361
+ }
362
+
363
+ return {mode: 'anchor', anchor: nearest.anchor, offset};
364
+ }
365
+
366
+ nearestAnchor(panel, x, y) {
367
+ const rect = this.panelRect(panel);
368
+ const viewport = this.viewportBounds();
369
+ const distances = [
370
+ ['left', x],
371
+ ['right', viewport.width - (x + rect.width)],
372
+ ['top', y],
373
+ ['bottom', viewport.height - viewport.reservedBottom - (y + rect.height)]
374
+ ].map(([anchor, distance]) => ({anchor, distance: Math.abs(distance)}));
375
+
376
+ return distances.sort((a, b) => a.distance - b.distance)[0];
377
+ }
378
+
379
+ clampPosition(panel, x, y) {
380
+ const rect = this.panelRect(panel);
381
+ const viewport = this.viewportBounds();
382
+ const minX = this.edgeGap;
383
+ const minY = this.edgeGap;
384
+ const maxX = Math.max(minX, viewport.width - rect.width - this.edgeGap);
385
+ const maxY = Math.max(minY, viewport.height - viewport.reservedBottom - rect.height - this.edgeGap);
386
+
387
+ return {
388
+ x: Math.min(maxX, Math.max(minX, x)),
389
+ y: Math.min(maxY, Math.max(minY, y))
390
+ };
391
+ }
392
+
393
+ panelRect(panel) {
394
+ const rect = panel.element.getBoundingClientRect();
395
+ return {
396
+ width: rect.width || panel.element.offsetWidth || 1,
397
+ height: rect.height || panel.element.offsetHeight || 1
398
+ };
399
+ }
400
+
401
+ viewportBounds() {
402
+ const reserved = this.getReservedBounds() || {};
403
+ return {
404
+ width: window.innerWidth,
405
+ height: window.innerHeight,
406
+ reservedBottom: Math.max(0, Number(reserved.bottom || 0))
407
+ };
408
+ }
409
+
410
+ isConstrainedViewport() {
411
+ return window.innerWidth < this.mobileBreakpoint;
412
+ }
413
+ }
414
+
415
+ window.OverlayLayoutManager = OverlayLayoutManager;
@@ -44,6 +44,13 @@ class TileGridManager {
44
44
  `;
45
45
 
46
46
  document.getElementById('map-container').appendChild(this.panelContainer);
47
+ window.overlayLayoutManager?.registerPanel({
48
+ id: 'tilegrid',
49
+ element: this.panelContainer,
50
+ handleSelector: '.tilegrid-title',
51
+ defaultAnchor: 'right',
52
+ defaultOffset: {x: 0, y: 0}
53
+ });
47
54
  }
48
55
 
49
56
  setupEventListeners() {
@@ -114,6 +121,7 @@ class TileGridManager {
114
121
 
115
122
  if (this.panelContainer) {
116
123
  this.panelContainer.style.display = this.isVisible ? 'block' : 'none';
124
+ this.isVisible && window.overlayLayoutManager?.refreshPanel('tilegrid');
117
125
  }
118
126
 
119
127
  const btn = document.getElementById('tilegrid-mode-btn');
@@ -163,6 +171,7 @@ class TileGridManager {
163
171
  }
164
172
 
165
173
  if (this.panelContainer && this.panelContainer.parentNode) {
174
+ window.overlayLayoutManager?.unregisterPanel('tilegrid');
166
175
  this.panelContainer.parentNode.removeChild(this.panelContainer);
167
176
  }
168
177
 
@@ -1,3 +1,3 @@
1
1
  module MapLibrePreview
2
- VERSION = '1.7.2'
2
+ VERSION = '1.8.0'
3
3
  end
@@ -10,6 +10,7 @@ html
10
10
  script[src="#{base_path}/vendor/maplibre-gl/maplibre-gl.js"]
11
11
  script[src="#{base_path}/vendor/maplibre-contour/index.min.js"]
12
12
  script[src="#{base_path}/vendor/d3/d3.v7.min.js"]
13
+ script[src="#{base_path}/js/overlay_layout.js"]
13
14
  script[src="#{base_path}/js/filters.js"]
14
15
  script[src="#{base_path}/js/contour.js"]
15
16
  script[src="#{base_path}/js/tilegrid.js"]
@@ -174,7 +175,7 @@ html
174
175
  | .controls a { color: #6897bb; text-decoration: none; margin-right: 15px; }
175
176
  | .controls a:hover { text-decoration: underline; }
176
177
  | .layer-controls-wrapper {
177
- | position: absolute; top: 50%; left: 10px; transform: translateY(-50%); z-index: 1000;
178
+ | position: static; z-index: 1000;
178
179
  | display: flex; align-items: flex-start; gap: 0;
179
180
  | }
180
181
  | .layer-controls {
@@ -204,6 +205,22 @@ html
204
205
  | .control-section-header {
205
206
  | display: flex; align-items: center; justify-content: space-between; gap: 8px;
206
207
  | }
208
+ | .overlay-managed-panel {
209
+ | z-index: 1000; will-change: left, top;
210
+ | }
211
+ | .overlay-dragging {
212
+ | cursor: grabbing;
213
+ | user-select: none;
214
+ | transition: none !important;
215
+ | }
216
+ | .overlay-panel-handle {
217
+ | cursor: grab; touch-action: none; user-select: none;
218
+ | -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none;
219
+ | }
220
+ | .overlay-panel-handle:focus-visible {
221
+ | outline: 1px solid #6897bb;
222
+ | outline-offset: 2px;
223
+ | }
207
224
  | .control-section-title {
208
225
  | color: #ffc66d; font-size: 11px; font-weight: bold; line-height: 1.2;
209
226
  | text-transform: uppercase;
@@ -330,13 +347,21 @@ html
330
347
  | color: #a9b7c6; overflow: hidden;
331
348
  | }
332
349
  | .style-parameters-header {
333
- | display: grid; grid-template-columns: 1fr auto auto; align-items: center; gap: 12px;
350
+ | display: flex; align-items: center; gap: 12px;
334
351
  | width: 100%; background: transparent; color: inherit; border: none;
335
- | padding: 10px 12px; cursor: pointer; text-align: left;
352
+ | padding: 10px 12px; text-align: left;
336
353
  | }
337
354
  | .style-parameters-header:hover {
338
355
  | background: rgba(75, 77, 79, 0.95);
339
356
  | }
357
+ | .style-parameters-toggle {
358
+ | display: inline-flex; align-items: center; justify-content: center;
359
+ | width: 22px; height: 22px; background: transparent; color: #a9b7c6;
360
+ | border: 1px solid transparent; border-radius: 2px; cursor: pointer;
361
+ | }
362
+ | .style-parameters-toggle:hover {
363
+ | background: #4b4d4f; border-color: #555555;
364
+ | }
340
365
  | .style-parameters-title {
341
366
  | color: #ffc66d; font-size: 11px; font-weight: bold; line-height: 1.2;
342
367
  | text-transform: uppercase;
@@ -487,6 +512,10 @@ html
487
512
  | transition: width 0.3s ease; width: 0%;
488
513
  | }
489
514
  | /* MapLibre Controls */
515
+ | .maplibregl-ctrl-top-left, .maplibregl-ctrl-top-right,
516
+ | .maplibregl-ctrl-bottom-left, .maplibregl-ctrl-bottom-right {
517
+ | z-index: 1000 !important;
518
+ | }
490
519
  | .maplibregl-ctrl-scale, .maplibregl-ctrl-group, .maplibregl-ctrl-terrain, .maplibregl-ctrl-globe {
491
520
  | background: rgba(60, 63, 65, 0.95) !important; border: 1px solid #555555 !important;
492
521
  | border-radius: 4px !important;
@@ -512,10 +541,12 @@ html
512
541
  | background: rgba(60, 63, 65, 0.95); border: 1px solid #555555;
513
542
  | border-radius: 6px; padding: 12px; width: 70vw; max-width: 800px;
514
543
  | backdrop-filter: blur(10px); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
544
+ | overflow: hidden;
515
545
  | }
516
546
  | .profile-header {
517
- | display: flex; justify-content: space-between; align-items: center;
547
+ | display: flex; justify-content: flex-start; align-items: center;
518
548
  | margin-bottom: 8px; border-bottom: 1px solid #555555; padding-bottom: 8px;
549
+ | gap: 8px;
519
550
  | }
520
551
  | .profile-title { color: #ffc66d; font-weight: bold; font-size: 12px; }
521
552
  | .profile-close {
@@ -523,6 +554,7 @@ html
523
554
  | font-size: 14px; padding: 0; width: 16px; height: 16px;
524
555
  | display: flex; align-items: center; justify-content: center;
525
556
  | border-radius: 2px; transition: all 0.2s ease;
557
+ | margin-left: auto;
526
558
  | }
527
559
  | .profile-close:hover { background: #4b4d4f; color: #a9b7c6; }
528
560
  | .profile-stats {
@@ -567,7 +599,7 @@ html
567
599
  | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
568
600
  | }
569
601
  | .tilegrid-header {
570
- | display: flex; justify-content: space-between; align-items: center;
602
+ | display: flex; justify-content: flex-start; align-items: center; gap: 8px;
571
603
  | margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid #555555;
572
604
  | }
573
605
  | .tilegrid-title {
@@ -578,6 +610,7 @@ html
578
610
  | font-size: 14px; padding: 0; width: 16px; height: 16px;
579
611
  | display: flex; align-items: center; justify-content: center;
580
612
  | border-radius: 2px; transition: all 0.2s ease;
613
+ | margin-left: auto;
581
614
  | }
582
615
  | .tilegrid-close:hover {
583
616
  | background: #4b4d4f; color: #a9b7c6;
@@ -35,6 +35,7 @@ ruby:
35
35
  span.setting-value id="terrain-exaggeration-value" 1.0x
36
36
  input.setting-range id="terrain-exaggeration-slider" type="range" min="0" max="3" step="0.1" value="1" oninput="setTerrainExaggeration(this.value)"
37
37
  button.control-button onclick="toggleAntialias()" id="antialias-btn" Antialias
38
+ button.control-button onclick="resetOverlayLayout()" id="overlay-layout-reset-btn" Reset window layout
38
39
 
39
40
  #settings-debug-panel.control-panel.settings-panel
40
41
  button.control-button onclick="toggleMapCache()" id="map-cache-btn" Cache
@@ -78,12 +79,13 @@ ruby:
78
79
  #map.map-layer data-style-url="#{style_url}"
79
80
 
80
81
  #style-parameters-panel.style-parameters-overlay.collapsed style="display: none;"
81
- button.style-parameters-header type="button" onclick="toggleStyleParametersPanel()" id="style-parameters-toggle" aria-expanded="false"
82
+ .style-parameters-header
82
83
  span.style-parameters-title Style parameters
83
84
  span.style-parameters-summary
84
85
  span#style-parameters-count 0
85
86
  span params
86
- span.style-parameters-toggle-icon
87
+ button.style-parameters-toggle type="button" onclick="toggleStyleParametersPanel()" id="style-parameters-toggle" aria-expanded="false" title="Collapse/Expand Style parameters"
88
+ span.style-parameters-toggle-icon ▲
87
89
  .style-parameters-body
88
90
  #style-parameter-fields.style-parameter-fields
89
91
  .style-parameter-actions
@@ -150,6 +152,7 @@ javascript:
150
152
  let currentProfile = null;
151
153
  let contourManager = null;
152
154
  let tileGridManager = null;
155
+ let overlayLayoutManager = null;
153
156
  let originalStyle = null;
154
157
  let styleParameterDefinitions = new Map();
155
158
  let styleParameterValues = {};
@@ -328,6 +331,7 @@ javascript:
328
331
  });
329
332
 
330
333
  layoutBottomOverlays();
334
+ overlayLayoutManager?.refreshPanel('style-parameters');
331
335
  };
332
336
 
333
337
  const toggleStyleParametersPanel = () => {
@@ -338,6 +342,7 @@ javascript:
338
342
  const isCollapsed = panel.classList.toggle('collapsed');
339
343
  toggle?.setAttribute('aria-expanded', String(!isCollapsed));
340
344
  layoutBottomOverlays();
345
+ overlayLayoutManager?.refreshPanel('style-parameters');
341
346
  };
342
347
 
343
348
  const initializeStyleParameters = async (style) => {
@@ -392,7 +397,7 @@ javascript:
392
397
 
393
398
  const patchRequestUrl = (resourceUrl) => shouldPatchRequestUrl(resourceUrl) ? appendStyleParametersToUrl(resourceUrl) : resourceUrl;
394
399
 
395
- const BOTTOM_OVERLAY_IDS = ['style-parameters-panel', 'loading-indicator', 'profile-overlay'];
400
+ const BOTTOM_OVERLAY_IDS = ['loading-indicator'];
396
401
  const BOTTOM_OVERLAY_BASE_OFFSET = 20;
397
402
  const BOTTOM_OVERLAY_GAP = 12;
398
403
 
@@ -419,6 +424,69 @@ javascript:
419
424
  element.style.bottom = `${bottomOffset}px`;
420
425
  bottomOffset += element.offsetHeight + BOTTOM_OVERLAY_GAP;
421
426
  });
427
+ overlayLayoutManager?.refreshBounds();
428
+ });
429
+ };
430
+
431
+ const getSystemReservedBottom = () => {
432
+ const loading = document.getElementById('loading-indicator');
433
+ return isVisibleBottomOverlay(loading) ? loading.offsetHeight + BOTTOM_OVERLAY_BASE_OFFSET + BOTTOM_OVERLAY_GAP : 0;
434
+ };
435
+
436
+ const leftControlStackOffset = (sectionName) => {
437
+ const mapSettings = document.getElementById('map-settings-wrapper');
438
+ const styleControls = document.getElementById('style-controls-wrapper');
439
+ const gap = 12;
440
+ const mapHeight = mapSettings?.getBoundingClientRect().height || mapSettings?.offsetHeight || 0;
441
+ const styleHeight = styleControls?.getBoundingClientRect().height || styleControls?.offsetHeight || 0;
442
+
443
+ return {
444
+ x: 0,
445
+ y: sectionName === 'map-settings' ? -((styleHeight + gap) / 2) : ((mapHeight + gap) / 2)
446
+ };
447
+ };
448
+
449
+ const initializeOverlayLayout = () => {
450
+ if (!window.OverlayLayoutManager) return;
451
+
452
+ overlayLayoutManager?.destroy();
453
+ overlayLayoutManager = new OverlayLayoutManager({
454
+ storageKey: 'maplibre-preview:overlay-layout:v3',
455
+ snapThreshold: 32,
456
+ mobileBreakpoint: 768,
457
+ edgeGap: 10,
458
+ getReservedBounds: () => ({bottom: getSystemReservedBottom()})
459
+ });
460
+ window.overlayLayoutManager = overlayLayoutManager;
461
+ window.resetOverlayLayout = () => overlayLayoutManager.resetLayout();
462
+
463
+ overlayLayoutManager.registerPanel({
464
+ id: 'map-settings',
465
+ element: '#map-settings-wrapper',
466
+ handleSelector: '.control-section-title',
467
+ defaultAnchor: 'top-left',
468
+ defaultOffset: {x: 0, y: 0}
469
+ });
470
+ overlayLayoutManager.registerPanel({
471
+ id: 'style-controls',
472
+ element: '#style-controls-wrapper',
473
+ handleSelector: '.control-section-title',
474
+ defaultAnchor: 'left',
475
+ defaultOffset: () => leftControlStackOffset('style-controls')
476
+ });
477
+ overlayLayoutManager.registerPanel({
478
+ id: 'style-parameters',
479
+ element: '#style-parameters-panel',
480
+ handleSelector: '.style-parameters-title',
481
+ defaultAnchor: 'bottom',
482
+ defaultOffset: {x: 0, y: 0}
483
+ });
484
+ overlayLayoutManager.registerPanel({
485
+ id: 'performance',
486
+ element: '#performance-panel',
487
+ handleSelector: '.performance-content',
488
+ defaultAnchor: 'top',
489
+ defaultOffset: {x: 0, y: -10}
422
490
  });
423
491
  };
424
492
 
@@ -648,6 +716,7 @@ javascript:
648
716
  });
649
717
  filters.init();
650
718
  createLayerButtons();
719
+ overlayLayoutManager?.refreshBounds();
651
720
  } catch (e) {
652
721
  console.warn('Filter initialization failed:', e);
653
722
  }
@@ -947,7 +1016,19 @@ javascript:
947
1016
 
948
1017
  [header, stats, chart].forEach(el => overlay.appendChild(el));
949
1018
  document.getElementById('map-container').appendChild(overlay);
950
- setTimeout(() => drawSimpleProfileChart(profile), 10);
1019
+ overlayLayoutManager?.registerPanel({
1020
+ id: 'profile',
1021
+ element: overlay,
1022
+ handleSelector: '.profile-title',
1023
+ defaultAnchor: 'bottom',
1024
+ defaultOffset: {x: 0, y: -16},
1025
+ snap: false,
1026
+ lockSizeOnDrag: true
1027
+ });
1028
+ setTimeout(() => {
1029
+ drawSimpleProfileChart(profile);
1030
+ overlayLayoutManager?.refreshPanel('profile');
1031
+ }, 10);
951
1032
  layoutBottomOverlays();
952
1033
  };
953
1034
 
@@ -1025,6 +1106,7 @@ javascript:
1025
1106
  };
1026
1107
 
1027
1108
  const hideProfile = () => {
1109
+ overlayLayoutManager?.unregisterPanel('profile');
1028
1110
  document.getElementById('profile-overlay')?.remove();
1029
1111
  layoutBottomOverlays();
1030
1112
  map.getLayer('profile-line') && (map.removeLayer('profile-line'), map.removeSource('profile-line'));
@@ -1491,4 +1573,5 @@ javascript:
1491
1573
  window.tileGridManager = null;
1492
1574
  window.addEventListener('resize', layoutBottomOverlays);
1493
1575
 
1576
+ initializeOverlayLayout();
1494
1577
  initializeMap();
@@ -14,6 +14,7 @@ RSpec.describe MapLibrePreview do
14
14
  expect(last_response.body).to include('maplibre-gl')
15
15
  expect(last_response.body).to include('maplibre-contour')
16
16
  expect(last_response.body).to include('d3')
17
+ expect(last_response.body).to include('overlay_layout')
17
18
  end
18
19
 
19
20
  it 'renders map cache toggle wiring' do
@@ -38,6 +39,13 @@ RSpec.describe MapLibrePreview do
38
39
  expect(last_response.body).to include('id="style-parameter-fields"')
39
40
  expect(last_response.body).to include('id="style-parameters-apply"')
40
41
  expect(last_response.body).to include('id="style-parameters-reset"')
42
+ expect(last_response.body).to include('OverlayLayoutManager')
43
+ expect(last_response.body).to include('overlayLayoutManager')
44
+ expect(last_response.body).to include('resetOverlayLayout')
45
+ expect(last_response.body).to include('id="overlay-layout-reset-btn"')
46
+ expect(last_response.body).to include('Reset window layout')
47
+ expect(last_response.body).to include('maplibre-preview:overlay-layout:v3')
48
+ expect(last_response.body).not_to include('overlay-panel-dock-actions')
41
49
  expect(last_response.body).to include('mapCacheDisabled')
42
50
  expect(last_response.body).to include('basemapOpacity')
43
51
  expect(last_response.body).to include('terrainExaggeration')
@@ -60,7 +68,7 @@ RSpec.describe MapLibrePreview do
60
68
  end
61
69
 
62
70
  it 'serves all required JavaScript modules' do
63
- %w[/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|
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|
64
72
  get js_file
65
73
  expect(last_response).to be_ok
66
74
  expect(last_response.content_type).to include('javascript')
@@ -88,6 +96,7 @@ RSpec.describe MapLibrePreview do
88
96
  expect(body).to include('/vendor/maplibre-gl/maplibre-gl.js')
89
97
  expect(body).to include('/vendor/maplibre-contour/index.min.js')
90
98
  expect(body).to include('/vendor/d3/d3.v7.min.js')
99
+ expect(body).to include('/js/overlay_layout.js')
91
100
  expect(body).not_to include('unpkg.com')
92
101
  expect(body).not_to include('d3js.org')
93
102
  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.7.2
4
+ version: 1.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alexander Ludov
@@ -203,6 +203,7 @@ files:
203
203
  - lib/maplibre-preview.rb
204
204
  - lib/maplibre-preview/public/js/contour.js
205
205
  - lib/maplibre-preview/public/js/filters.js
206
+ - lib/maplibre-preview/public/js/overlay_layout.js
206
207
  - lib/maplibre-preview/public/js/tilegrid.js
207
208
  - lib/maplibre-preview/public/vendor/d3/d3.v7.min.js
208
209
  - lib/maplibre-preview/public/vendor/maplibre-contour/index.min.js