@v-office/website-headless 0.1.1 → 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.
@@ -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 };