@v-office/website-headless 0.2.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2177 -55
- package/dist/enhance/index.d.mts +501 -180
- package/dist/enhance/index.mjs +1388 -472
- package/dist/index-CPMXjfFH.d.mts +2724 -0
- package/dist/index.d.mts +2614 -1181
- package/dist/index.mjs +8568 -1739
- package/dist/parts-CS1VBrPt.mjs +648 -0
- package/dist/sdk-claim-CsYFzvEc.mjs +72 -0
- package/dist/sdk-create-Bsd9lqj_.mjs +9 -0
- package/instructions/CHANGELOG.md +1 -0
- package/instructions/MIGRATION.md +1 -0
- package/instructions/versions/1.0.0/CHANGELOG.md +170 -0
- package/instructions/versions/1.0.0/MIGRATION.md +252 -0
- package/instructions/versions/1.0.0/release.json +10 -0
- package/package.json +6 -4
package/dist/enhance/index.mjs
CHANGED
|
@@ -1,237 +1,160 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
if (
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
e.preventDefault();
|
|
71
|
-
e.stopPropagation();
|
|
72
|
-
goTo(currentIndex() + 1);
|
|
73
|
-
});
|
|
74
|
-
let engaged = false;
|
|
75
|
-
track.addEventListener("scroll", () => {
|
|
76
|
-
if (!engaged) {
|
|
77
|
-
engaged = true;
|
|
78
|
-
el.classList.add("is-engaged");
|
|
79
|
-
}
|
|
80
|
-
const idx = currentIndex();
|
|
81
|
-
updateButtons(idx);
|
|
82
|
-
updateDots(idx);
|
|
83
|
-
}, { passive: true });
|
|
84
|
-
updateButtons(0);
|
|
85
|
-
updateDots(0);
|
|
1
|
+
import { C as shareUrl, O as fillTemplate, _ as acquireFavorites, c as circleOffsets, g as FAVORITES_EVENT, n as GATE_MARKERS, r as DEFAULT_CLUSTER, t as ENHANCE_PARTS } from "../parts-CS1VBrPt.mjs";
|
|
2
|
+
//#region src/enhance/dom.ts
|
|
3
|
+
/** See {@link PartSelector}. */
|
|
4
|
+
const partSelector = (...parts) => parts.map((part) => `[data-part="${part}"]`).join(", ");
|
|
5
|
+
/** See {@link SelectAll}. */
|
|
6
|
+
const selectAll = (root, selector) => {
|
|
7
|
+
return [...root instanceof HTMLElement && root.matches(selector) ? [root] : [], ...root.querySelectorAll(selector)];
|
|
8
|
+
};
|
|
9
|
+
/** See {@link SelectOwned}. */
|
|
10
|
+
const selectOwned = (scope, selector, owner) => [...scope.querySelectorAll(selector)].filter((element) => element.closest(owner) === scope);
|
|
11
|
+
/** See {@link OnIdle}. */
|
|
12
|
+
const onIdle = (task) => {
|
|
13
|
+
if (typeof window.requestIdleCallback === "function") {
|
|
14
|
+
const handle = window.requestIdleCallback(task, { timeout: 500 });
|
|
15
|
+
return () => window.cancelIdleCallback(handle);
|
|
16
|
+
}
|
|
17
|
+
const handle = window.setTimeout(task, 0);
|
|
18
|
+
return () => window.clearTimeout(handle);
|
|
19
|
+
};
|
|
20
|
+
/** See {@link PrefersReducedMotion}. */
|
|
21
|
+
const prefersReducedMotion = () => typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
22
|
+
/** See {@link HasBox}. */
|
|
23
|
+
const hasBox = (element) => element.getClientRects().length > 0;
|
|
24
|
+
//#endregion
|
|
25
|
+
//#region src/enhance/lifecycle.ts
|
|
26
|
+
/** See {@link Listen}. */
|
|
27
|
+
const listen = (target, type, handler, options) => {
|
|
28
|
+
const listener = handler;
|
|
29
|
+
target.addEventListener(type, listener, options);
|
|
30
|
+
return () => target.removeEventListener(type, listener, options);
|
|
31
|
+
};
|
|
32
|
+
/** See {@link Combine}. */
|
|
33
|
+
const combine = (disposes) => {
|
|
34
|
+
let done = false;
|
|
35
|
+
return () => {
|
|
36
|
+
if (done) return;
|
|
37
|
+
done = true;
|
|
38
|
+
for (const dispose of [...disposes].reverse()) dispose();
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
const bound = /* @__PURE__ */ new WeakMap();
|
|
42
|
+
/** See {@link BindOnce}. */
|
|
43
|
+
const bindOnce = (key, family, create) => {
|
|
44
|
+
const families = bound.get(key) ?? /* @__PURE__ */ new Map();
|
|
45
|
+
bound.set(key, families);
|
|
46
|
+
const existing = families.get(family);
|
|
47
|
+
if (existing) return existing;
|
|
48
|
+
const handle = create();
|
|
49
|
+
const teardown = handle.dispose;
|
|
50
|
+
handle.dispose = combine([teardown, () => families.delete(family)]);
|
|
51
|
+
families.set(family, handle);
|
|
52
|
+
return handle;
|
|
53
|
+
};
|
|
54
|
+
/** See {@link IsBound}. */
|
|
55
|
+
const isBound = (key, family) => bound.get(key)?.has(family) ?? false;
|
|
56
|
+
//#endregion
|
|
57
|
+
//#region src/enhance/favorites.ts
|
|
58
|
+
const HEART = `[data-fav], ${partSelector(ENHANCE_PARTS.favorite)}`;
|
|
59
|
+
const BADGE = `[data-fav-badge], ${partSelector(ENHANCE_PARTS.favoriteBadge)}`;
|
|
60
|
+
/** See {@link BindFavorites}. */
|
|
61
|
+
const bindFavorites = (root, options) => bindOnce(root, "favorites", () => ({ dispose: createFavoritesBinding(root, options.storageKey) })).dispose;
|
|
62
|
+
function createFavoritesBinding(root, storageKey) {
|
|
63
|
+
const onChange = (change) => {
|
|
64
|
+
if (favorites.ids().size > 0) for (const badge of selectAll(root, BADGE)) replay(badge, "data-fav-bump", "on");
|
|
65
|
+
window.dispatchEvent(new CustomEvent(FAVORITES_EVENT, { detail: change }));
|
|
66
|
+
};
|
|
67
|
+
const { favorites, release } = acquireFavorites({
|
|
68
|
+
storageKey,
|
|
69
|
+
onChange
|
|
86
70
|
});
|
|
87
|
-
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
if (viewport.dataset.carouselBound === "true") return;
|
|
91
|
-
viewport.dataset.carouselBound = "true";
|
|
92
|
-
const findActiveTrack = () => viewport.querySelector(".rental-track:not([hidden])");
|
|
93
|
-
const tabsBlock = viewport.closest("[data-rental-tabs]");
|
|
94
|
-
const prevBtn = tabsBlock?.querySelector("[data-rental-prev]");
|
|
95
|
-
const nextBtn = tabsBlock?.querySelector("[data-rental-next]");
|
|
96
|
-
/**
|
|
97
|
-
* Sync prev/next button disabled state to the current native scroll
|
|
98
|
-
* position. The native scroll engine reports scrollLeft on every
|
|
99
|
-
* scroll frame; we just compare it to the bounds.
|
|
100
|
-
*/
|
|
101
|
-
const updateArrowButtons = () => {
|
|
102
|
-
if (!prevBtn && !nextBtn) return;
|
|
103
|
-
const max = viewport.scrollWidth - viewport.clientWidth;
|
|
104
|
-
if (prevBtn) prevBtn.disabled = viewport.scrollLeft <= 1;
|
|
105
|
-
if (nextBtn) nextBtn.disabled = viewport.scrollLeft >= max - 1;
|
|
106
|
-
};
|
|
107
|
-
/**
|
|
108
|
-
* Step the carousel forward or back by one card. Finds the card
|
|
109
|
-
* closest to the current scroll position, picks the neighbour in
|
|
110
|
-
* `direction`, and asks the browser to smoothly scroll there.
|
|
111
|
-
* scroll-snap CSS handles the snap; we just provide the target.
|
|
112
|
-
*
|
|
113
|
-
* Forward edge case: stepping to the last card would crop its
|
|
114
|
-
* right side (offsetLeft places card-left at port-start, card-
|
|
115
|
-
* right past port-end). When direction is forward AND we're
|
|
116
|
-
* landing on the last card, scroll to the maximum scrollLeft
|
|
117
|
-
* instead so the card is right-aligned (matches the
|
|
118
|
-
* `:last-child { scroll-snap-align: end }` CSS rule).
|
|
119
|
-
*/
|
|
120
|
-
const stepBy = (direction) => {
|
|
121
|
-
const track = findActiveTrack();
|
|
122
|
-
if (!track) return;
|
|
123
|
-
const cards = Array.from(track.children);
|
|
124
|
-
if (cards.length === 0) return;
|
|
125
|
-
const cs = getComputedStyle(viewport);
|
|
126
|
-
const padStart = parseFloat(cs.scrollPaddingInlineStart) || 0;
|
|
127
|
-
let currentIdx = 0;
|
|
128
|
-
let bestDist = Math.abs(cards[0].offsetLeft - padStart - viewport.scrollLeft);
|
|
129
|
-
for (let i = 1; i < cards.length; i++) {
|
|
130
|
-
const snapPoint = cards[i].offsetLeft - padStart;
|
|
131
|
-
const d = Math.abs(snapPoint - viewport.scrollLeft);
|
|
132
|
-
if (d < bestDist) {
|
|
133
|
-
bestDist = d;
|
|
134
|
-
currentIdx = i;
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
const newIdx = Math.max(0, Math.min(cards.length - 1, currentIdx + direction));
|
|
138
|
-
const max = viewport.scrollWidth - viewport.clientWidth;
|
|
139
|
-
const cardTarget = cards[newIdx].offsetLeft - padStart;
|
|
140
|
-
const target = direction > 0 && newIdx === cards.length - 1 && cardTarget < max ? max : cardTarget;
|
|
141
|
-
viewport.scrollTo({
|
|
142
|
-
left: target,
|
|
143
|
-
behavior: "smooth"
|
|
144
|
-
});
|
|
71
|
+
const paintAll = () => {
|
|
72
|
+
for (const heart of selectAll(root, HEART)) paintHeart(heart, favorites);
|
|
73
|
+
for (const badge of selectAll(root, BADGE)) paintBadge(badge, favorites.ids().size);
|
|
145
74
|
};
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
suppressNextClick = true;
|
|
155
|
-
if (suppressTimer !== null) window.clearTimeout(suppressTimer);
|
|
156
|
-
suppressTimer = window.setTimeout(() => {
|
|
157
|
-
suppressNextClick = false;
|
|
158
|
-
suppressTimer = null;
|
|
159
|
-
}, 350);
|
|
160
|
-
};
|
|
161
|
-
const onPointerMove = (e) => {
|
|
162
|
-
if (!isDown || pointerId !== e.pointerId) return;
|
|
163
|
-
const dx = e.clientX - startX;
|
|
164
|
-
if (!dragging) {
|
|
165
|
-
if (Math.abs(dx) < DRAG_THRESHOLD_PX) return;
|
|
166
|
-
dragging = true;
|
|
167
|
-
viewport.classList.add("is-dragging");
|
|
168
|
-
const sel = window.getSelection?.();
|
|
169
|
-
if (sel) sel.removeAllRanges();
|
|
75
|
+
const onClick = (event) => {
|
|
76
|
+
const heart = event.target instanceof Element ? event.target.closest(HEART) : null;
|
|
77
|
+
const id = heart ? heartId(heart) : null;
|
|
78
|
+
if (!heart || !id) return;
|
|
79
|
+
if (!event.defaultPrevented) {
|
|
80
|
+
event.preventDefault();
|
|
81
|
+
event.stopPropagation();
|
|
82
|
+
favorites.toggle(id);
|
|
170
83
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
84
|
+
replay(heart.querySelector("svg") ?? heart, "data-fav-burst", favorites.has(id) ? "save" : "remove");
|
|
85
|
+
};
|
|
86
|
+
const onStorage = (event) => {
|
|
87
|
+
if (event.key === storageKey) favorites.reload();
|
|
88
|
+
};
|
|
89
|
+
paintAll();
|
|
90
|
+
return combine([
|
|
91
|
+
release,
|
|
92
|
+
favorites.subscribe(paintAll),
|
|
93
|
+
listen(root, "click", onClick),
|
|
94
|
+
listen(window, "storage", onStorage),
|
|
95
|
+
paintArrivals(root, favorites)
|
|
96
|
+
]);
|
|
97
|
+
}
|
|
98
|
+
/** The rental a heart belongs to: its own `data-fav-id`, else the result card around it. */
|
|
99
|
+
function heartId(heart) {
|
|
100
|
+
return heart.dataset["favId"] || heart.closest("[data-result-card]")?.getAttribute("data-result-card") || null;
|
|
101
|
+
}
|
|
102
|
+
function paintHeart(heart, favorites) {
|
|
103
|
+
const id = heartId(heart);
|
|
104
|
+
if (!id) return;
|
|
105
|
+
const saved = favorites.has(id);
|
|
106
|
+
heart.setAttribute("aria-pressed", String(saved));
|
|
107
|
+
heart.toggleAttribute("data-saved", saved);
|
|
108
|
+
}
|
|
109
|
+
function paintBadge(badge, count) {
|
|
110
|
+
badge.textContent = String(count);
|
|
111
|
+
if (count > 0) badge.setAttribute("data-active", "true");
|
|
112
|
+
else badge.removeAttribute("data-active");
|
|
113
|
+
const template = count === 1 ? badge.dataset["templateOne"] ?? badge.dataset["template"] : badge.dataset["template"];
|
|
114
|
+
if (count > 0) badge.removeAttribute("aria-hidden");
|
|
115
|
+
else badge.setAttribute("aria-hidden", "true");
|
|
116
|
+
if (count > 0 && template) badge.setAttribute("aria-label", fillTemplate(template, { count }));
|
|
117
|
+
else badge.removeAttribute("aria-label");
|
|
118
|
+
}
|
|
119
|
+
/** Hearts rendered after binding (an island committing its cards) get their state as they arrive. */
|
|
120
|
+
function paintArrivals(root, favorites) {
|
|
121
|
+
const target = root instanceof Document ? root.documentElement : root;
|
|
122
|
+
if (!(target instanceof Node)) return () => {};
|
|
123
|
+
const observer = new MutationObserver((records) => {
|
|
124
|
+
for (const record of records) for (const node of record.addedNodes) {
|
|
125
|
+
if (!(node instanceof HTMLElement)) continue;
|
|
126
|
+
for (const heart of selectAll(node, HEART)) paintHeart(heart, favorites);
|
|
127
|
+
for (const badge of selectAll(node, BADGE)) paintBadge(badge, favorites.ids().size);
|
|
208
128
|
}
|
|
209
|
-
}, { capture: true });
|
|
210
|
-
viewport.addEventListener("scroll", updateArrowButtons, { passive: true });
|
|
211
|
-
if (prevBtn) prevBtn.addEventListener("click", () => stepBy(-1));
|
|
212
|
-
if (nextBtn) nextBtn.addEventListener("click", () => stepBy(1));
|
|
213
|
-
viewport.addEventListener("rental-track-changed", () => {
|
|
214
|
-
viewport.scrollLeft = 0;
|
|
215
|
-
updateArrowButtons();
|
|
216
129
|
});
|
|
217
|
-
|
|
130
|
+
observer.observe(target, {
|
|
131
|
+
childList: true,
|
|
132
|
+
subtree: true
|
|
133
|
+
});
|
|
134
|
+
return () => observer.disconnect();
|
|
135
|
+
}
|
|
136
|
+
/** Restart an animation that may already run (a fast double tap answers the second press too): remove the
|
|
137
|
+
* attribute, flush layout, set it again; it comes off when the animation ends. Without the site's
|
|
138
|
+
* keyframes nothing moves, and the attribute still comes and goes. */
|
|
139
|
+
function replay(element, attribute, value) {
|
|
140
|
+
element.removeAttribute(attribute);
|
|
141
|
+
element.getBoundingClientRect();
|
|
142
|
+
element.setAttribute(attribute, value);
|
|
143
|
+
element.addEventListener("animationend", () => element.removeAttribute(attribute), { once: true });
|
|
218
144
|
}
|
|
219
145
|
//#endregion
|
|
220
|
-
//#region src/enhance/
|
|
221
|
-
/**
|
|
222
|
-
|
|
223
|
-
function withViewTransition(mutate) {
|
|
146
|
+
//#region src/enhance/motion.ts
|
|
147
|
+
/** See {@link WithViewTransition}. */
|
|
148
|
+
const withViewTransition = (mutate) => {
|
|
224
149
|
const doc = document;
|
|
225
|
-
if (
|
|
150
|
+
if (prefersReducedMotion() || typeof doc.startViewTransition !== "function") {
|
|
226
151
|
mutate();
|
|
227
152
|
return;
|
|
228
153
|
}
|
|
229
154
|
const transition = doc.startViewTransition(mutate);
|
|
230
155
|
transition.ready.catch(() => {});
|
|
231
156
|
transition.finished.catch(() => {});
|
|
232
|
-
}
|
|
233
|
-
let lastInputWasKeyboard = false;
|
|
234
|
-
let tracked = false;
|
|
157
|
+
};
|
|
235
158
|
const KEYBOARD_KEYS = /* @__PURE__ */ new Set([
|
|
236
159
|
"Tab",
|
|
237
160
|
"Enter",
|
|
@@ -244,294 +167,1287 @@ const KEYBOARD_KEYS = /* @__PURE__ */ new Set([
|
|
|
244
167
|
"Home",
|
|
245
168
|
"End"
|
|
246
169
|
]);
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
if (
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
170
|
+
let keyboard = false;
|
|
171
|
+
let claims = 0;
|
|
172
|
+
let removeListeners = () => {};
|
|
173
|
+
/** See {@link TrackInputModality}. */
|
|
174
|
+
const trackInputModality = () => {
|
|
175
|
+
claims += 1;
|
|
176
|
+
if (claims === 1) removeListeners = combine([listen(window, "pointerdown", () => keyboard = false, { capture: true }), listen(window, "keydown", (event) => {
|
|
177
|
+
if (KEYBOARD_KEYS.has(event.key)) keyboard = true;
|
|
178
|
+
}, { capture: true })]);
|
|
179
|
+
let released = false;
|
|
180
|
+
return () => {
|
|
181
|
+
if (released) return;
|
|
182
|
+
released = true;
|
|
183
|
+
claims -= 1;
|
|
184
|
+
if (claims === 0) removeListeners();
|
|
185
|
+
};
|
|
186
|
+
};
|
|
187
|
+
/** See {@link FocusForKeyboard}. */
|
|
188
|
+
const focusForKeyboard = (element) => {
|
|
189
|
+
if (keyboard && element.isConnected) element.focus();
|
|
190
|
+
};
|
|
191
|
+
/** See {@link LastInputWasKeyboard}. */
|
|
192
|
+
const lastInputWasKeyboard = () => keyboard;
|
|
193
|
+
//#endregion
|
|
194
|
+
//#region src/enhance/layers.ts
|
|
195
|
+
const OPEN = partSelector(ENHANCE_PARTS.layerOpen);
|
|
196
|
+
const CLOSE = partSelector(ENHANCE_PARTS.layerClose);
|
|
197
|
+
const BODY = partSelector(ENHANCE_PARTS.layerBody);
|
|
198
|
+
/** See {@link BindLayer}. */
|
|
199
|
+
const bindLayer = (dialog, options = {}) => bindOnce(dialog, "layer", () => createLayer(dialog, options));
|
|
200
|
+
function createLayer(dialog, options) {
|
|
201
|
+
const name = options.name ?? dialog.id;
|
|
202
|
+
if (!name) throw new Error("bindLayer: the dialog needs an id or options.name (it names the URL hash).");
|
|
203
|
+
const hash = `#${name}`;
|
|
204
|
+
const scroller = options.scroller === void 0 ? dialog.querySelector(BODY) : options.scroller;
|
|
205
|
+
const ownsHash = () => location.hash === hash;
|
|
206
|
+
let open = false;
|
|
273
207
|
let returnFocus = null;
|
|
274
|
-
|
|
275
|
-
|
|
208
|
+
let unlock = () => {};
|
|
209
|
+
const show = (opener) => {
|
|
210
|
+
open = true;
|
|
211
|
+
returnFocus = opener ?? (document.activeElement instanceof HTMLElement ? document.activeElement : null);
|
|
212
|
+
unlock = lockScroll();
|
|
276
213
|
withViewTransition(() => {
|
|
277
|
-
|
|
278
|
-
|
|
214
|
+
if (!dialog.open) dialog.showModal();
|
|
215
|
+
settleFocus(dialog);
|
|
216
|
+
options.onOpen?.(opener);
|
|
279
217
|
});
|
|
280
|
-
history.pushState({ layer: options.historyKey }, "");
|
|
281
|
-
requestAnimationFrame(() => focusForKeyboard(closeBtn));
|
|
282
218
|
};
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
219
|
+
const hide = (native) => {
|
|
220
|
+
if (!open) return;
|
|
221
|
+
open = false;
|
|
222
|
+
options.onClose?.();
|
|
223
|
+
if (scroller) scroller.scrollTop = 0;
|
|
224
|
+
if (!native) withViewTransition(() => dialog.close());
|
|
225
|
+
unlock();
|
|
226
|
+
if (returnFocus) focusForKeyboard(returnFocus);
|
|
227
|
+
};
|
|
228
|
+
const handle = {
|
|
229
|
+
open(opener = null) {
|
|
230
|
+
if (open) return;
|
|
231
|
+
history.pushState(history.state, "", hash);
|
|
232
|
+
show(opener);
|
|
233
|
+
},
|
|
234
|
+
close() {
|
|
235
|
+
if (!open) return;
|
|
236
|
+
if (ownsHash()) history.back();
|
|
237
|
+
else hide(false);
|
|
238
|
+
},
|
|
239
|
+
get isOpen() {
|
|
240
|
+
return open;
|
|
241
|
+
},
|
|
242
|
+
dispose: () => {}
|
|
243
|
+
};
|
|
244
|
+
const onDocumentClick = (event) => {
|
|
245
|
+
const opener = event.target instanceof Element ? event.target.closest(OPEN) : null;
|
|
246
|
+
if (!opener || opener.getAttribute("aria-controls") !== dialog.id) return;
|
|
247
|
+
event.preventDefault();
|
|
248
|
+
handle.open(opener);
|
|
249
|
+
};
|
|
250
|
+
const onDialogClick = (event) => {
|
|
251
|
+
const target = event.target;
|
|
252
|
+
if (target === dialog) {
|
|
253
|
+
if (options.closeOnBackdrop ?? true) handle.close();
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
const closer = target instanceof Element ? target.closest(CLOSE) : null;
|
|
257
|
+
if (closer && closer.closest("dialog") === dialog) handle.close();
|
|
258
|
+
};
|
|
259
|
+
const onCancel = (event) => {
|
|
260
|
+
event.preventDefault();
|
|
261
|
+
handle.close();
|
|
262
|
+
};
|
|
263
|
+
const onNativeClose = () => {
|
|
264
|
+
if (!open) return;
|
|
265
|
+
hide(true);
|
|
266
|
+
if (ownsHash()) history.back();
|
|
267
|
+
};
|
|
268
|
+
const onPopState = () => {
|
|
269
|
+
if (ownsHash() && !open) show(null);
|
|
270
|
+
else if (!ownsHash() && open) hide(false);
|
|
271
|
+
};
|
|
272
|
+
const release = combine([
|
|
273
|
+
trackInputModality(),
|
|
274
|
+
listen(document, "click", onDocumentClick),
|
|
275
|
+
listen(dialog, "click", onDialogClick),
|
|
276
|
+
listen(dialog, "cancel", onCancel),
|
|
277
|
+
listen(dialog, "close", onNativeClose),
|
|
278
|
+
listen(window, "popstate", onPopState)
|
|
279
|
+
]);
|
|
280
|
+
handle.dispose = () => {
|
|
281
|
+
release();
|
|
282
|
+
if (!open) return;
|
|
283
|
+
open = false;
|
|
284
|
+
dialog.close();
|
|
285
|
+
unlock();
|
|
286
|
+
};
|
|
287
|
+
if (ownsHash()) {
|
|
288
|
+
history.replaceState(history.state, "", location.pathname + location.search);
|
|
289
|
+
handle.open(null);
|
|
290
|
+
}
|
|
291
|
+
return handle;
|
|
292
|
+
}
|
|
293
|
+
/** showModal() focuses the first focusable element. Keep it for keyboard users on the first closer; a tap
|
|
294
|
+
* gets no focus move at all (a ring on a button nobody aimed at). */
|
|
295
|
+
function settleFocus(dialog) {
|
|
296
|
+
const closer = dialog.querySelector(CLOSE);
|
|
297
|
+
if (lastInputWasKeyboard()) {
|
|
298
|
+
if (closer) focusForKeyboard(closer);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
const focused = document.activeElement;
|
|
302
|
+
if (focused instanceof HTMLElement && dialog.contains(focused)) focused.blur();
|
|
303
|
+
}
|
|
304
|
+
let locks = 0;
|
|
305
|
+
let previousOverflow = "";
|
|
306
|
+
/** Lock the page's scroll while any layer is open; the Dispose releases this claim. */
|
|
307
|
+
function lockScroll() {
|
|
308
|
+
locks += 1;
|
|
309
|
+
if (locks === 1) {
|
|
310
|
+
previousOverflow = document.body.style.overflow;
|
|
311
|
+
document.body.style.overflow = "hidden";
|
|
312
|
+
}
|
|
313
|
+
let released = false;
|
|
314
|
+
return () => {
|
|
315
|
+
if (released) return;
|
|
316
|
+
released = true;
|
|
317
|
+
locks -= 1;
|
|
318
|
+
if (locks === 0) document.body.style.overflow = previousOverflow;
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
//#endregion
|
|
322
|
+
//#region src/enhance/strip.ts
|
|
323
|
+
/** Strip roots, the legacy kit card markup included. Internal to ./enhance. */
|
|
324
|
+
const STRIP_ROOT = `${partSelector(ENHANCE_PARTS.strip)}, .card-images`;
|
|
325
|
+
const TRACK = `${partSelector(ENHANCE_PARTS.stripTrack)}, [data-img-track]`;
|
|
326
|
+
const ITEM = partSelector(ENHANCE_PARTS.stripItem);
|
|
327
|
+
const PREV = `${partSelector(ENHANCE_PARTS.stripPrev)}, [data-img-prev]`;
|
|
328
|
+
const NEXT = `${partSelector(ENHANCE_PARTS.stripNext)}, [data-img-next]`;
|
|
329
|
+
const DOTS = `${partSelector(ENHANCE_PARTS.stripDots)}, [data-dots]`;
|
|
330
|
+
const COUNTER = partSelector(ENHANCE_PARTS.stripCounter);
|
|
331
|
+
const DEFAULT_COUNTER = "{current} / {count}";
|
|
332
|
+
const DRAG_THRESHOLD_PX = 2;
|
|
333
|
+
const CLICK_SUPPRESS_MS = 350;
|
|
334
|
+
/** See {@link BindStrip}. */
|
|
335
|
+
const bindStrip = (root, options = {}) => bindOnce(root, "strip", () => createStrip(root, settingsFor(root, options)));
|
|
336
|
+
function settingsFor(root, options) {
|
|
337
|
+
const paging = options.paging ?? (root.dataset["paging"] === "snap" ? "snap" : "page");
|
|
338
|
+
return {
|
|
339
|
+
paging,
|
|
340
|
+
edges: options.edges ?? (root.dataset["edges"] === "wrap" ? "wrap" : "stop"),
|
|
341
|
+
drag: options.drag ?? paging === "snap",
|
|
342
|
+
deferSlides: options.deferSlides ?? true
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
function createStrip(root, settings) {
|
|
346
|
+
const own = (selector) => selectOwned(root, selector, STRIP_ROOT)[0] ?? null;
|
|
347
|
+
const track = own(TRACK);
|
|
348
|
+
const controls = {
|
|
349
|
+
prev: own(PREV),
|
|
350
|
+
next: own(NEXT),
|
|
351
|
+
dots: own(DOTS),
|
|
352
|
+
counter: own(COUNTER)
|
|
353
|
+
};
|
|
354
|
+
const items = () => track ? visibleItems(track) : [];
|
|
355
|
+
const index = () => track ? currentIndex(track, items(), settings.paging) : 0;
|
|
356
|
+
const paint = () => {
|
|
357
|
+
if (!track) return;
|
|
358
|
+
const list = items();
|
|
359
|
+
const current = currentIndex(track, list, settings.paging);
|
|
360
|
+
for (const item of allItems(track)) item.toggleAttribute("data-current", item === list[current]);
|
|
361
|
+
paintArrows(controls.prev, controls.next, edgesOf(track, current, list.length, settings), settings.edges);
|
|
362
|
+
paintDots(controls.dots, current, list.length);
|
|
363
|
+
paintCounter(controls.counter, current, list.length);
|
|
364
|
+
};
|
|
365
|
+
const goTo = (target, how) => {
|
|
366
|
+
if (!track || track.clientWidth === 0) return;
|
|
367
|
+
const list = items();
|
|
368
|
+
if (list.length === 0) return;
|
|
369
|
+
const at = clamp(target, 0, list.length - 1);
|
|
370
|
+
const left = settings.paging === "page" ? at * track.clientWidth : snapOffsets(track, list)[at] ?? 0;
|
|
371
|
+
const smooth = (how ?? (settings.paging === "page" ? "instant" : "smooth")) === "smooth";
|
|
372
|
+
track.scrollTo({
|
|
373
|
+
left,
|
|
374
|
+
behavior: smooth && !prefersReducedMotion() ? "smooth" : "instant"
|
|
288
375
|
});
|
|
289
|
-
|
|
376
|
+
paint();
|
|
290
377
|
};
|
|
291
|
-
const
|
|
292
|
-
if (
|
|
293
|
-
|
|
378
|
+
const step = (delta) => {
|
|
379
|
+
if (!track) return;
|
|
380
|
+
const list = items();
|
|
381
|
+
if (list.length === 0) return;
|
|
382
|
+
const current = currentIndex(track, list, settings.paging);
|
|
383
|
+
const edge = edgesOf(track, current, list.length, settings);
|
|
384
|
+
const leaving = delta > 0 ? edge.atEnd : edge.atStart;
|
|
385
|
+
if (leaving && settings.edges === "stop") return;
|
|
386
|
+
goTo(leaving ? delta > 0 ? 0 : list.length - 1 : current + delta);
|
|
387
|
+
};
|
|
388
|
+
const handle = {
|
|
389
|
+
get index() {
|
|
390
|
+
return index();
|
|
391
|
+
},
|
|
392
|
+
get total() {
|
|
393
|
+
return items().length;
|
|
394
|
+
},
|
|
395
|
+
goTo,
|
|
396
|
+
step,
|
|
397
|
+
reset: () => goTo(0, "instant"),
|
|
398
|
+
dispose: () => {}
|
|
294
399
|
};
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
400
|
+
if (!track) return handle;
|
|
401
|
+
const disposes = [];
|
|
402
|
+
const start = () => {
|
|
403
|
+
disposes.push(bindArrow(controls.prev, () => step(-1)), bindArrow(controls.next, () => step(1)), listen(track, "scroll", () => {
|
|
404
|
+
root.toggleAttribute("data-engaged", true);
|
|
405
|
+
paint();
|
|
406
|
+
}, { passive: true }));
|
|
407
|
+
if (settings.drag) disposes.push(bindDrag(root, track));
|
|
408
|
+
if (settings.deferSlides) disposes.push(bindDeferredSlides(root, track, () => upcoming(track, items(), settings.paging)));
|
|
409
|
+
paint();
|
|
410
|
+
};
|
|
411
|
+
if (track.children.length > 0) start();
|
|
412
|
+
else disposes.push(whenFilled(track, start));
|
|
413
|
+
handle.dispose = combine(disposes);
|
|
414
|
+
return handle;
|
|
415
|
+
}
|
|
416
|
+
/** The track's items, whether or not they render. */
|
|
417
|
+
function allItems(track) {
|
|
418
|
+
const marked = selectOwned(track, ITEM, TRACK);
|
|
419
|
+
return marked.length > 0 ? marked : [...track.children].filter((child) => child instanceof HTMLElement);
|
|
420
|
+
}
|
|
421
|
+
/** The items that render a box: a hidden tile reports offsetLeft 0 and would make an arrow dead. */
|
|
422
|
+
function visibleItems(track) {
|
|
423
|
+
return allItems(track).filter(hasBox);
|
|
424
|
+
}
|
|
425
|
+
function currentIndex(track, list, paging) {
|
|
426
|
+
if (list.length === 0) return 0;
|
|
427
|
+
if (paging === "page") {
|
|
428
|
+
const width = track.clientWidth;
|
|
429
|
+
return width === 0 ? 0 : clamp(Math.round(track.scrollLeft / width), 0, list.length - 1);
|
|
430
|
+
}
|
|
431
|
+
const offsets = snapOffsets(track, list);
|
|
432
|
+
const distance = (at) => Math.abs((offsets[at] ?? 0) - track.scrollLeft);
|
|
433
|
+
return offsets.reduce((best, _, at) => distance(at) < distance(best) ? at : best, 0);
|
|
434
|
+
}
|
|
435
|
+
/** Per item, the scroll position that puts it at the track's start, honouring the scroll padding, clamped to
|
|
436
|
+
* the scrollable range (the last items cannot reach the start). */
|
|
437
|
+
function snapOffsets(track, list) {
|
|
438
|
+
const padding = scrollPaddingStart(track);
|
|
439
|
+
const origin = track.getBoundingClientRect().left - track.scrollLeft;
|
|
440
|
+
const max = maxScroll(track);
|
|
441
|
+
return list.map((item) => clamp(item.getBoundingClientRect().left - origin - padding, 0, max));
|
|
442
|
+
}
|
|
443
|
+
const measuredPadding = /* @__PURE__ */ new WeakMap();
|
|
444
|
+
/** The used value of `scroll-padding-inline-start` in px. A `max()`/`calc()` value reads back unresolved,
|
|
445
|
+
* and parseFloat of it is NaN (cards landed hundreds of px off): such a value is measured once per value
|
|
446
|
+
* and track width on a probe, never on every scroll frame. */
|
|
447
|
+
function scrollPaddingStart(track) {
|
|
448
|
+
const value = getComputedStyle(track).scrollPaddingInlineStart;
|
|
449
|
+
if (!value || value === "auto") return 0;
|
|
450
|
+
if (/^-?\d+(\.\d+)?px$/.test(value)) return Number.parseFloat(value);
|
|
451
|
+
const key = `${value}|${track.clientWidth}`;
|
|
452
|
+
const cached = measuredPadding.get(track);
|
|
453
|
+
if (cached?.key === key) return cached.px;
|
|
454
|
+
const probe = document.createElement("div");
|
|
455
|
+
probe.style.cssText = `display:block;flex:none;height:0;visibility:hidden;width:${value}`;
|
|
456
|
+
track.append(probe);
|
|
457
|
+
const width = probe.getBoundingClientRect().width;
|
|
458
|
+
probe.remove();
|
|
459
|
+
const px = Number.isFinite(width) ? width : 0;
|
|
460
|
+
measuredPadding.set(track, {
|
|
461
|
+
key,
|
|
462
|
+
px
|
|
299
463
|
});
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
464
|
+
return px;
|
|
465
|
+
}
|
|
466
|
+
function maxScroll(track) {
|
|
467
|
+
return Math.max(0, track.scrollWidth - track.clientWidth);
|
|
468
|
+
}
|
|
469
|
+
/** Where the strip stands: a page strip by its index, a snap strip by its scroll position (several items
|
|
470
|
+
* share the last screen, so the last index is never reached there). */
|
|
471
|
+
function edgesOf(track, current, total, settings) {
|
|
472
|
+
if (total <= 1) return {
|
|
473
|
+
atStart: true,
|
|
474
|
+
atEnd: true
|
|
475
|
+
};
|
|
476
|
+
if (settings.paging === "page") return {
|
|
477
|
+
atStart: current <= 0,
|
|
478
|
+
atEnd: current >= total - 1
|
|
479
|
+
};
|
|
480
|
+
return {
|
|
481
|
+
atStart: track.scrollLeft <= 1,
|
|
482
|
+
atEnd: track.scrollLeft >= maxScroll(track) - 1
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
/** The item the visitor is about to reach (one past the current), for the deferred photos. */
|
|
486
|
+
function upcoming(track, list, paging) {
|
|
487
|
+
if (paging === "page" && track.clientWidth > 0) return Math.ceil(track.scrollLeft / track.clientWidth) + 1;
|
|
488
|
+
return currentIndex(track, list, paging) + 1;
|
|
489
|
+
}
|
|
490
|
+
function paintArrows(prev, next, edge, edges) {
|
|
491
|
+
const single = edge.atStart && edge.atEnd;
|
|
492
|
+
setDisabled(prev, single || edges === "stop" && edge.atStart);
|
|
493
|
+
setDisabled(next, single || edges === "stop" && edge.atEnd);
|
|
494
|
+
}
|
|
495
|
+
function setDisabled(control, disabled) {
|
|
496
|
+
if (!control) return;
|
|
497
|
+
if (control instanceof HTMLButtonElement) control.disabled = disabled;
|
|
498
|
+
else control.toggleAttribute("aria-disabled", disabled);
|
|
499
|
+
}
|
|
500
|
+
function paintDots(dots, current, total) {
|
|
501
|
+
if (!dots) return;
|
|
502
|
+
[...dots.children].forEach((dot, at) => {
|
|
503
|
+
if (at === current) dot.setAttribute("data-active", "true");
|
|
504
|
+
else dot.removeAttribute("data-active");
|
|
505
|
+
});
|
|
506
|
+
const template = dots.dataset["template"];
|
|
507
|
+
if (template) dots.setAttribute("aria-label", fillTemplate(template, {
|
|
508
|
+
current: current + 1,
|
|
509
|
+
count: total
|
|
510
|
+
}));
|
|
511
|
+
}
|
|
512
|
+
function paintCounter(counter, current, total) {
|
|
513
|
+
if (!counter || total === 0) return;
|
|
514
|
+
counter.textContent = fillTemplate(counter.dataset["template"] ?? DEFAULT_COUNTER, {
|
|
515
|
+
current: current + 1,
|
|
516
|
+
count: total
|
|
305
517
|
});
|
|
306
|
-
|
|
307
|
-
|
|
518
|
+
}
|
|
519
|
+
/** Arrows sit inside a card link: the click must step the strip and nothing else. */
|
|
520
|
+
function bindArrow(control, onPress) {
|
|
521
|
+
if (!control) return () => {};
|
|
522
|
+
return listen(control, "click", (event) => {
|
|
523
|
+
event.preventDefault();
|
|
524
|
+
event.stopPropagation();
|
|
525
|
+
onPress();
|
|
308
526
|
});
|
|
309
527
|
}
|
|
528
|
+
/** Wait for the first child (an island that fills its strip after mount), then start once. */
|
|
529
|
+
function whenFilled(track, start) {
|
|
530
|
+
const observer = new MutationObserver(() => {
|
|
531
|
+
if (track.children.length === 0) return;
|
|
532
|
+
observer.disconnect();
|
|
533
|
+
start();
|
|
534
|
+
});
|
|
535
|
+
observer.observe(track, { childList: true });
|
|
536
|
+
return () => observer.disconnect();
|
|
537
|
+
}
|
|
538
|
+
/** Mouse drag for desktop (touch and pen scroll natively). The drag stays where it is released; the
|
|
539
|
+
* click that ends a drag is swallowed so a stretched card link does not navigate. */
|
|
540
|
+
function bindDrag(root, track) {
|
|
541
|
+
let suppressUntil = 0;
|
|
542
|
+
let stopDrag = () => {};
|
|
543
|
+
const onPointerDown = (down) => {
|
|
544
|
+
if (down.pointerType !== "mouse" || down.button !== 0) return;
|
|
545
|
+
const startX = down.clientX;
|
|
546
|
+
const startLeft = track.scrollLeft;
|
|
547
|
+
let dragging = false;
|
|
548
|
+
const onMove = (move) => {
|
|
549
|
+
if (move.pointerId !== down.pointerId) return;
|
|
550
|
+
const dx = move.clientX - startX;
|
|
551
|
+
if (!dragging && Math.abs(dx) < DRAG_THRESHOLD_PX) return;
|
|
552
|
+
if (!dragging) {
|
|
553
|
+
dragging = true;
|
|
554
|
+
root.toggleAttribute("data-dragging", true);
|
|
555
|
+
window.getSelection()?.removeAllRanges();
|
|
556
|
+
}
|
|
557
|
+
move.preventDefault();
|
|
558
|
+
track.scrollLeft = startLeft - dx;
|
|
559
|
+
};
|
|
560
|
+
const onEnd = (end) => {
|
|
561
|
+
if (end.pointerId !== down.pointerId) return;
|
|
562
|
+
if (dragging) suppressUntil = performance.now() + CLICK_SUPPRESS_MS;
|
|
563
|
+
root.removeAttribute("data-dragging");
|
|
564
|
+
stopDrag();
|
|
565
|
+
};
|
|
566
|
+
stopDrag = combine([
|
|
567
|
+
listen(document, "pointermove", onMove, { passive: false }),
|
|
568
|
+
listen(document, "pointerup", onEnd),
|
|
569
|
+
listen(document, "pointercancel", onEnd)
|
|
570
|
+
]);
|
|
571
|
+
};
|
|
572
|
+
const onClick = (event) => {
|
|
573
|
+
if (performance.now() > suppressUntil) return;
|
|
574
|
+
suppressUntil = 0;
|
|
575
|
+
event.preventDefault();
|
|
576
|
+
event.stopPropagation();
|
|
577
|
+
};
|
|
578
|
+
return combine([
|
|
579
|
+
listen(track, "pointerdown", onPointerDown),
|
|
580
|
+
listen(root, "click", onClick, { capture: true }),
|
|
581
|
+
() => stopDrag()
|
|
582
|
+
]);
|
|
583
|
+
}
|
|
584
|
+
/** Promote deferred photos one item ahead of the visitor: a pointer over the strip, a finger on it or focus
|
|
585
|
+
* inside it gives the next item its source; scrolling gives every item up to one past the one arriving. */
|
|
586
|
+
function bindDeferredSlides(root, track, upTo) {
|
|
587
|
+
const promote = () => {
|
|
588
|
+
allItems(track).filter(hasBox).slice(0, upTo() + 1).forEach(promoteImages);
|
|
589
|
+
};
|
|
590
|
+
return combine([
|
|
591
|
+
listen(root, "pointerover", promote, { passive: true }),
|
|
592
|
+
listen(root, "touchstart", promote, { passive: true }),
|
|
593
|
+
listen(root, "focusin", promote),
|
|
594
|
+
listen(track, "scroll", promote, { passive: true })
|
|
595
|
+
]);
|
|
596
|
+
}
|
|
597
|
+
/** srcset before src, so the browser picks from the set and never fetches the fallback first. The image
|
|
598
|
+
* keeps loading="lazy", so the browser still owns when it fetches. */
|
|
599
|
+
function promoteImages(item) {
|
|
600
|
+
for (const image of item.querySelectorAll("img[data-src]")) {
|
|
601
|
+
const srcset = image.dataset["srcset"];
|
|
602
|
+
if (srcset) image.srcset = srcset;
|
|
603
|
+
image.src = image.dataset["src"] ?? image.src;
|
|
604
|
+
image.removeAttribute("data-src");
|
|
605
|
+
image.removeAttribute("data-srcset");
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
function clamp(value, min, max) {
|
|
609
|
+
return Math.min(max, Math.max(min, value));
|
|
610
|
+
}
|
|
310
611
|
//#endregion
|
|
311
612
|
//#region src/enhance/gallery.ts
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
613
|
+
const DIALOGS = {
|
|
614
|
+
grid: `dialog${partSelector(ENHANCE_PARTS.galleryGrid)}`,
|
|
615
|
+
viewer: `dialog${partSelector(ENHANCE_PARTS.galleryViewer)}`,
|
|
616
|
+
floorplan: `dialog${partSelector(ENHANCE_PARTS.floorplan)}`
|
|
617
|
+
};
|
|
618
|
+
const LAYER_OPEN = partSelector(ENHANCE_PARTS.layerOpen);
|
|
619
|
+
const LANDING_FRAMES = 5;
|
|
620
|
+
/** See {@link BindGallery}. */
|
|
621
|
+
const bindGallery = (root) => bindOnce(root, "gallery", () => ({ dispose: createGallery(root) })).dispose;
|
|
622
|
+
function createGallery(root) {
|
|
623
|
+
const strips = [...root.querySelectorAll(STRIP_ROOT)].map((strip) => ({
|
|
624
|
+
element: strip,
|
|
625
|
+
handle: bindStrip(strip, { paging: "page" })
|
|
626
|
+
}));
|
|
627
|
+
const stripIn = (scope) => strips.find(({ element }) => element.closest("dialog") === scope)?.handle ?? null;
|
|
628
|
+
const pageStrip = stripIn(null);
|
|
629
|
+
const viewer = root.querySelector(DIALOGS.viewer);
|
|
630
|
+
const viewerStrip = viewer ? stripIn(viewer) : null;
|
|
631
|
+
const viewerTrack = viewer?.querySelector(partSelector(ENHANCE_PARTS.stripTrack)) ?? null;
|
|
632
|
+
const floorplan = root.querySelector(DIALOGS.floorplan);
|
|
633
|
+
let lastViewed = null;
|
|
634
|
+
const layers = /* @__PURE__ */ new Map();
|
|
635
|
+
for (const dialog of root.querySelectorAll(`${DIALOGS.grid}, ${DIALOGS.viewer}, ${DIALOGS.floorplan}`)) {
|
|
636
|
+
const viewerOptions = dialog === viewer && viewerStrip && viewerTrack ? {
|
|
637
|
+
onOpen: (opener) => landOn(viewerTrack, viewerStrip, photoIndex(opener) ?? pageStrip?.index ?? 0),
|
|
638
|
+
onClose: () => lastViewed = viewerStrip.index
|
|
639
|
+
} : {};
|
|
640
|
+
layers.set(dialog, bindLayer(dialog, viewerOptions));
|
|
641
|
+
}
|
|
642
|
+
const onViewerClosed = () => {
|
|
643
|
+
if (lastViewed === null) return;
|
|
644
|
+
pageStrip?.goTo(lastViewed, "instant");
|
|
645
|
+
lastViewed = null;
|
|
646
|
+
};
|
|
647
|
+
const onClick = (event) => {
|
|
648
|
+
const opener = openerOf(event.target, root);
|
|
649
|
+
if (!opener || opener.matches(LAYER_OPEN)) return;
|
|
650
|
+
const layer = layerFor(opener, layers);
|
|
651
|
+
if (!layer) return;
|
|
652
|
+
event.preventDefault();
|
|
653
|
+
layer.open(opener);
|
|
654
|
+
};
|
|
655
|
+
const onViewerKey = (event) => {
|
|
656
|
+
if (event.key !== "ArrowRight" && event.key !== "ArrowLeft") return;
|
|
657
|
+
event.preventDefault();
|
|
658
|
+
viewerStrip?.step(event.key === "ArrowRight" ? 1 : -1);
|
|
659
|
+
};
|
|
660
|
+
return combine([
|
|
661
|
+
...strips.map(({ handle }) => handle.dispose),
|
|
662
|
+
...[...layers.values()].map((layer) => layer.dispose),
|
|
663
|
+
listen(root, "click", onClick),
|
|
664
|
+
viewer ? listen(viewer, "keydown", onViewerKey) : () => {},
|
|
665
|
+
viewer ? listen(viewer, "close", onViewerClosed) : () => {},
|
|
666
|
+
floorplan ? publishRatios(floorplan) : () => {},
|
|
667
|
+
floorplan ? warmOnIntent(root, floorplan) : () => {}
|
|
668
|
+
]);
|
|
669
|
+
}
|
|
670
|
+
/** The nearest element (from a click target up to the gallery root) whose aria-controls names a dialog. */
|
|
671
|
+
function openerOf(target, root) {
|
|
672
|
+
const opener = target instanceof Element ? target.closest("[aria-controls]") : null;
|
|
673
|
+
return opener && root.contains(opener) ? opener : null;
|
|
674
|
+
}
|
|
675
|
+
function layerFor(opener, layers) {
|
|
676
|
+
const id = opener.getAttribute("aria-controls");
|
|
677
|
+
for (const [dialog, layer] of layers) if (dialog.id === id) return layer;
|
|
678
|
+
return null;
|
|
679
|
+
}
|
|
680
|
+
function photoIndex(opener) {
|
|
681
|
+
const value = opener?.dataset["index"];
|
|
682
|
+
const index = value === void 0 ? NaN : Number(value);
|
|
683
|
+
return Number.isInteger(index) && index >= 0 ? index : null;
|
|
684
|
+
}
|
|
685
|
+
/** The viewer may still be closed in the first frame (a view transition runs the mutation once it has its
|
|
686
|
+
* snapshot), and a closed track has width 0, so 0 × index lands on photo 1. Wait for a real width, and give
|
|
687
|
+
* up after a handful of frames rather than spin: a viewer that never lays out has a problem this cannot fix. */
|
|
688
|
+
function landOn(track, strip, index, attempt = 0) {
|
|
689
|
+
requestAnimationFrame(() => {
|
|
690
|
+
if (track.clientWidth === 0 && attempt < LANDING_FRAMES) {
|
|
691
|
+
landOn(track, strip, index, attempt + 1);
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
strip.goTo(index, "instant");
|
|
354
695
|
});
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
const
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
696
|
+
}
|
|
697
|
+
/** The plan's real ratio replaces the width/height guess: sizing keys on `--fp-ar`, so a guess that is off
|
|
698
|
+
* shows a correctly sized plan the moment the bytes arrive. */
|
|
699
|
+
function publishRatios(floorplan) {
|
|
700
|
+
const publish = (image) => {
|
|
701
|
+
if (image.naturalWidth && image.naturalHeight) image.style.setProperty("--fp-ar", `${image.naturalWidth}/${image.naturalHeight}`);
|
|
702
|
+
};
|
|
703
|
+
const images = [...floorplan.querySelectorAll("img")];
|
|
704
|
+
images.filter((image) => image.complete).forEach(publish);
|
|
705
|
+
return combine(images.map((image) => listen(image, "load", () => publish(image))));
|
|
706
|
+
}
|
|
707
|
+
/** The floor plan is lazy, so nobody pays for a plan never opened, but it would open onto an empty surface
|
|
708
|
+
* while the bytes arrive. Hover, focus and (on a touchscreen) the tap itself put it in the HTTP cache. */
|
|
709
|
+
function warmOnIntent(root, floorplan) {
|
|
710
|
+
const image = floorplan.querySelector("img");
|
|
711
|
+
if (!image || !floorplan.id) return () => {};
|
|
712
|
+
let warmed = false;
|
|
713
|
+
const warm = (event) => {
|
|
714
|
+
if (warmed || !(event.target instanceof Element)) return;
|
|
715
|
+
if (event.target.closest("[aria-controls]")?.getAttribute("aria-controls") !== floorplan.id) return;
|
|
716
|
+
warmed = true;
|
|
717
|
+
const preload = new Image();
|
|
718
|
+
if (image.srcset) preload.srcset = image.srcset;
|
|
719
|
+
if (image.sizes) preload.sizes = image.sizes;
|
|
720
|
+
preload.src = image.currentSrc || image.src;
|
|
721
|
+
};
|
|
722
|
+
return combine([listen(root, "pointerover", warm, { passive: true }), listen(root, "focusin", warm)]);
|
|
723
|
+
}
|
|
724
|
+
//#endregion
|
|
725
|
+
//#region src/enhance/image-fade.ts
|
|
726
|
+
/** See {@link BindImageFade}. */
|
|
727
|
+
const bindImageFade = (root) => bindOnce(root, "image-fade", () => ({ dispose: createImageFade(root) })).dispose;
|
|
728
|
+
function createImageFade(root) {
|
|
729
|
+
for (const image of root.querySelectorAll("img[loading=\"lazy\"]")) if (image.complete && (image.currentSrc || image.getAttribute("src"))) image.dataset["loaded"] = "cache";
|
|
730
|
+
root.toggleAttribute("data-image-fade", true);
|
|
731
|
+
const mark = (value) => (event) => {
|
|
732
|
+
const image = event.target;
|
|
733
|
+
if (image instanceof HTMLImageElement && image.getAttribute("loading") === "lazy" && !image.dataset["loaded"]) image.dataset["loaded"] = value;
|
|
734
|
+
};
|
|
735
|
+
return combine([
|
|
736
|
+
() => root.removeAttribute("data-image-fade"),
|
|
737
|
+
listen(root, "load", mark("fade"), { capture: true }),
|
|
738
|
+
listen(root, "error", mark("cache"), { capture: true })
|
|
739
|
+
]);
|
|
740
|
+
}
|
|
741
|
+
//#endregion
|
|
742
|
+
//#region src/enhance/reveal.ts
|
|
743
|
+
const REVEAL = partSelector(ENHANCE_PARTS.reveal);
|
|
744
|
+
const CLAMP = partSelector(ENHANCE_PARTS.clamp);
|
|
745
|
+
/** See {@link BindReveal}. */
|
|
746
|
+
const bindReveal = (root, options = {}) => bindOnce(root, "reveal", () => ({ dispose: createReveal(root, options) })).dispose;
|
|
747
|
+
/** See {@link BindClamp}. */
|
|
748
|
+
const bindClamp = (root) => bindOnce(root, "clamp", () => ({ dispose: createClamp(root) })).dispose;
|
|
749
|
+
function createReveal(root, options) {
|
|
750
|
+
const own = (part) => selectOwned(root, partSelector(part), REVEAL);
|
|
751
|
+
const collapsedItems = own(ENHANCE_PARTS.revealItem).filter((item) => item.hidden);
|
|
752
|
+
const more = own(ENHANCE_PARTS.revealMore)[0] ?? null;
|
|
753
|
+
const less = own(ENHANCE_PARTS.revealLess)[0] ?? null;
|
|
754
|
+
const step = options.step ?? positiveInteger(root.dataset["step"]) ?? Number.POSITIVE_INFINITY;
|
|
755
|
+
const collapsible = less !== null && (options.collapsible ?? true);
|
|
756
|
+
const paint = () => {
|
|
757
|
+
const remaining = collapsedItems.some((item) => item.hidden);
|
|
758
|
+
const revealed = collapsedItems.some((item) => !item.hidden);
|
|
759
|
+
if (more) {
|
|
760
|
+
more.hidden = !remaining;
|
|
761
|
+
more.setAttribute("aria-expanded", String(!remaining));
|
|
762
|
+
}
|
|
763
|
+
if (less) less.hidden = !(collapsible && revealed);
|
|
764
|
+
root.toggleAttribute("data-open", revealed);
|
|
765
|
+
};
|
|
766
|
+
const showMore = () => {
|
|
767
|
+
const next = collapsedItems.filter((item) => item.hidden).slice(0, step);
|
|
768
|
+
for (const item of next) item.hidden = false;
|
|
769
|
+
paint();
|
|
770
|
+
const first = next[0];
|
|
771
|
+
if (first) {
|
|
772
|
+
if (first.tabIndex < 0 && !first.hasAttribute("tabindex")) first.tabIndex = -1;
|
|
773
|
+
focusForKeyboard(first);
|
|
774
|
+
}
|
|
775
|
+
};
|
|
776
|
+
const showLess = () => {
|
|
777
|
+
for (const item of collapsedItems) item.hidden = true;
|
|
778
|
+
paint();
|
|
779
|
+
if (more) focusForKeyboard(more);
|
|
780
|
+
};
|
|
781
|
+
paint();
|
|
782
|
+
return combine([
|
|
783
|
+
trackInputModality(),
|
|
784
|
+
more ? listen(more, "click", showMore) : () => {},
|
|
785
|
+
less ? listen(less, "click", showLess) : () => {}
|
|
786
|
+
]);
|
|
787
|
+
}
|
|
788
|
+
function createClamp(root) {
|
|
789
|
+
const text = selectOwned(root, partSelector(ENHANCE_PARTS.clampText), CLAMP)[0];
|
|
790
|
+
const toggle = selectOwned(root, partSelector(ENHANCE_PARTS.clampToggle), CLAMP)[0];
|
|
791
|
+
if (!text || !toggle) return () => {};
|
|
792
|
+
const expanded = () => root.hasAttribute("data-expanded");
|
|
793
|
+
const measure = () => {
|
|
794
|
+
if (expanded()) return;
|
|
795
|
+
toggle.hidden = text.scrollHeight <= text.clientHeight + 1;
|
|
796
|
+
};
|
|
797
|
+
const onToggle = () => {
|
|
798
|
+
const next = !expanded();
|
|
799
|
+
root.toggleAttribute("data-expanded", next);
|
|
800
|
+
toggle.setAttribute("aria-expanded", String(next));
|
|
801
|
+
measure();
|
|
802
|
+
};
|
|
803
|
+
toggle.setAttribute("aria-expanded", String(expanded()));
|
|
804
|
+
measure();
|
|
805
|
+
const observer = new ResizeObserver(measure);
|
|
806
|
+
observer.observe(text);
|
|
807
|
+
return combine([listen(toggle, "click", onToggle), () => observer.disconnect()]);
|
|
808
|
+
}
|
|
809
|
+
function positiveInteger(value) {
|
|
810
|
+
const parsed = value === void 0 ? NaN : Number(value);
|
|
811
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
|
812
|
+
}
|
|
813
|
+
//#endregion
|
|
814
|
+
//#region src/enhance/share.ts
|
|
815
|
+
/** Share buttons, the legacy kit attribute included. Internal to ./enhance. */
|
|
816
|
+
const SHARE_BUTTON = `${partSelector(ENHANCE_PARTS.share)}, [data-share]`;
|
|
817
|
+
const STATUS = `${partSelector(ENHANCE_PARTS.shareStatus)}, [data-share-live]`;
|
|
818
|
+
const DONE_MS = 2e3;
|
|
819
|
+
/** See {@link BindShare}. */
|
|
820
|
+
const bindShare = (button, options = {}) => bindOnce(button, "share", () => ({ dispose: createShare(button, options) })).dispose;
|
|
821
|
+
function createShare(button, options) {
|
|
822
|
+
let timer = 0;
|
|
823
|
+
const onClick = async () => {
|
|
824
|
+
const url = shareUrl(location, {
|
|
825
|
+
origin: options.origin || button.dataset["shareOrigin"] || location.origin,
|
|
826
|
+
...options.strip ? { strip: options.strip } : {}
|
|
391
827
|
});
|
|
392
|
-
if (
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
};
|
|
399
|
-
if (img.complete) applyRatio();
|
|
400
|
-
img.addEventListener("load", applyRatio, { once: true });
|
|
401
|
-
}
|
|
828
|
+
if (prefersNativeShare() && await nativeShare(button, url)) return;
|
|
829
|
+
try {
|
|
830
|
+
await navigator.clipboard.writeText(url);
|
|
831
|
+
} catch {
|
|
832
|
+
window.prompt(button.getAttribute("aria-label") ?? button.textContent?.trim() ?? "", url);
|
|
833
|
+
return;
|
|
402
834
|
}
|
|
835
|
+
window.clearTimeout(timer);
|
|
836
|
+
showDone(button, true);
|
|
837
|
+
timer = window.setTimeout(() => showDone(button, false), DONE_MS);
|
|
838
|
+
};
|
|
839
|
+
return combine([listen(button, "click", () => void onClick()), () => {
|
|
840
|
+
window.clearTimeout(timer);
|
|
841
|
+
showDone(button, false);
|
|
842
|
+
}]);
|
|
843
|
+
}
|
|
844
|
+
/** The OS share sheet is the expected pattern on a phone or tablet, not with mouse and hover (even where
|
|
845
|
+
* `navigator.share` exists). */
|
|
846
|
+
function prefersNativeShare() {
|
|
847
|
+
return typeof navigator.share === "function" && typeof window.matchMedia === "function" && window.matchMedia("(hover: none) and (pointer: coarse)").matches;
|
|
848
|
+
}
|
|
849
|
+
/** True when the sheet handled it (shared or dismissed); false to fall back to the clipboard. */
|
|
850
|
+
async function nativeShare(button, url) {
|
|
851
|
+
try {
|
|
852
|
+
await navigator.share({
|
|
853
|
+
title: button.dataset["shareTitle"] || document.title,
|
|
854
|
+
url
|
|
855
|
+
});
|
|
856
|
+
return true;
|
|
857
|
+
} catch (error) {
|
|
858
|
+
return error instanceof DOMException && error.name === "AbortError";
|
|
403
859
|
}
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
const
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
860
|
+
}
|
|
861
|
+
function showDone(button, done) {
|
|
862
|
+
if (done) button.setAttribute("data-state", "done");
|
|
863
|
+
else button.removeAttribute("data-state");
|
|
864
|
+
const idle = button.querySelector("[data-share-idle]");
|
|
865
|
+
const doneLabel = button.querySelector("[data-share-done]");
|
|
866
|
+
if (idle) idle.hidden = done;
|
|
867
|
+
if (doneLabel) doneLabel.hidden = !done;
|
|
868
|
+
const status = button.querySelector(STATUS);
|
|
869
|
+
if (status) status.textContent = done ? status.dataset["message"] ?? doneLabel?.textContent?.trim() ?? "" : "";
|
|
870
|
+
}
|
|
871
|
+
//#endregion
|
|
872
|
+
//#region src/enhance/enhance.ts
|
|
873
|
+
const GALLERY = partSelector(ENHANCE_PARTS.gallery);
|
|
874
|
+
/** See {@link Enhance}. */
|
|
875
|
+
const enhance = (root = document, options = {}) => {
|
|
876
|
+
const disposes = [];
|
|
877
|
+
const each = (selector, family, bind) => {
|
|
878
|
+
for (const element of selectAll(root, selector)) if (!isBound(element, family)) disposes.push(bind(element));
|
|
414
879
|
};
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
880
|
+
if (options.galleries ?? true) each(GALLERY, "gallery", bindGallery);
|
|
881
|
+
if (options.imageFade ?? true) each(GALLERY, "image-fade", bindImageFade);
|
|
882
|
+
if (options.layers ?? true) each(`dialog${partSelector(ENHANCE_PARTS.layer)}`, "layer", (dialog) => {
|
|
883
|
+
if (!(dialog instanceof HTMLDialogElement)) return () => {};
|
|
884
|
+
return bindLayer(dialog).dispose;
|
|
885
|
+
});
|
|
886
|
+
if (options.reveals ?? true) each(partSelector(ENHANCE_PARTS.reveal), "reveal", (element) => bindReveal(element));
|
|
887
|
+
if (options.clamps ?? true) each(partSelector(ENHANCE_PARTS.clamp), "clamp", bindClamp);
|
|
888
|
+
const share = options.share ?? true;
|
|
889
|
+
if (share !== false) each(SHARE_BUTTON, "share", (button) => bindShare(button, share === true ? {} : share));
|
|
890
|
+
if (options.favorites && !isBound(root, "favorites")) disposes.push(bindFavorites(root, options.favorites));
|
|
891
|
+
if (options.strips ?? true) disposes.push(onIdle(() => each(STRIP_ROOT, "strip", (strip) => bindStrip(strip).dispose)));
|
|
892
|
+
return combine(disposes);
|
|
893
|
+
};
|
|
894
|
+
//#endregion
|
|
895
|
+
//#region src/enhance/map/card-hover.ts
|
|
896
|
+
/** See {@link ObserveResultCardHover}. */
|
|
897
|
+
const observeResultCardHover = (onHover) => {
|
|
898
|
+
let current = null;
|
|
899
|
+
const report = (id) => {
|
|
900
|
+
if (id === current) return;
|
|
901
|
+
current = id;
|
|
902
|
+
onHover(id);
|
|
419
903
|
};
|
|
420
|
-
const
|
|
421
|
-
|
|
904
|
+
const cardOf = (target) => target instanceof Element ? target.closest("[data-result-card]")?.getAttribute("data-result-card") ?? null : null;
|
|
905
|
+
return combine([
|
|
906
|
+
listen(document, "pointerover", (event) => report(cardOf(event.target))),
|
|
907
|
+
listen(document, "focusin", (event) => report(cardOf(event.target))),
|
|
908
|
+
listen(document, "pointerout", (event) => {
|
|
909
|
+
if (event.relatedTarget === null) report(null);
|
|
910
|
+
}),
|
|
911
|
+
listen(document, "focusout", (event) => {
|
|
912
|
+
if (event.relatedTarget === null) report(null);
|
|
913
|
+
})
|
|
914
|
+
]);
|
|
915
|
+
};
|
|
916
|
+
//#endregion
|
|
917
|
+
//#region src/enhance/map/maplibre.ts
|
|
918
|
+
let pending = null;
|
|
919
|
+
/** See {@link LoadMapLibre}. */
|
|
920
|
+
const loadMapLibre = (load) => {
|
|
921
|
+
pending ??= load().then((module) => "default" in module ? module.default : module, (error) => {
|
|
922
|
+
pending = null;
|
|
923
|
+
throw error;
|
|
924
|
+
});
|
|
925
|
+
return pending;
|
|
926
|
+
};
|
|
927
|
+
//#endregion
|
|
928
|
+
//#region src/enhance/map/surface.ts
|
|
929
|
+
/** Moves shorter than this between press and release are a click, on the map (`clickTolerance`) AND on a
|
|
930
|
+
* pin: one number in two places, because two thresholds deciding the same thing disagreed (MapLibre's
|
|
931
|
+
* default 3 px started a drag that carried the pin away from under the pointer). Half a pin's width:
|
|
932
|
+
* the blur that aiming itself has. */
|
|
933
|
+
const CLICK_TOLERANCE_PX = 12;
|
|
934
|
+
const GUEST_INPUT = [
|
|
935
|
+
"mousedown",
|
|
936
|
+
"wheel",
|
|
937
|
+
"touchstart",
|
|
938
|
+
"keydown"
|
|
939
|
+
];
|
|
940
|
+
/** See {@link CreateMapSurface}. */
|
|
941
|
+
const createMapSurface = (options) => {
|
|
942
|
+
let map = null;
|
|
943
|
+
let building = null;
|
|
944
|
+
let destroyed = false;
|
|
945
|
+
let byGuest = false;
|
|
946
|
+
const cleanups = [];
|
|
947
|
+
const report = () => {
|
|
948
|
+
if (!map) return;
|
|
949
|
+
const { clientWidth, clientHeight } = options.container;
|
|
950
|
+
const guest = byGuest;
|
|
951
|
+
byGuest = false;
|
|
952
|
+
options.onView?.(clientWidth === 0 || clientHeight === 0 ? null : boundsOf(map), guest);
|
|
422
953
|
};
|
|
423
|
-
const
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
954
|
+
const create = async () => {
|
|
955
|
+
await whenWide(options.container, cleanups);
|
|
956
|
+
const lib = await loadMapLibre(options.load);
|
|
957
|
+
if (destroyed || map) return;
|
|
958
|
+
const created = new lib.Map({
|
|
959
|
+
container: options.container,
|
|
960
|
+
style: options.styleUrl,
|
|
961
|
+
scrollZoom: options.scrollZoom,
|
|
962
|
+
attributionControl: { compact: false },
|
|
963
|
+
clickTolerance: 12,
|
|
964
|
+
...cameraOptions(options.camera)
|
|
428
965
|
});
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
if (!(
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
966
|
+
map = created;
|
|
967
|
+
created.once("load", () => {
|
|
968
|
+
if (!destroyed) options.onReady?.(created, lib);
|
|
969
|
+
});
|
|
970
|
+
created.on("moveend", report);
|
|
971
|
+
cleanups.push(...GUEST_INPUT.map((type) => listen(options.container, type, () => byGuest = true, { passive: true })), observeSize(options.container, report));
|
|
972
|
+
};
|
|
973
|
+
const surface = {
|
|
974
|
+
build: () => building ??= create(),
|
|
975
|
+
map: () => map,
|
|
976
|
+
zoomIn: () => {
|
|
977
|
+
byGuest = true;
|
|
978
|
+
map?.zoomIn();
|
|
979
|
+
},
|
|
980
|
+
zoomOut: () => {
|
|
981
|
+
byGuest = true;
|
|
982
|
+
map?.zoomOut();
|
|
983
|
+
},
|
|
984
|
+
fit: (bounds, padding, maxZoom) => {
|
|
985
|
+
byGuest = false;
|
|
986
|
+
map?.fitBounds(bounds, {
|
|
987
|
+
padding,
|
|
988
|
+
maxZoom,
|
|
989
|
+
animate: false
|
|
443
990
|
});
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
991
|
+
},
|
|
992
|
+
resize: () => map?.resize(),
|
|
993
|
+
destroy: () => {
|
|
994
|
+
destroyed = true;
|
|
995
|
+
combine(cleanups)();
|
|
996
|
+
map?.remove();
|
|
997
|
+
map = null;
|
|
998
|
+
}
|
|
448
999
|
};
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
1000
|
+
if (options.start === "near") cleanups.push(whenNear(options.container, options.nearMargin ?? "100% 0px", () => void surface.build()));
|
|
1001
|
+
return surface;
|
|
1002
|
+
};
|
|
1003
|
+
/** See {@link MarkerElement}. */
|
|
1004
|
+
const markerElement = ({ tag, part }) => {
|
|
1005
|
+
const el = document.createElement(tag);
|
|
1006
|
+
if (el instanceof HTMLButtonElement) el.type = "button";
|
|
1007
|
+
const body = document.createElement("span");
|
|
1008
|
+
body.dataset["part"] = part;
|
|
1009
|
+
el.append(body);
|
|
1010
|
+
return {
|
|
1011
|
+
el,
|
|
1012
|
+
body
|
|
1013
|
+
};
|
|
1014
|
+
};
|
|
1015
|
+
/** See {@link BindActivation}. */
|
|
1016
|
+
const bindActivation = (element, onActivate) => {
|
|
1017
|
+
let start = null;
|
|
1018
|
+
return combine([
|
|
1019
|
+
listen(element, "pointerdown", (event) => {
|
|
1020
|
+
start = {
|
|
1021
|
+
x: event.clientX,
|
|
1022
|
+
y: event.clientY
|
|
1023
|
+
};
|
|
1024
|
+
try {
|
|
1025
|
+
element.setPointerCapture(event.pointerId);
|
|
1026
|
+
} catch {}
|
|
1027
|
+
}),
|
|
1028
|
+
listen(element, "pointercancel", () => start = null),
|
|
1029
|
+
listen(element, "pointerup", (event) => {
|
|
1030
|
+
if (!start) return;
|
|
1031
|
+
const moved = Math.hypot(event.clientX - start.x, event.clientY - start.y);
|
|
1032
|
+
start = null;
|
|
1033
|
+
if (moved > 12) return;
|
|
1034
|
+
event.stopPropagation();
|
|
1035
|
+
onActivate();
|
|
1036
|
+
}),
|
|
1037
|
+
listen(element, "click", (event) => {
|
|
1038
|
+
event.stopPropagation();
|
|
1039
|
+
if (event.detail === 0) onActivate();
|
|
1040
|
+
})
|
|
1041
|
+
]);
|
|
1042
|
+
};
|
|
1043
|
+
function cameraOptions(camera) {
|
|
1044
|
+
if ("center" in camera) return {
|
|
1045
|
+
center: camera.center,
|
|
1046
|
+
zoom: camera.zoom
|
|
1047
|
+
};
|
|
1048
|
+
return {
|
|
1049
|
+
bounds: camera.bounds(),
|
|
1050
|
+
fitBoundsOptions: {
|
|
1051
|
+
padding: camera.padding,
|
|
1052
|
+
maxZoom: camera.maxZoom
|
|
1053
|
+
}
|
|
1054
|
+
};
|
|
1055
|
+
}
|
|
1056
|
+
function boundsOf(map) {
|
|
1057
|
+
const bounds = map.getBounds();
|
|
1058
|
+
return [[bounds.getWest(), bounds.getSouth()], [bounds.getEast(), bounds.getNorth()]];
|
|
1059
|
+
}
|
|
1060
|
+
/** MapLibre measures during init and a zero-width container renders broken: wait for a width. */
|
|
1061
|
+
function whenWide(container, cleanups) {
|
|
1062
|
+
if (container.clientWidth > 0) return Promise.resolve();
|
|
1063
|
+
return new Promise((resolve) => {
|
|
1064
|
+
const observer = new ResizeObserver(() => {
|
|
1065
|
+
if (container.clientWidth === 0) return;
|
|
1066
|
+
observer.disconnect();
|
|
1067
|
+
resolve();
|
|
455
1068
|
});
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
1069
|
+
observer.observe(container);
|
|
1070
|
+
cleanups.push(() => observer.disconnect());
|
|
1071
|
+
});
|
|
1072
|
+
}
|
|
1073
|
+
/** Run `task` once, the first time `element` comes within `margin` of the viewport. */
|
|
1074
|
+
function whenNear(element, margin, task) {
|
|
1075
|
+
const observer = new IntersectionObserver((entries) => {
|
|
1076
|
+
if (!entries.some((entry) => entry.isIntersecting)) return;
|
|
1077
|
+
observer.disconnect();
|
|
1078
|
+
task();
|
|
1079
|
+
}, { rootMargin: margin });
|
|
1080
|
+
observer.observe(element);
|
|
1081
|
+
return () => observer.disconnect();
|
|
1082
|
+
}
|
|
1083
|
+
/** Report after every size change once built (a layer closing to 0×0, a breakpoint hiding the map). */
|
|
1084
|
+
function observeSize(container, onChange) {
|
|
1085
|
+
let first = true;
|
|
1086
|
+
const observer = new ResizeObserver(() => {
|
|
1087
|
+
if (first) {
|
|
1088
|
+
first = false;
|
|
1089
|
+
return;
|
|
462
1090
|
}
|
|
463
|
-
|
|
1091
|
+
onChange();
|
|
1092
|
+
});
|
|
1093
|
+
observer.observe(container);
|
|
1094
|
+
return () => observer.disconnect();
|
|
1095
|
+
}
|
|
1096
|
+
//#endregion
|
|
1097
|
+
//#region src/enhance/map/place-markers.ts
|
|
1098
|
+
/** Popup distance from the spot: above a teardrop's head, beside a dot. */
|
|
1099
|
+
const POPUP_OFFSET = {
|
|
1100
|
+
bottom: 36,
|
|
1101
|
+
center: 14
|
|
1102
|
+
};
|
|
1103
|
+
/** See {@link BindPlaceMarkers}. */
|
|
1104
|
+
const bindPlaceMarkers = (map, lib, options) => {
|
|
1105
|
+
let popup = null;
|
|
1106
|
+
let disposed = false;
|
|
1107
|
+
const closePopup = () => {
|
|
1108
|
+
popup?.remove();
|
|
1109
|
+
popup = null;
|
|
464
1110
|
};
|
|
465
|
-
const
|
|
466
|
-
|
|
467
|
-
|
|
1111
|
+
const openPopup = (marker, text) => {
|
|
1112
|
+
closePopup();
|
|
1113
|
+
const content = document.createElement("p");
|
|
1114
|
+
content.dataset["part"] = "map-popup";
|
|
1115
|
+
content.textContent = text;
|
|
1116
|
+
popup = new lib.Popup({
|
|
1117
|
+
closeButton: false,
|
|
1118
|
+
closeOnClick: false,
|
|
1119
|
+
offset: POPUP_OFFSET[options.anchor]
|
|
1120
|
+
}).setLngLat(marker.at).setDOMContent(content).addTo(map);
|
|
468
1121
|
};
|
|
469
|
-
const
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
left: next * track.clientWidth,
|
|
474
|
-
behavior: "smooth"
|
|
1122
|
+
const placed = options.markers.map((marker) => {
|
|
1123
|
+
const { el, body } = markerElement({
|
|
1124
|
+
tag: options.popup ? "button" : "div",
|
|
1125
|
+
part: "map-marker"
|
|
475
1126
|
});
|
|
1127
|
+
options.render(body, marker);
|
|
1128
|
+
const instance = new lib.Marker({
|
|
1129
|
+
element: el,
|
|
1130
|
+
anchor: options.anchor
|
|
1131
|
+
}).setLngLat(marker.at).addTo(map);
|
|
1132
|
+
const text = options.popup?.(marker);
|
|
1133
|
+
const release = text ? bindActivation(el, () => openPopup(marker, text)) : () => {};
|
|
1134
|
+
return combine([release, () => instance.remove()]);
|
|
1135
|
+
});
|
|
1136
|
+
const onMapClick = () => {
|
|
1137
|
+
if (!disposed) closePopup();
|
|
476
1138
|
};
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
1139
|
+
map.on("click", onMapClick);
|
|
1140
|
+
return combine([...placed, () => {
|
|
1141
|
+
disposed = true;
|
|
1142
|
+
map.off("click", onMapClick);
|
|
1143
|
+
closePopup();
|
|
1144
|
+
}]);
|
|
1145
|
+
};
|
|
1146
|
+
//#endregion
|
|
1147
|
+
//#region src/enhance/map/result-markers.ts
|
|
1148
|
+
const SOURCE = "voffice-results";
|
|
1149
|
+
const EMPTY = {
|
|
1150
|
+
type: "FeatureCollection",
|
|
1151
|
+
features: []
|
|
1152
|
+
};
|
|
1153
|
+
const NO_OFFSET = [0, 0];
|
|
1154
|
+
/** See {@link BindResultMarkers}. */
|
|
1155
|
+
const bindResultMarkers = (map, lib, options) => {
|
|
1156
|
+
let { points, layout, priced } = options;
|
|
1157
|
+
let expanded = null;
|
|
1158
|
+
let pinWidth = 0;
|
|
1159
|
+
let disposed = false;
|
|
1160
|
+
let active = null;
|
|
1161
|
+
const sources = /* @__PURE__ */ new Set();
|
|
1162
|
+
const entries = /* @__PURE__ */ new Map();
|
|
1163
|
+
let onScreen = [];
|
|
1164
|
+
const groups = () => new Map(layout.groups.map((group) => [group.key, group]));
|
|
1165
|
+
const publish = () => {
|
|
1166
|
+
const sourceId = sourceIdFor(options.cluster, priced);
|
|
1167
|
+
const data = featureCollection(points, layout);
|
|
1168
|
+
if (sources.has(sourceId)) map.getSource(sourceId)?.setData(data);
|
|
1169
|
+
else {
|
|
1170
|
+
addSource(map, sourceId, options.cluster, priced, data);
|
|
1171
|
+
sources.add(sourceId);
|
|
1172
|
+
}
|
|
1173
|
+
if (active && active !== sourceId) map.getSource(active)?.setData(EMPTY);
|
|
1174
|
+
active = sourceId;
|
|
1175
|
+
};
|
|
1176
|
+
const notify = () => {
|
|
1177
|
+
options.onMarkers(onScreen.flatMap((key) => entries.get(key)?.record ?? []));
|
|
1178
|
+
};
|
|
1179
|
+
const destroy = (key) => {
|
|
1180
|
+
const entry = entries.get(key);
|
|
1181
|
+
if (!entry) return;
|
|
1182
|
+
entry.marker.remove();
|
|
1183
|
+
entry.release();
|
|
1184
|
+
entries.delete(key);
|
|
1185
|
+
};
|
|
1186
|
+
const create = (key, wanted) => {
|
|
1187
|
+
const part = wanted.kind === "pin" ? "map-pin" : wanted.kind === "group" ? "map-group" : "map-cluster";
|
|
1188
|
+
const { el: shell, body } = markerElement({
|
|
1189
|
+
tag: "button",
|
|
1190
|
+
part
|
|
1191
|
+
});
|
|
1192
|
+
const at = wanted.kind === "group" ? wanted.group.at : wanted.at;
|
|
1193
|
+
const offset = wanted.kind === "pin" ? wanted.offset : NO_OFFSET;
|
|
1194
|
+
const marker = new lib.Marker({
|
|
1195
|
+
element: shell,
|
|
1196
|
+
offset
|
|
1197
|
+
}).setLngLat(at);
|
|
1198
|
+
if (wanted.kind === "pin") {
|
|
1199
|
+
body.dataset["id"] = wanted.id;
|
|
1200
|
+
const release = bindActivation(shell, () => options.onPin(wanted.id));
|
|
1201
|
+
return {
|
|
1202
|
+
record: {
|
|
1203
|
+
kind: "pin",
|
|
1204
|
+
key,
|
|
1205
|
+
id: wanted.id,
|
|
1206
|
+
body,
|
|
1207
|
+
shell
|
|
1208
|
+
},
|
|
1209
|
+
marker,
|
|
1210
|
+
offset: String(offset),
|
|
1211
|
+
release
|
|
1212
|
+
};
|
|
1213
|
+
}
|
|
1214
|
+
if (wanted.kind === "group") {
|
|
1215
|
+
const { group } = wanted;
|
|
1216
|
+
body.dataset["leafIds"] = group.ids.join("|");
|
|
1217
|
+
const release = bindActivation(shell, () => options.onGroup(group.key));
|
|
1218
|
+
return {
|
|
1219
|
+
record: {
|
|
1220
|
+
kind: "group",
|
|
1221
|
+
key,
|
|
1222
|
+
count: group.ids.length,
|
|
1223
|
+
leafIds: group.ids,
|
|
1224
|
+
body,
|
|
1225
|
+
shell
|
|
1226
|
+
},
|
|
1227
|
+
marker,
|
|
1228
|
+
offset: String(offset),
|
|
1229
|
+
release
|
|
1230
|
+
};
|
|
1231
|
+
}
|
|
1232
|
+
const release = bindActivation(shell, () => zoomInto(map, active, wanted.clusterId, wanted.at));
|
|
1233
|
+
resolveLeaves(map, active, wanted.clusterId, groups()).then((leafIds) => {
|
|
1234
|
+
const entry = entries.get(key);
|
|
1235
|
+
if (!entry || disposed || entry.record.kind === "pin") return;
|
|
1236
|
+
entry.record = {
|
|
1237
|
+
...entry.record,
|
|
1238
|
+
leafIds
|
|
1239
|
+
};
|
|
1240
|
+
body.dataset["leafIds"] = leafIds.join("|");
|
|
1241
|
+
if (onScreen.includes(key)) notify();
|
|
1242
|
+
}, () => {});
|
|
1243
|
+
return {
|
|
1244
|
+
record: {
|
|
1245
|
+
kind: "cluster",
|
|
1246
|
+
key,
|
|
1247
|
+
count: wanted.count,
|
|
1248
|
+
leafIds: [],
|
|
1249
|
+
body,
|
|
1250
|
+
shell
|
|
1251
|
+
},
|
|
1252
|
+
marker,
|
|
1253
|
+
offset: String(offset),
|
|
1254
|
+
release
|
|
488
1255
|
};
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
1256
|
+
};
|
|
1257
|
+
const sync = () => {
|
|
1258
|
+
if (disposed || !active || !map.isSourceLoaded(active)) return;
|
|
1259
|
+
const wanted = wantedMarkers(map.querySourceFeatures(active), layout, groups(), expanded, pinWidth);
|
|
1260
|
+
const shown = new Set(onScreen);
|
|
1261
|
+
for (const [key, want] of wanted) {
|
|
1262
|
+
const entry = entries.get(key) ?? create(key, want);
|
|
1263
|
+
entries.set(key, entry);
|
|
1264
|
+
const offset = want.kind === "pin" ? want.offset : NO_OFFSET;
|
|
1265
|
+
if (entry.offset !== String(offset)) {
|
|
1266
|
+
entry.marker.setOffset(offset);
|
|
1267
|
+
entry.offset = String(offset);
|
|
493
1268
|
}
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
1269
|
+
if (entry.record.kind === "group") entry.record.body.toggleAttribute("data-expanded", key === `g${expanded}`);
|
|
1270
|
+
if (!shown.has(key)) entry.marker.addTo(map);
|
|
1271
|
+
}
|
|
1272
|
+
const next = [...wanted.keys()];
|
|
1273
|
+
for (const key of shown) if (!wanted.has(key)) entries.get(key)?.marker.remove();
|
|
1274
|
+
const changed = next.length !== onScreen.length || next.some((key, at) => key !== onScreen[at]);
|
|
1275
|
+
onScreen = next;
|
|
1276
|
+
if (changed) notify();
|
|
1277
|
+
};
|
|
1278
|
+
publish();
|
|
1279
|
+
const onBackground = () => {
|
|
1280
|
+
if (!disposed) options.onBackground();
|
|
1281
|
+
};
|
|
1282
|
+
map.on("render", sync);
|
|
1283
|
+
map.on("click", onBackground);
|
|
1284
|
+
map.on("zoomstart", onBackground);
|
|
1285
|
+
return {
|
|
1286
|
+
update(nextPoints, nextLayout, nextPriced) {
|
|
1287
|
+
if (disposed) return;
|
|
1288
|
+
points = nextPoints;
|
|
1289
|
+
layout = nextLayout;
|
|
1290
|
+
priced = nextPriced;
|
|
1291
|
+
const kept = /* @__PURE__ */ new Set([...points.map((point) => `p${point.id}`), ...layout.groups.map((group) => `g${group.key}`)]);
|
|
1292
|
+
for (const key of entries.keys()) if (!kept.has(key)) destroy(key);
|
|
1293
|
+
onScreen = onScreen.filter((key) => entries.has(key));
|
|
1294
|
+
publish();
|
|
1295
|
+
sync();
|
|
1296
|
+
},
|
|
1297
|
+
expand(groupKey, pinWidthPx) {
|
|
1298
|
+
if (disposed) return;
|
|
1299
|
+
expanded = groupKey;
|
|
1300
|
+
pinWidth = pinWidthPx;
|
|
1301
|
+
const group = groupKey === null ? void 0 : groups().get(groupKey);
|
|
1302
|
+
if (group) map.easeTo({
|
|
1303
|
+
center: group.at,
|
|
1304
|
+
duration: 300
|
|
497
1305
|
});
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
if (
|
|
502
|
-
|
|
1306
|
+
sync();
|
|
1307
|
+
},
|
|
1308
|
+
dispose() {
|
|
1309
|
+
if (disposed) return;
|
|
1310
|
+
disposed = true;
|
|
1311
|
+
map.off("render", sync);
|
|
1312
|
+
map.off("click", onBackground);
|
|
1313
|
+
map.off("zoomstart", onBackground);
|
|
1314
|
+
for (const key of entries.keys()) destroy(key);
|
|
1315
|
+
for (const sourceId of sources) map.getSource(sourceId)?.setData(EMPTY);
|
|
1316
|
+
onScreen = [];
|
|
1317
|
+
}
|
|
1318
|
+
};
|
|
1319
|
+
};
|
|
1320
|
+
/** One source per cluster setting (a source's clustering is fixed when it is added, and a priced set may
|
|
1321
|
+
* cluster wider than a set of dots). */
|
|
1322
|
+
function sourceIdFor(cluster, priced) {
|
|
1323
|
+
const settings = clusterSettings(cluster, priced);
|
|
1324
|
+
return settings ? `${SOURCE}-${settings.radius}-${settings.maxZoom}` : SOURCE;
|
|
1325
|
+
}
|
|
1326
|
+
function clusterSettings(cluster, priced) {
|
|
1327
|
+
if (cluster === false) return null;
|
|
1328
|
+
const radius = cluster.radius ?? DEFAULT_CLUSTER.radius;
|
|
1329
|
+
const maxZoom = cluster.maxZoom ?? DEFAULT_CLUSTER.maxZoom;
|
|
1330
|
+
if (!priced || !cluster.whenPriced) return {
|
|
1331
|
+
radius,
|
|
1332
|
+
maxZoom
|
|
1333
|
+
};
|
|
1334
|
+
return {
|
|
1335
|
+
radius: cluster.whenPriced.radius ?? radius,
|
|
1336
|
+
maxZoom: cluster.whenPriced.maxZoom ?? maxZoom
|
|
1337
|
+
};
|
|
1338
|
+
}
|
|
1339
|
+
function addSource(map, id, cluster, priced, data) {
|
|
1340
|
+
const settings = clusterSettings(cluster, priced);
|
|
1341
|
+
map.addSource(id, {
|
|
1342
|
+
type: "geojson",
|
|
1343
|
+
data,
|
|
1344
|
+
cluster: settings !== null,
|
|
1345
|
+
...settings ? {
|
|
1346
|
+
clusterRadius: settings.radius,
|
|
1347
|
+
clusterMaxZoom: settings.maxZoom,
|
|
1348
|
+
clusterProperties: { size: ["+", ["get", "size"]] }
|
|
1349
|
+
} : {}
|
|
503
1350
|
});
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
if (w === 0) return;
|
|
513
|
-
setCounter(carouselCounter, Math.round(carousel.scrollLeft / w));
|
|
514
|
-
};
|
|
515
|
-
carousel.addEventListener("scroll", sync, { passive: true });
|
|
516
|
-
sync();
|
|
517
|
-
}
|
|
518
|
-
window.addEventListener("popstate", () => {
|
|
519
|
-
const layer = innermostOpen();
|
|
520
|
-
if (layer) closeLayerInternal(layer);
|
|
1351
|
+
map.addLayer({
|
|
1352
|
+
id: `${id}-points`,
|
|
1353
|
+
type: "circle",
|
|
1354
|
+
source: id,
|
|
1355
|
+
paint: {
|
|
1356
|
+
"circle-radius": 0,
|
|
1357
|
+
"circle-opacity": 0
|
|
1358
|
+
}
|
|
521
1359
|
});
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
1360
|
+
}
|
|
1361
|
+
/** Ungrouped points one feature each; a same-spot group one feature with its size, so a cluster that
|
|
1362
|
+
* swallows it counts its rentals, not its bubble. */
|
|
1363
|
+
function featureCollection(points, layout) {
|
|
1364
|
+
const grouped = new Set(layout.groups.flatMap((group) => group.ids));
|
|
1365
|
+
const feature = (at, properties) => ({
|
|
1366
|
+
type: "Feature",
|
|
1367
|
+
geometry: {
|
|
1368
|
+
type: "Point",
|
|
1369
|
+
coordinates: [at[0], at[1]]
|
|
1370
|
+
},
|
|
1371
|
+
properties
|
|
1372
|
+
});
|
|
1373
|
+
return {
|
|
1374
|
+
type: "FeatureCollection",
|
|
1375
|
+
features: [...points.filter((point) => !grouped.has(point.id)).map((point) => feature(point.at, {
|
|
1376
|
+
id: point.id,
|
|
1377
|
+
size: 1
|
|
1378
|
+
})), ...layout.groups.map((group) => feature(group.at, {
|
|
1379
|
+
group: group.key,
|
|
1380
|
+
size: group.ids.length
|
|
1381
|
+
}))]
|
|
1382
|
+
};
|
|
1383
|
+
}
|
|
1384
|
+
/** The markers the loaded features call for, keyed and deduped (a feature repeats in every tile it
|
|
1385
|
+
* touches). An expanded group keeps its bubble as the anchor and adds its pins on a circle around it. */
|
|
1386
|
+
function wantedMarkers(features, layout, groups, expanded, pinWidth) {
|
|
1387
|
+
const wanted = /* @__PURE__ */ new Map();
|
|
1388
|
+
for (const { properties, geometry } of features) {
|
|
1389
|
+
const at = lngLatOf(geometry.coordinates);
|
|
1390
|
+
if (!at) continue;
|
|
1391
|
+
if (properties["cluster"]) {
|
|
1392
|
+
const clusterId = Number(properties["cluster_id"]);
|
|
1393
|
+
const count = Number(properties["size"] ?? properties["point_count"]);
|
|
1394
|
+
wanted.set(`c${clusterId}`, {
|
|
1395
|
+
kind: "cluster",
|
|
1396
|
+
clusterId,
|
|
1397
|
+
at,
|
|
1398
|
+
count
|
|
1399
|
+
});
|
|
1400
|
+
continue;
|
|
529
1401
|
}
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
1402
|
+
const group = typeof properties["group"] === "string" ? groups.get(properties["group"]) : void 0;
|
|
1403
|
+
if (group) {
|
|
1404
|
+
wanted.set(`g${group.key}`, {
|
|
1405
|
+
kind: "group",
|
|
1406
|
+
group
|
|
1407
|
+
});
|
|
1408
|
+
if (group.key !== expanded) continue;
|
|
1409
|
+
const offsets = circleOffsets(group.ids.length, pinWidth);
|
|
1410
|
+
group.ids.forEach((id, at) => {
|
|
1411
|
+
wanted.set(`p${id}`, {
|
|
1412
|
+
kind: "pin",
|
|
1413
|
+
id,
|
|
1414
|
+
at: group.at,
|
|
1415
|
+
offset: offsets[at] ?? NO_OFFSET
|
|
1416
|
+
});
|
|
1417
|
+
});
|
|
1418
|
+
continue;
|
|
533
1419
|
}
|
|
1420
|
+
if (typeof properties["id"] !== "string") continue;
|
|
1421
|
+
const id = properties["id"];
|
|
1422
|
+
wanted.set(`p${id}`, {
|
|
1423
|
+
kind: "pin",
|
|
1424
|
+
id,
|
|
1425
|
+
at,
|
|
1426
|
+
offset: layout.offsets[id] ?? NO_OFFSET
|
|
1427
|
+
});
|
|
1428
|
+
}
|
|
1429
|
+
return wanted;
|
|
1430
|
+
}
|
|
1431
|
+
function lngLatOf(coordinates) {
|
|
1432
|
+
if (!Array.isArray(coordinates)) return null;
|
|
1433
|
+
const [lng, lat] = coordinates;
|
|
1434
|
+
return typeof lng === "number" && typeof lat === "number" ? [lng, lat] : null;
|
|
1435
|
+
}
|
|
1436
|
+
/** The rental ids inside a cluster (its groups' members included). */
|
|
1437
|
+
async function resolveLeaves(map, sourceId, clusterId, groups) {
|
|
1438
|
+
const source = sourceId ? map.getSource(sourceId) : void 0;
|
|
1439
|
+
if (!source) return [];
|
|
1440
|
+
return (await source.getClusterLeaves(clusterId, Number.POSITIVE_INFINITY, 0)).flatMap(({ properties }) => {
|
|
1441
|
+
if (typeof properties["group"] === "string") return [...groups.get(properties["group"])?.ids ?? []];
|
|
1442
|
+
return typeof properties["id"] === "string" ? [properties["id"]] : [];
|
|
534
1443
|
});
|
|
535
1444
|
}
|
|
1445
|
+
/** A cluster opens by easing to the zoom at which it breaks apart. */
|
|
1446
|
+
function zoomInto(map, sourceId, clusterId, at) {
|
|
1447
|
+
(sourceId ? map.getSource(sourceId) : void 0)?.getClusterExpansionZoom(clusterId).then((zoom) => map.easeTo({
|
|
1448
|
+
center: at,
|
|
1449
|
+
zoom
|
|
1450
|
+
}), () => {});
|
|
1451
|
+
}
|
|
536
1452
|
//#endregion
|
|
537
|
-
export {
|
|
1453
|
+
export { CLICK_TOLERANCE_PX, ENHANCE_PARTS, GATE_MARKERS, bindClamp, bindFavorites, bindGallery, bindImageFade, bindLayer, bindPlaceMarkers, bindResultMarkers, bindReveal, bindShare, bindStrip, createMapSurface, enhance, focusForKeyboard, markerElement, observeResultCardHover, trackInputModality, withViewTransition };
|