@v-office/website-headless 0.2.0 → 1.1.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 +2178 -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 +2620 -1181
- package/dist/index.mjs +8577 -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 +2 -0
- package/instructions/MIGRATION.md +2 -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/instructions/versions/1.1.0/CHANGELOG.md +19 -0
- package/instructions/versions/1.1.0/MIGRATION.md +14 -0
- package/instructions/versions/1.1.0/release.json +3 -0
- package/package.json +7 -4
|
@@ -0,0 +1,648 @@
|
|
|
1
|
+
//#region src/store/store.ts
|
|
2
|
+
/** See {@link CreateStore}. */
|
|
3
|
+
const createStore = function createStore(initial) {
|
|
4
|
+
let state = initial;
|
|
5
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
6
|
+
return {
|
|
7
|
+
getState: () => state,
|
|
8
|
+
setState(next) {
|
|
9
|
+
const value = typeof next === "function" ? next(state) : next;
|
|
10
|
+
if (value === state) return;
|
|
11
|
+
state = value;
|
|
12
|
+
for (const listener of Array.from(listeners)) listener(state);
|
|
13
|
+
},
|
|
14
|
+
subscribe(listener) {
|
|
15
|
+
listeners.add(listener);
|
|
16
|
+
return () => {
|
|
17
|
+
listeners.delete(listener);
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
//#endregion
|
|
23
|
+
//#region src/shared/registry.ts
|
|
24
|
+
function space(namespace) {
|
|
25
|
+
const holder = globalThis;
|
|
26
|
+
const symbol = Symbol.for(`@v-office/website-headless/${namespace}`);
|
|
27
|
+
holder[symbol] ??= {
|
|
28
|
+
entries: /* @__PURE__ */ new Map(),
|
|
29
|
+
watchers: /* @__PURE__ */ new Map()
|
|
30
|
+
};
|
|
31
|
+
return holder[symbol];
|
|
32
|
+
}
|
|
33
|
+
function notify(target, key, value) {
|
|
34
|
+
for (const watcher of Array.from(target.watchers.get(key) ?? [])) watcher(value);
|
|
35
|
+
}
|
|
36
|
+
/** See {@link AcquireShared}. */
|
|
37
|
+
const acquireShared = function acquireShared(namespace, key, create, dispose) {
|
|
38
|
+
const target = space(namespace);
|
|
39
|
+
let entry = target.entries.get(key);
|
|
40
|
+
if (entry) {
|
|
41
|
+
clearTimeout(entry.pending);
|
|
42
|
+
entry.pending = void 0;
|
|
43
|
+
entry.claims += 1;
|
|
44
|
+
} else {
|
|
45
|
+
const value = create();
|
|
46
|
+
entry = {
|
|
47
|
+
value,
|
|
48
|
+
destroy: () => dispose(value),
|
|
49
|
+
claims: 1,
|
|
50
|
+
pending: void 0
|
|
51
|
+
};
|
|
52
|
+
target.entries.set(key, entry);
|
|
53
|
+
notify(target, key, entry.value);
|
|
54
|
+
}
|
|
55
|
+
const claimed = entry;
|
|
56
|
+
let released = false;
|
|
57
|
+
return {
|
|
58
|
+
value: claimed.value,
|
|
59
|
+
release() {
|
|
60
|
+
if (released) return;
|
|
61
|
+
released = true;
|
|
62
|
+
claimed.claims -= 1;
|
|
63
|
+
if (claimed.claims > 0) return;
|
|
64
|
+
claimed.pending = setTimeout(() => {
|
|
65
|
+
if (claimed.claims > 0 || target.entries.get(key) !== claimed) return;
|
|
66
|
+
target.entries.delete(key);
|
|
67
|
+
notify(target, key, null);
|
|
68
|
+
claimed.destroy();
|
|
69
|
+
}, 0);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
/** See {@link ConnectShared}. */
|
|
74
|
+
const connectShared = function connectShared(namespace, key, onValue) {
|
|
75
|
+
const target = space(namespace);
|
|
76
|
+
const watcher = onValue;
|
|
77
|
+
const watchers = target.watchers.get(key) ?? /* @__PURE__ */ new Set();
|
|
78
|
+
target.watchers.set(key, watchers);
|
|
79
|
+
watchers.add(watcher);
|
|
80
|
+
onValue(target.entries.get(key)?.value ?? null);
|
|
81
|
+
return () => {
|
|
82
|
+
watchers.delete(watcher);
|
|
83
|
+
if (watchers.size === 0) target.watchers.delete(key);
|
|
84
|
+
};
|
|
85
|
+
};
|
|
86
|
+
//#endregion
|
|
87
|
+
//#region src/shared/text.ts
|
|
88
|
+
/** See {@link DefineCopy}. */
|
|
89
|
+
const defineCopy = () => (copy) => copy;
|
|
90
|
+
/** See {@link FillTemplate}. */
|
|
91
|
+
const fillTemplate = function fillTemplate(template, values) {
|
|
92
|
+
const byName = values;
|
|
93
|
+
return template.replace(/\{(\w+)\}/g, (token, name) => Object.hasOwn(byName, name) ? String(byName[name]) : token);
|
|
94
|
+
};
|
|
95
|
+
//#endregion
|
|
96
|
+
//#region src/shared/price-label.ts
|
|
97
|
+
/** A run of digits with grouping or decimal separators between them (apostrophes and the no-break
|
|
98
|
+
* spaces Intl uses for grouping included). */
|
|
99
|
+
const NUMBER_RUN = /\d(?:[\d.,'’ ]*\d)?/g;
|
|
100
|
+
const SEPARATOR = /[.,'’ ]/;
|
|
101
|
+
/** The one amount in `label`, or null for any shape this module does not read. */
|
|
102
|
+
function readAmount(label) {
|
|
103
|
+
const runs = [...label.matchAll(NUMBER_RUN)];
|
|
104
|
+
const run = runs.length === 1 ? runs[0] : void 0;
|
|
105
|
+
if (!run) return null;
|
|
106
|
+
const text = run[0];
|
|
107
|
+
const start = run.index;
|
|
108
|
+
const end = start + text.length;
|
|
109
|
+
const last = [...text].findLastIndex((char) => SEPARATOR.test(char));
|
|
110
|
+
const tail = text.slice(last + 1);
|
|
111
|
+
if (last === -1 || tail.length === 3) return {
|
|
112
|
+
start,
|
|
113
|
+
end,
|
|
114
|
+
units: text
|
|
115
|
+
};
|
|
116
|
+
const separator = text.charAt(last);
|
|
117
|
+
if (tail.length !== 2 || separator !== "." && separator !== ",") return null;
|
|
118
|
+
return {
|
|
119
|
+
start,
|
|
120
|
+
end,
|
|
121
|
+
units: text.slice(0, last),
|
|
122
|
+
decimal: {
|
|
123
|
+
separator,
|
|
124
|
+
cents: tail
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
const digitsOf = (units) => units.replace(/\D/g, "");
|
|
129
|
+
/** `units` plus one, keeping its grouping ("1.999" → "2.000"). An ungrouped amount that gains a fourth
|
|
130
|
+
* digit ("999,50" → "1.000") is grouped with the separator the decimal implies, because Intl groups
|
|
131
|
+
* four-digit amounts in both fleet locales; an amount already written ungrouped stays ungrouped. */
|
|
132
|
+
function incrementUnits(units, separator) {
|
|
133
|
+
const digits = digitsOf(units);
|
|
134
|
+
const next = String(Number(digits) + 1);
|
|
135
|
+
const inferred = digits.length <= 3 ? separator === "," ? "." : "," : "";
|
|
136
|
+
const group = units.match(/\D/)?.[0] ?? inferred;
|
|
137
|
+
return group ? next.replace(/\B(?=(\d{3})+$)/g, group) : next;
|
|
138
|
+
}
|
|
139
|
+
/** See {@link FormatPriceLabel}. */
|
|
140
|
+
const formatPriceLabel = function formatPriceLabel(label, cents) {
|
|
141
|
+
const amount = readAmount(label);
|
|
142
|
+
const decimal = amount?.decimal;
|
|
143
|
+
if (!amount || !decimal || cents === "show") return label;
|
|
144
|
+
if (cents === "hide-zero" && decimal.cents !== "00") return label;
|
|
145
|
+
const units = cents === "round" && Number(decimal.cents) >= 50 ? incrementUnits(amount.units, decimal.separator) : amount.units;
|
|
146
|
+
return label.slice(0, amount.start) + units + label.slice(amount.end);
|
|
147
|
+
};
|
|
148
|
+
/** See {@link ParsePriceLabel}. */
|
|
149
|
+
const parsePriceLabel = function parsePriceLabel(label) {
|
|
150
|
+
const amount = label ? readAmount(label) : null;
|
|
151
|
+
if (!amount) return null;
|
|
152
|
+
return Number(`${digitsOf(amount.units)}.${amount.decimal?.cents ?? "00"}`);
|
|
153
|
+
};
|
|
154
|
+
//#endregion
|
|
155
|
+
//#region src/shared/tracking.ts
|
|
156
|
+
/** utm_*, mtm_*, pk_*, gad_*, gclid, gbraid, wbraid, dclid, fbclid, msclkid, mc_eid, ref, _gl. Not
|
|
157
|
+
* global (no `lastIndex` state); its `source` is safe to inline into a `<head>` script. */
|
|
158
|
+
const TRACKING_PARAM_PATTERN = /^(?:utm_|mtm_|pk_|gad_|(?:gclid|gbraid|wbraid|dclid|fbclid|msclkid|mc_eid|ref|_gl)$)/;
|
|
159
|
+
/** See {@link IsTrackingParam}. */
|
|
160
|
+
const isTrackingParam = function isTrackingParam(key) {
|
|
161
|
+
return TRACKING_PARAM_PATTERN.test(key);
|
|
162
|
+
};
|
|
163
|
+
/** Delete every key `drop` names. The keys are collected BEFORE deleting: deleting while iterating
|
|
164
|
+
* `searchParams.keys()` shifts the list under the iterator, so the key after a deleted one was skipped
|
|
165
|
+
* and a tracking parameter survived — the one thing this exists to prevent. */
|
|
166
|
+
function dropKeys(params, drop) {
|
|
167
|
+
for (const key of new Set(params.keys())) if (drop(key)) params.delete(key);
|
|
168
|
+
}
|
|
169
|
+
/** See {@link WithoutTracking}. */
|
|
170
|
+
const withoutTracking = function withoutTracking(search) {
|
|
171
|
+
const params = new URLSearchParams(search);
|
|
172
|
+
dropKeys(params, isTrackingParam);
|
|
173
|
+
return params.toString();
|
|
174
|
+
};
|
|
175
|
+
/** See {@link ShareUrl}. */
|
|
176
|
+
const shareUrl = function shareUrl(location, options = {}) {
|
|
177
|
+
const extra = new Set(options.strip ?? []);
|
|
178
|
+
const url = new URL(location.pathname + location.search, options.origin || location.origin);
|
|
179
|
+
dropKeys(url.searchParams, (key) => isTrackingParam(key) || extra.has(key));
|
|
180
|
+
url.hash = "";
|
|
181
|
+
return url.href;
|
|
182
|
+
};
|
|
183
|
+
//#endregion
|
|
184
|
+
//#region src/shared/storage.ts
|
|
185
|
+
const PROBE_KEY = "@v-office/website-headless:probe";
|
|
186
|
+
/** See {@link BrowserStorage}. */
|
|
187
|
+
const browserStorage = function browserStorage(kind) {
|
|
188
|
+
try {
|
|
189
|
+
const storage = kind === "local" ? globalThis.localStorage : globalThis.sessionStorage;
|
|
190
|
+
if (!storage) return null;
|
|
191
|
+
storage.setItem(PROBE_KEY, "1");
|
|
192
|
+
storage.removeItem(PROBE_KEY);
|
|
193
|
+
return storage;
|
|
194
|
+
} catch {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
//#endregion
|
|
199
|
+
//#region src/favorites/favorites.ts
|
|
200
|
+
/** The window event every site script listens to (`detail: FavoritesChange`); kept verbatim. The
|
|
201
|
+
* `./enhance` binder dispatches it from `onChange`. */
|
|
202
|
+
const FAVORITES_EVENT = "merkliste:changed";
|
|
203
|
+
/** See {@link FavoritesStorageKey}. */
|
|
204
|
+
const favoritesStorageKey = function favoritesStorageKey(namespace) {
|
|
205
|
+
if (namespace.trim() === "") throw new TypeError("favoritesStorageKey needs the site's storage namespace (non-empty).");
|
|
206
|
+
return `${namespace}-merkliste`;
|
|
207
|
+
};
|
|
208
|
+
/** The stored ids; corrupt JSON, a non-array or a refused read is an empty list. */
|
|
209
|
+
function readIds(storage, key) {
|
|
210
|
+
try {
|
|
211
|
+
const parsed = JSON.parse(storage?.getItem(key) ?? "[]");
|
|
212
|
+
return new Set(Array.isArray(parsed) ? parsed.filter((id) => typeof id === "string") : []);
|
|
213
|
+
} catch {
|
|
214
|
+
return /* @__PURE__ */ new Set();
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
function writeIds(storage, key, ids) {
|
|
218
|
+
try {
|
|
219
|
+
storage?.setItem(key, JSON.stringify([...ids]));
|
|
220
|
+
} catch {}
|
|
221
|
+
}
|
|
222
|
+
/** Every id whose saved state differs between `before` and `after`. */
|
|
223
|
+
function changesBetween(before, after) {
|
|
224
|
+
return [...[...after].filter((id) => !before.has(id)).map((id) => ({
|
|
225
|
+
id,
|
|
226
|
+
saved: true
|
|
227
|
+
})), ...[...before].filter((id) => !after.has(id)).map((id) => ({
|
|
228
|
+
id,
|
|
229
|
+
saved: false
|
|
230
|
+
}))];
|
|
231
|
+
}
|
|
232
|
+
/** See {@link CreateFavorites}. */
|
|
233
|
+
const createFavorites = function createFavorites(options) {
|
|
234
|
+
const { storageKey } = options;
|
|
235
|
+
const storage = options.storage === void 0 ? browserStorage("local") : options.storage;
|
|
236
|
+
const store = createStore({ ids: readIds(storage, storageKey) });
|
|
237
|
+
let disposed = false;
|
|
238
|
+
/** Move to `next`, persist unless it came from storage, and report each changed id. */
|
|
239
|
+
const commit = (next, persist) => {
|
|
240
|
+
if (disposed) return;
|
|
241
|
+
const changes = changesBetween(store.getState().ids, next);
|
|
242
|
+
if (changes.length === 0) return;
|
|
243
|
+
if (persist) writeIds(storage, storageKey, next);
|
|
244
|
+
store.setState({ ids: next });
|
|
245
|
+
for (const change of changes) options.onChange?.(change);
|
|
246
|
+
};
|
|
247
|
+
const ids = () => store.getState().ids;
|
|
248
|
+
const set = (id, saved) => {
|
|
249
|
+
const next = new Set(ids());
|
|
250
|
+
if (saved) next.add(id);
|
|
251
|
+
else next.delete(id);
|
|
252
|
+
commit(next, true);
|
|
253
|
+
};
|
|
254
|
+
return {
|
|
255
|
+
getState: store.getState,
|
|
256
|
+
subscribe: store.subscribe,
|
|
257
|
+
ids,
|
|
258
|
+
has: (id) => ids().has(id),
|
|
259
|
+
toggle(id) {
|
|
260
|
+
const saved = !ids().has(id);
|
|
261
|
+
set(id, saved);
|
|
262
|
+
return saved;
|
|
263
|
+
},
|
|
264
|
+
set,
|
|
265
|
+
prune(known) {
|
|
266
|
+
commit(new Set([...ids()].filter((id) => known.has(id))), true);
|
|
267
|
+
},
|
|
268
|
+
reload() {
|
|
269
|
+
commit(readIds(storage, storageKey), false);
|
|
270
|
+
},
|
|
271
|
+
dispose() {
|
|
272
|
+
disposed = true;
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
};
|
|
276
|
+
/** See {@link AcquireFavorites}. */
|
|
277
|
+
const acquireFavorites = function acquireFavorites(options) {
|
|
278
|
+
const claim = acquireShared("favorites", options.storageKey, () => {
|
|
279
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
280
|
+
return {
|
|
281
|
+
favorites: createFavorites({
|
|
282
|
+
storageKey: options.storageKey,
|
|
283
|
+
...options.storage === void 0 ? {} : { storage: options.storage },
|
|
284
|
+
onChange: (change) => {
|
|
285
|
+
for (const listener of Array.from(listeners)) listener(change);
|
|
286
|
+
}
|
|
287
|
+
}),
|
|
288
|
+
listeners
|
|
289
|
+
};
|
|
290
|
+
}, (shared) => shared.favorites.dispose());
|
|
291
|
+
const { favorites, listeners } = claim.value;
|
|
292
|
+
const listener = options.onChange ? (change) => options.onChange?.(change) : null;
|
|
293
|
+
if (listener) listeners.add(listener);
|
|
294
|
+
return {
|
|
295
|
+
favorites,
|
|
296
|
+
release() {
|
|
297
|
+
if (listener) listeners.delete(listener);
|
|
298
|
+
claim.release();
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
};
|
|
302
|
+
//#endregion
|
|
303
|
+
//#region src/map/constants.ts
|
|
304
|
+
/** OpenFreeMap "Bright": free for commercial use, no key, no rate limit, OSM data. A third-party
|
|
305
|
+
* request (the visitor's IP reaches the tile host), which is why the map components load tiles
|
|
306
|
+
* on click by default. A brand style is a JSON file the site hosts. */
|
|
307
|
+
const DEFAULT_MAP_STYLE_URL = "https://tiles.openfreemap.org/styles/bright";
|
|
308
|
+
/** Pins closer than this are "the same spot": below it their pills overlap at every usable zoom,
|
|
309
|
+
* and the feeds' distinct pins sit at least 18 m apart, so 12 m splits cleanly (measured). */
|
|
310
|
+
const SAME_SPOT_METERS = 12;
|
|
311
|
+
/** The list's re-settle pulse after a guest map move that changed the in-view set. A perceived-
|
|
312
|
+
* responsiveness transition (the re-filter itself is instant), NOT simulated latency. */
|
|
313
|
+
const RELOAD_FLASH_MS = 220;
|
|
314
|
+
//#endregion
|
|
315
|
+
//#region src/map/points.ts
|
|
316
|
+
/** See {@link MapPoints}. */
|
|
317
|
+
const mapPoints = (items, options = {}) => {
|
|
318
|
+
const cents = options.cents ?? "hide-zero";
|
|
319
|
+
return items.flatMap((item) => {
|
|
320
|
+
if (!item.coords) return [];
|
|
321
|
+
const total = item.price?.total ?? null;
|
|
322
|
+
return [{
|
|
323
|
+
id: item.id,
|
|
324
|
+
at: [item.coords.lng, item.coords.lat],
|
|
325
|
+
label: total !== null && options.price !== "hidden" ? formatPriceLabel(total, cents) : null,
|
|
326
|
+
sortValue: parsePriceLabel(total)
|
|
327
|
+
}];
|
|
328
|
+
});
|
|
329
|
+
};
|
|
330
|
+
/** See {@link HasMapPoints}. */
|
|
331
|
+
const hasMapPoints = (items) => {
|
|
332
|
+
return items.some((item) => item.coords !== null && item.coords !== void 0);
|
|
333
|
+
};
|
|
334
|
+
//#endregion
|
|
335
|
+
//#region src/map/same-spot.ts
|
|
336
|
+
const CASCADE_STEP = {
|
|
337
|
+
x: 13,
|
|
338
|
+
y: 9
|
|
339
|
+
};
|
|
340
|
+
/** Steps from the centre before a cascade stops spreading (only a 7+ stack would reach it, and
|
|
341
|
+
* those sit on a circle). */
|
|
342
|
+
const CASCADE_CAP = 3;
|
|
343
|
+
const CIRCLE_FROM = 6;
|
|
344
|
+
const GROUP_FROM = 3;
|
|
345
|
+
const MIN_RADIUS_PX = 38;
|
|
346
|
+
const PIN_GAP_PX = 12;
|
|
347
|
+
const EARTH_RADIUS_M = 6371e3;
|
|
348
|
+
/** See {@link DistanceMeters}. */
|
|
349
|
+
const distanceMeters = (a, b) => {
|
|
350
|
+
const rad = (degrees) => degrees * Math.PI / 180;
|
|
351
|
+
const dLat = rad(b[1] - a[1]);
|
|
352
|
+
const dLng = rad(b[0] - a[0]);
|
|
353
|
+
const h = Math.sin(dLat / 2) ** 2 + Math.cos(rad(a[1])) * Math.cos(rad(b[1])) * Math.sin(dLng / 2) ** 2;
|
|
354
|
+
return 2 * EARTH_RADIUS_M * Math.asin(Math.sqrt(h));
|
|
355
|
+
};
|
|
356
|
+
/** Round half away from zero, so a cascade is symmetric around the true position. */
|
|
357
|
+
function roundSymmetric(value) {
|
|
358
|
+
return Math.sign(value) * Math.round(Math.abs(value)) || 0;
|
|
359
|
+
}
|
|
360
|
+
/** See {@link CircleOffsets}. */
|
|
361
|
+
const circleOffsets = (count, pinWidthPx) => {
|
|
362
|
+
const radius = Math.max(MIN_RADIUS_PX, (pinWidthPx + PIN_GAP_PX) * count / (2 * Math.PI));
|
|
363
|
+
return Array.from({ length: count }, (_, k) => {
|
|
364
|
+
const angle = -Math.PI / 2 + 2 * Math.PI * k / count;
|
|
365
|
+
return [roundSymmetric(Math.cos(angle) * radius), roundSymmetric(Math.sin(angle) * radius)];
|
|
366
|
+
});
|
|
367
|
+
};
|
|
368
|
+
function cascadeOffsets(count) {
|
|
369
|
+
const mid = (count - 1) / 2;
|
|
370
|
+
return Array.from({ length: count }, (_, k) => {
|
|
371
|
+
const step = Math.max(-3, Math.min(CASCADE_CAP, k - mid));
|
|
372
|
+
return [roundSymmetric(step * CASCADE_STEP.x), roundSymmetric(step * CASCADE_STEP.y)];
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
/** Spots with more than one pin, each measured against its first pin (result order). */
|
|
376
|
+
function spots(points) {
|
|
377
|
+
const used = /* @__PURE__ */ new Set();
|
|
378
|
+
const out = [];
|
|
379
|
+
points.forEach((anchor, i) => {
|
|
380
|
+
if (used.has(i)) return;
|
|
381
|
+
const spot = [anchor];
|
|
382
|
+
for (let j = i + 1; j < points.length; j += 1) {
|
|
383
|
+
const other = points[j];
|
|
384
|
+
if (!other || used.has(j) || distanceMeters(anchor.at, other.at) > 12) continue;
|
|
385
|
+
used.add(j);
|
|
386
|
+
spot.push(other);
|
|
387
|
+
}
|
|
388
|
+
if (spot.length > 1) out.push(spot);
|
|
389
|
+
});
|
|
390
|
+
return out;
|
|
391
|
+
}
|
|
392
|
+
/** Cheapest first (unpriced last), then result order (sort is stable). */
|
|
393
|
+
function cheapestFirst(spot) {
|
|
394
|
+
return [...spot].sort((a, b) => (a.sortValue ?? Number.POSITIVE_INFINITY) - (b.sortValue ?? Number.POSITIVE_INFINITY));
|
|
395
|
+
}
|
|
396
|
+
function place(offsets, spot, layout) {
|
|
397
|
+
spot.forEach((point, k) => {
|
|
398
|
+
const offset = layout[k];
|
|
399
|
+
if (offset) offsets[point.id] = offset;
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
/** See {@link LayoutSameSpot}. */
|
|
403
|
+
const layoutSameSpot = (points, options) => {
|
|
404
|
+
const offsets = {};
|
|
405
|
+
const groups = [];
|
|
406
|
+
for (const spot of spots(points)) if (options.mode === "group" && spot.length >= GROUP_FROM) {
|
|
407
|
+
const ids = cheapestFirst(spot).map((point) => point.id);
|
|
408
|
+
groups.push({
|
|
409
|
+
key: `g:${[...ids].sort().join(",")}`,
|
|
410
|
+
at: spot[0]?.at ?? [0, 0],
|
|
411
|
+
ids
|
|
412
|
+
});
|
|
413
|
+
} else if (options.mode === "fan" && spot.length >= CIRCLE_FROM) {
|
|
414
|
+
const ordered = cheapestFirst(spot);
|
|
415
|
+
place(offsets, ordered, circleOffsets(ordered.length, options.pinWidthPx));
|
|
416
|
+
} else place(offsets, spot, cascadeOffsets(spot.length));
|
|
417
|
+
return {
|
|
418
|
+
offsets,
|
|
419
|
+
groups
|
|
420
|
+
};
|
|
421
|
+
};
|
|
422
|
+
//#endregion
|
|
423
|
+
//#region src/map/frame.ts
|
|
424
|
+
const DEFAULT_FIT = {
|
|
425
|
+
padding: 56,
|
|
426
|
+
maxZoom: 12,
|
|
427
|
+
excludeOutliers: true
|
|
428
|
+
};
|
|
429
|
+
/** Generous on purpose: a second real region (two islands) stays well below it; the case it
|
|
430
|
+
* exists for sits at about a thousand times the median distance. */
|
|
431
|
+
const OUTLIER_FACTOR = 10;
|
|
432
|
+
/** Below this the median distance describes one point, not a set: a two-hit search shows both. */
|
|
433
|
+
const OUTLIER_MIN_POINTS = 4;
|
|
434
|
+
function median(values) {
|
|
435
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
436
|
+
const mid = Math.floor(sorted.length / 2);
|
|
437
|
+
return sorted.length % 2 ? sorted[mid] ?? 0 : ((sorted[mid - 1] ?? 0) + (sorted[mid] ?? 0)) / 2;
|
|
438
|
+
}
|
|
439
|
+
/** The points that aim the camera. Degrees, not metres: only distances are compared with each
|
|
440
|
+
* other, so the projection does not matter. */
|
|
441
|
+
function framed(points, excludeOutliers) {
|
|
442
|
+
if (!excludeOutliers || points.length < OUTLIER_MIN_POINTS) return points;
|
|
443
|
+
const centre = [median(points.map((point) => point.at[0])), median(points.map((point) => point.at[1]))];
|
|
444
|
+
const distances = points.map((point) => Math.hypot(point.at[0] - centre[0], point.at[1] - centre[1]));
|
|
445
|
+
const limit = median(distances) * OUTLIER_FACTOR;
|
|
446
|
+
const kept = points.filter((_, i) => (distances[i] ?? 0) <= limit);
|
|
447
|
+
return kept.length > 0 ? kept : points;
|
|
448
|
+
}
|
|
449
|
+
/** See {@link FrameFor}. */
|
|
450
|
+
const frameFor = (points, options = {}) => {
|
|
451
|
+
const aim = framed(points, options.excludeOutliers ?? DEFAULT_FIT.excludeOutliers);
|
|
452
|
+
if (aim.length === 0) return null;
|
|
453
|
+
const lngs = aim.map((point) => point.at[0]);
|
|
454
|
+
const lats = aim.map((point) => point.at[1]);
|
|
455
|
+
return [[Math.min(...lngs), Math.min(...lats)], [Math.max(...lngs), Math.max(...lats)]];
|
|
456
|
+
};
|
|
457
|
+
/** See {@link IdsInView}. */
|
|
458
|
+
const idsInView = (points, bounds) => {
|
|
459
|
+
const [[west, south], [east, north]] = bounds;
|
|
460
|
+
return points.filter(({ at: [lng, lat] }) => lng >= west && lng <= east && lat >= south && lat <= north).map((point) => point.id);
|
|
461
|
+
};
|
|
462
|
+
//#endregion
|
|
463
|
+
//#region src/map/controller.ts
|
|
464
|
+
/** The kit's cluster settings (count-only bubbles). */
|
|
465
|
+
const DEFAULT_CLUSTER = {
|
|
466
|
+
radius: 54,
|
|
467
|
+
maxZoom: 11
|
|
468
|
+
};
|
|
469
|
+
/** A dot's width, until a pill was measured. */
|
|
470
|
+
const DOT_WIDTH_PX = 20;
|
|
471
|
+
function pointsKey(points) {
|
|
472
|
+
return points.map((point) => `${point.id}@${point.at.join(",")}=${point.label ?? ""}`).join("|");
|
|
473
|
+
}
|
|
474
|
+
/** See {@link CreateResultsMapController}. */
|
|
475
|
+
const createResultsMapController = (source, options = {}) => {
|
|
476
|
+
const listFollowsMap = options.listFollowsMap ?? true;
|
|
477
|
+
const mode = options.sameSpot ?? "fan";
|
|
478
|
+
const reports = {
|
|
479
|
+
inline: null,
|
|
480
|
+
fullscreen: null
|
|
481
|
+
};
|
|
482
|
+
let pinWidth = DOT_WIDTH_PX;
|
|
483
|
+
let currentKey = "";
|
|
484
|
+
/** The in-view set last handed to the list (null = no viewport filter). */
|
|
485
|
+
let applied = null;
|
|
486
|
+
let pulse = null;
|
|
487
|
+
let released = false;
|
|
488
|
+
const statusOf = (points) => source.getState().status === "loading" ? "waiting" : points.length > 0 ? "ready" : "empty";
|
|
489
|
+
const initialPoints = mapPoints(source.getState().matched, options.pins);
|
|
490
|
+
currentKey = pointsKey(initialPoints);
|
|
491
|
+
const store = createStore({
|
|
492
|
+
status: statusOf(initialPoints),
|
|
493
|
+
points: initialPoints,
|
|
494
|
+
priced: initialPoints.some((point) => point.label !== null),
|
|
495
|
+
layout: layoutSameSpot(initialPoints, {
|
|
496
|
+
mode,
|
|
497
|
+
pinWidthPx: pinWidth
|
|
498
|
+
}),
|
|
499
|
+
frame: frameFor(initialPoints, options.fit),
|
|
500
|
+
frameVersion: 0,
|
|
501
|
+
openId: null,
|
|
502
|
+
expandedGroup: null,
|
|
503
|
+
guestMoved: false
|
|
504
|
+
});
|
|
505
|
+
const update = (patch) => store.setState({
|
|
506
|
+
...store.getState(),
|
|
507
|
+
...patch
|
|
508
|
+
});
|
|
509
|
+
const driving = () => reports.fullscreen ? "fullscreen" : "inline";
|
|
510
|
+
/** Rule 2: the set the list should follow now, or null for no filter. */
|
|
511
|
+
function wanted() {
|
|
512
|
+
const bounds = reports[driving()];
|
|
513
|
+
if (!listFollowsMap || !store.getState().guestMoved || !bounds) return null;
|
|
514
|
+
return idsInView(store.getState().points, bounds);
|
|
515
|
+
}
|
|
516
|
+
function stopPulse() {
|
|
517
|
+
if (!pulse) return;
|
|
518
|
+
clearTimeout(pulse);
|
|
519
|
+
pulse = null;
|
|
520
|
+
source.setReloading(false);
|
|
521
|
+
}
|
|
522
|
+
function handOver(ids) {
|
|
523
|
+
applied = ids ? ids.join("|") : null;
|
|
524
|
+
source.setViewport(ids ? new Set(ids) : null);
|
|
525
|
+
}
|
|
526
|
+
/** Rule 3 with `withPulse`, rule 4 (and every non-guest change) without. */
|
|
527
|
+
function follow(withPulse) {
|
|
528
|
+
const ids = wanted();
|
|
529
|
+
if ((ids ? ids.join("|") : null) === applied) return stopPulse();
|
|
530
|
+
if (!withPulse || ids === null) {
|
|
531
|
+
stopPulse();
|
|
532
|
+
return handOver(ids);
|
|
533
|
+
}
|
|
534
|
+
if (pulse) clearTimeout(pulse);
|
|
535
|
+
else source.setReloading(true);
|
|
536
|
+
pulse = setTimeout(() => {
|
|
537
|
+
pulse = null;
|
|
538
|
+
handOver(ids);
|
|
539
|
+
source.setReloading(false);
|
|
540
|
+
}, 220);
|
|
541
|
+
}
|
|
542
|
+
function onSource() {
|
|
543
|
+
const points = mapPoints(source.getState().matched, options.pins);
|
|
544
|
+
const key = pointsKey(points);
|
|
545
|
+
const previous = store.getState();
|
|
546
|
+
if (key === currentKey) {
|
|
547
|
+
const status = statusOf(points);
|
|
548
|
+
if (status !== previous.status) update({ status });
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
currentKey = key;
|
|
552
|
+
const layout = layoutSameSpot(points, {
|
|
553
|
+
mode,
|
|
554
|
+
pinWidthPx: pinWidth
|
|
555
|
+
});
|
|
556
|
+
update({
|
|
557
|
+
status: statusOf(points),
|
|
558
|
+
points,
|
|
559
|
+
priced: points.some((point) => point.label !== null),
|
|
560
|
+
layout,
|
|
561
|
+
frame: frameFor(points, options.fit),
|
|
562
|
+
frameVersion: previous.guestMoved ? previous.frameVersion : previous.frameVersion + 1,
|
|
563
|
+
openId: previous.openId !== null && points.some((point) => point.id === previous.openId) ? previous.openId : null,
|
|
564
|
+
expandedGroup: layout.groups.some((group) => group.key === previous.expandedGroup) ? previous.expandedGroup : null
|
|
565
|
+
});
|
|
566
|
+
if (previous.guestMoved) follow(false);
|
|
567
|
+
}
|
|
568
|
+
function report(surface, view) {
|
|
569
|
+
reports[surface] = view.bounds;
|
|
570
|
+
if (view.byGuest && !store.getState().guestMoved) {
|
|
571
|
+
update({ guestMoved: true });
|
|
572
|
+
if (options.regionScope === "release-on-move" && !released) {
|
|
573
|
+
released = true;
|
|
574
|
+
source.releaseRegionScope();
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
follow(view.byGuest && surface === driving());
|
|
578
|
+
}
|
|
579
|
+
const unsubscribe = source.subscribe(onSource);
|
|
580
|
+
return {
|
|
581
|
+
getState: () => store.getState(),
|
|
582
|
+
subscribe: (listener) => store.subscribe(listener),
|
|
583
|
+
report,
|
|
584
|
+
setPinWidth: (px) => {
|
|
585
|
+
if (px === pinWidth || !(px > 0)) return;
|
|
586
|
+
pinWidth = px;
|
|
587
|
+
update({ layout: layoutSameSpot(store.getState().points, {
|
|
588
|
+
mode,
|
|
589
|
+
pinWidthPx: px
|
|
590
|
+
}) });
|
|
591
|
+
},
|
|
592
|
+
openPin: (id) => update({ openId: id }),
|
|
593
|
+
closePopup: () => update({ openId: null }),
|
|
594
|
+
toggleGroup: (key) => update({ expandedGroup: key === null || key === store.getState().expandedGroup ? null : key }),
|
|
595
|
+
dispose: () => {
|
|
596
|
+
unsubscribe();
|
|
597
|
+
stopPulse();
|
|
598
|
+
if (applied !== null) handOver(null);
|
|
599
|
+
}
|
|
600
|
+
};
|
|
601
|
+
};
|
|
602
|
+
//#endregion
|
|
603
|
+
//#region src/enhance/parts.ts
|
|
604
|
+
const ENHANCE_PARTS = {
|
|
605
|
+
strip: "strip",
|
|
606
|
+
stripTrack: "strip-track",
|
|
607
|
+
stripItem: "strip-item",
|
|
608
|
+
stripPrev: "strip-prev",
|
|
609
|
+
stripNext: "strip-next",
|
|
610
|
+
stripDots: "strip-dots",
|
|
611
|
+
stripCounter: "strip-counter",
|
|
612
|
+
layer: "layer",
|
|
613
|
+
layerOpen: "layer-open",
|
|
614
|
+
layerClose: "layer-close",
|
|
615
|
+
layerBody: "layer-body",
|
|
616
|
+
reveal: "reveal",
|
|
617
|
+
revealItem: "reveal-item",
|
|
618
|
+
revealMore: "reveal-more",
|
|
619
|
+
revealLess: "reveal-less",
|
|
620
|
+
clamp: "clamp",
|
|
621
|
+
clampText: "clamp-text",
|
|
622
|
+
clampToggle: "clamp-toggle",
|
|
623
|
+
share: "share",
|
|
624
|
+
shareStatus: "share-status",
|
|
625
|
+
favorite: "favorite",
|
|
626
|
+
favoriteBadge: "favorite-badge",
|
|
627
|
+
gallery: "rental-gallery",
|
|
628
|
+
galleryTile: "rental-gallery-tile",
|
|
629
|
+
galleryAll: "rental-gallery-all",
|
|
630
|
+
galleryGrid: "rental-gallery-grid",
|
|
631
|
+
galleryViewer: "rental-gallery-viewer",
|
|
632
|
+
galleryCaption: "rental-gallery-caption",
|
|
633
|
+
galleryCounter: "rental-gallery-counter",
|
|
634
|
+
floorplanOpen: "rental-floorplan-open",
|
|
635
|
+
floorplan: "rental-floorplan"
|
|
636
|
+
};
|
|
637
|
+
const GATE_MARKERS = {
|
|
638
|
+
stats: "data-rental-stats",
|
|
639
|
+
descriptionStart: "data-rental-description",
|
|
640
|
+
descriptionEnd: "data-rental-description-end",
|
|
641
|
+
amenities: "data-rental-amenities",
|
|
642
|
+
amenitiesModal: "data-amenities-modal",
|
|
643
|
+
amenitiesEnd: "data-amenities-overlay-end",
|
|
644
|
+
roomAttribute: "data-room-attr",
|
|
645
|
+
reviewCard: "data-review-card"
|
|
646
|
+
};
|
|
647
|
+
//#endregion
|
|
648
|
+
export { connectShared as A, shareUrl as C, defineCopy as D, parsePriceLabel as E, fillTemplate as O, isTrackingParam as S, formatPriceLabel as T, acquireFavorites as _, DEFAULT_FIT as a, browserStorage as b, circleOffsets as c, hasMapPoints as d, mapPoints as f, FAVORITES_EVENT as g, SAME_SPOT_METERS as h, createResultsMapController as i, createStore as j, acquireShared as k, distanceMeters as l, RELOAD_FLASH_MS as m, GATE_MARKERS as n, frameFor as o, DEFAULT_MAP_STYLE_URL as p, DEFAULT_CLUSTER as r, idsInView as s, ENHANCE_PARTS as t, layoutSameSpot as u, createFavorites as v, withoutTracking as w, TRACKING_PARAM_PATTERN as x, favoritesStorageKey as y };
|