maplibre-preview 1.7.2 → 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 +4 -4
- data/CHANGELOG.md +28 -0
- data/lib/maplibre-preview/public/css/temporal_picker.css +164 -0
- data/lib/maplibre-preview/public/js/overlay_layout.js +415 -0
- data/lib/maplibre-preview/public/js/temporal_picker.js +247 -0
- data/lib/maplibre-preview/public/js/tilegrid.js +9 -0
- data/lib/maplibre-preview/version.rb +1 -1
- data/lib/maplibre-preview/views/maplibre_layout.slim +77 -10
- data/lib/maplibre-preview/views/maplibre_map.slim +238 -26
- data/spec/maplibre_preview_spec.rb +32 -5
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2c43caaa2de2305aa8211b7d1de99aaf7337b6162d7d99a448f73007c717591a
|
|
4
|
+
data.tar.gz: b16f170dc316e26844ffa6dd44d8401d38ba9128a9cf0dc13bd3abd1b30d0507
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2401ce59b8a156a2b34ddeecc36a3c6ad4ae161f41fcf998d3e2a6c788cf096f62fde591327948bdf18f1625b8eb9d0e55f226f8d758f7d6b0d8524087bc43f1
|
|
7
|
+
data.tar.gz: ad6e8ab6f576675903edda6b0859df42ef467dffff70e651eaacb36bbb8c906806b7d3bfec2d29545bcad663bcc99a114d88b912edd22caf115d5b7ecbdada0f
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
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
|
+
|
|
17
|
+
## [1.8.0] - 2026-05-14
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
- **Movable overlay windows** - added `OverlayLayoutManager` for shared drag, edge clamping, snapping and persisted positions of movable map UI panels
|
|
21
|
+
- **Window layout reset** - added `Reset window layout` action in Map Settings to clear saved panel positions from local storage and restore defaults
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
- **Panel layout foundation** - moved Map Settings, Style Controls, Style Parameters, Performance, Elevation Profile and Tile Boundaries to the shared overlay layout manager
|
|
25
|
+
- **Default panel positions** - kept static map controls attached to map edges and placed Map Settings in the top-left default position
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
- **Overlay visibility** - fixed managed panel z-index so map settings, filter controls and static MapLibre controls remain visible
|
|
29
|
+
- **Elevation profile frame** - fixed profile window positioning and sizing while dragging near the bottom edge
|
|
30
|
+
|
|
3
31
|
## [1.7.2] - 2026-05-14
|
|
4
32
|
|
|
5
33
|
### Changed
|
|
@@ -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,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;
|