@zappie/foundations 0.1.4 → 0.1.6

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.
@@ -1,12 +1,39 @@
1
1
  // src/branch-map/runtime.ts
2
+ import { MarkerClusterer } from "@googlemaps/markerclusterer";
3
+ import { clusterSize, fitPlan, focusedBranch } from "./fit.js";
2
4
  import {
3
- branchBounds,
4
5
  validCoordinates,
5
6
  visibleBranches
6
7
  } from "./geometry.js";
7
8
  import { loadMaps } from "./loader.js";
8
- var BRANCH_PADDING = 32;
9
- var SHOPPER_PADDING = 88;
9
+ import {
10
+ branchMarker,
11
+ DEFAULT_COLORS,
12
+ labelOffset,
13
+ markerOverlayClass,
14
+ paintLabel,
15
+ paintMarker
16
+ } from "./markers.js";
17
+ var clusterable = (marker) => marker;
18
+ var ROUTE_OPACITY = 0.7;
19
+ var ROUTE_WEIGHT = 4;
20
+ var CLUSTER_EXPAND_PADDING = 56;
21
+ var CLUSTER_EXPAND_MAX_ZOOM = 21;
22
+ var CLUSTER_EXPAND_MAX_ATTEMPTS = 6;
23
+ function clusterIcon(count, colors) {
24
+ const size = clusterSize(count);
25
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}"><circle cx="${size / 2}" cy="${size / 2}" r="${size / 2 - 3}" fill="${colors.accent}" stroke="#ffffff" stroke-width="3"/></svg>`;
26
+ return {
27
+ size,
28
+ url: `data:image/svg+xml;base64,${btoa(svg)}`,
29
+ label: {
30
+ text: String(count),
31
+ color: colors.accentForeground,
32
+ fontSize: "13px",
33
+ fontWeight: "600"
34
+ }
35
+ };
36
+ }
10
37
  async function createMap(element, key, signal, options) {
11
38
  const maps = await loadMaps(key);
12
39
  signal.throwIfAborted();
@@ -16,130 +43,229 @@ async function createMap(element, key, signal, options) {
16
43
  mapTypeControl: options.mapTypeControl,
17
44
  streetViewControl: false
18
45
  });
19
- let pins = [];
46
+ const Marker = markerOverlayClass(maps);
47
+ const pins = /* @__PURE__ */ new Map();
48
+ let shopperPin;
49
+ let route;
50
+ let clusterer;
51
+ let clusterColors;
52
+ let clustered;
20
53
  let fitListener;
54
+ let stopExpanding;
21
55
  let fitted;
22
56
  let lastSelected;
23
- class Pin extends maps.OverlayView {
24
- constructor(position, pin) {
25
- super();
26
- this.position = position;
27
- const { label, selected, imageUrl } = pin;
28
- const size = imageUrl ? `${selected ? 40 : 32}px` : "var(--branch-map-pin-size,24px)";
29
- this.button = document.createElement("button");
30
- this.button.type = "button";
31
- this.button.setAttribute("aria-label", label);
32
- this.button.setAttribute("aria-pressed", String(selected));
33
- this.button.dataset.selected = String(selected);
34
- this.button.dataset.branchMapPin = "";
35
- this.button.style.cssText = imageUrl ? `position:absolute;transform:translate(-50%,-100%);width:${size};height:${size};padding:0;border-radius:50%;overflow:hidden;border:2px solid white;background:white;box-shadow:${selected ? "0 2px 6px rgb(0 0 0 / 35%)" : "0 1px 3px rgb(0 0 0 / 25%)"};cursor:pointer` : `position:absolute;transform:translate(-50%,-100%);width:${size};height:${size};border-radius:50%;border:2px solid var(--branch-map-pin-border,currentColor);background:var(${selected ? "--branch-map-pin-selected" : "--branch-map-pin"},currentColor);cursor:pointer`;
36
- if (imageUrl) {
37
- const image = document.createElement("img");
38
- image.alt = "";
39
- image.decoding = "async";
40
- image.style.cssText = "display:block;width:100%;height:100%;object-fit:cover";
41
- image.src = imageUrl;
42
- this.button.appendChild(image);
43
- }
44
- if (pin.caption) {
45
- this.caption = document.createElement("span");
46
- this.caption.textContent = pin.caption;
47
- this.caption.setAttribute("aria-hidden", "true");
48
- this.caption.dataset.branchMapPinLabel = "";
49
- this.caption.dataset.selected = String(selected);
50
- this.caption.style.cssText = `position:absolute;transform:translate(-50%,calc(-100% - ${size} - 4px));white-space:nowrap;pointer-events:none;padding:2px 8px;border-radius:4px;font-size:12px;line-height:16px;font-weight:500;background:var(--branch-map-label-background,white);color:var(--branch-map-label-color,#111);border:1px solid var(--branch-map-label-border,transparent);box-shadow:0 1px 3px rgb(0 0 0 / 20%)`;
51
- }
52
- this.button.onclick = pin.onClick ?? null;
53
- }
54
- onAdd() {
55
- const pane = this.getPanes()?.overlayMouseTarget;
56
- pane?.appendChild(this.button);
57
- if (this.caption) {
58
- pane?.appendChild(this.caption);
59
- }
60
- maps.OverlayView.preventMapHitsAndGesturesFrom(this.button);
61
- }
62
- draw() {
63
- const point = this.getProjection().fromLatLngToDivPixel(
64
- new maps.LatLng(this.position)
65
- );
66
- if (point) {
67
- for (const node of [this.button, this.caption]) {
68
- if (node) {
69
- node.style.left = `${point.x}px`;
70
- node.style.top = `${point.y}px`;
71
- }
72
- }
73
- }
74
- }
75
- onRemove() {
76
- this.button.remove();
77
- this.caption?.remove();
78
- this.button.onclick = null;
79
- }
80
- }
81
- const clear = () => {
82
- pins.forEach((pin) => pin.setMap(null));
83
- pins = [];
57
+ let select = () => {
84
58
  };
85
- const fit = (branches, location, maxZoom) => {
59
+ const fit = (plan) => {
86
60
  fitListener?.remove();
87
61
  fitListener = void 0;
88
- const bounds = branchBounds(branches, location);
89
- if (!bounds) {
62
+ if (plan.kind === "center") {
63
+ map.panTo(plan.center);
64
+ map.setZoom(plan.zoom);
90
65
  return;
91
66
  }
92
- map.fitBounds(bounds, location ? SHOPPER_PADDING : BRANCH_PADDING);
67
+ map.fitBounds(plan.bounds, plan.padding);
93
68
  fitListener = maps.event.addListenerOnce(map, "idle", () => {
94
69
  fitListener = void 0;
95
70
  const zoom = map.getZoom();
96
- if (typeof zoom === "number" && zoom > maxZoom) {
97
- map.setZoom(maxZoom);
71
+ if (typeof zoom === "number" && zoom > plan.maxZoom) {
72
+ map.setZoom(plan.maxZoom);
98
73
  }
99
74
  });
100
75
  };
76
+ const ensureClusterer = (colors) => {
77
+ const key2 = `${colors.accent}|${colors.accentForeground}`;
78
+ if (clusterer && clusterColors === key2) {
79
+ return clusterer;
80
+ }
81
+ clusterer?.clearMarkers(true);
82
+ clusterer?.setMap(null);
83
+ clusterColors = key2;
84
+ clustered = void 0;
85
+ clusterer = new MarkerClusterer({
86
+ map,
87
+ renderer: {
88
+ render: ({ count, position }) => {
89
+ const icon = clusterIcon(count, colors);
90
+ return new maps.Marker({
91
+ position,
92
+ icon: {
93
+ url: icon.url,
94
+ scaledSize: new maps.Size(icon.size, icon.size)
95
+ },
96
+ label: icon.label,
97
+ zIndex: 999
98
+ });
99
+ }
100
+ }
101
+ });
102
+ return clusterer;
103
+ };
104
+ const expandTo = (marker) => {
105
+ stopExpanding?.();
106
+ const active = clusterer;
107
+ if (!active) {
108
+ return;
109
+ }
110
+ let attempts = 0;
111
+ let cancelled = false;
112
+ let idle;
113
+ const expand = () => {
114
+ if (cancelled) {
115
+ return;
116
+ }
117
+ active.render();
118
+ const cluster = active.clusters?.find(
119
+ (candidate) => candidate.markers?.includes(clusterable(marker))
120
+ );
121
+ if (!cluster || cluster.count <= 1) {
122
+ return;
123
+ }
124
+ if (attempts === 0 && cluster.bounds) {
125
+ map.fitBounds(cluster.bounds, CLUSTER_EXPAND_PADDING);
126
+ } else {
127
+ const zoom = map.getZoom();
128
+ if (typeof zoom !== "number" || zoom >= CLUSTER_EXPAND_MAX_ZOOM) {
129
+ return;
130
+ }
131
+ map.setZoom(Math.min(zoom + 2, CLUSTER_EXPAND_MAX_ZOOM));
132
+ }
133
+ attempts += 1;
134
+ if (attempts < CLUSTER_EXPAND_MAX_ATTEMPTS) {
135
+ idle = maps.event.addListenerOnce(map, "idle", expand);
136
+ }
137
+ };
138
+ expand();
139
+ stopExpanding = () => {
140
+ cancelled = true;
141
+ idle?.remove();
142
+ stopExpanding = void 0;
143
+ };
144
+ };
101
145
  return {
102
146
  update(branches, selected, onSelect, location, options2) {
103
- clear();
147
+ select = onSelect;
148
+ const colors = options2?.colors ?? DEFAULT_COLORS;
104
149
  map.setOptions({ mapTypeControl: options2?.mapTypeControl ?? false });
105
150
  const visible = visibleBranches(branches);
151
+ const shopper = validCoordinates(location) ? location : void 0;
152
+ const focused = focusedBranch(visible, selected, shopper);
153
+ for (const [id, pin] of pins) {
154
+ if (!visible.some((branch) => branch.id === id)) {
155
+ pin.marker.setMap(null);
156
+ pins.delete(id);
157
+ }
158
+ }
106
159
  for (const branch of visible) {
107
- const isSelected = branch.id === selected;
108
- const pin = new Pin(branch.coordinates, {
109
- label: branch.name,
110
- selected: isSelected,
111
- imageUrl: options2?.pinImageUrl,
112
- caption: options2?.pinLabel === "all" || options2?.pinLabel === "selected" && isSelected ? branch.name : void 0,
113
- onClick: () => onSelect(branch)
160
+ let pin = pins.get(branch.id);
161
+ if (!pin) {
162
+ const button2 = document.createElement("button");
163
+ button2.type = "button";
164
+ button2.dataset.branchMapPin = "";
165
+ const id = branch.id;
166
+ button2.onclick = () => {
167
+ const current = pins.get(id);
168
+ if (current) {
169
+ select(current.branch);
170
+ }
171
+ };
172
+ pin = {
173
+ marker: new Marker(branch.id, branch.coordinates, button2),
174
+ branch
175
+ };
176
+ pin.marker.label.dataset.branchMapPinLabel = "";
177
+ pins.set(branch.id, pin);
178
+ }
179
+ pin.branch = branch;
180
+ pin.marker.setPosition(branch.coordinates);
181
+ const isFocused = branch.id === focused?.id;
182
+ const spec = branchMarker(options2?.pinImageUrl, isFocused);
183
+ const button = pin.marker.element;
184
+ button.setAttribute("aria-label", branch.name);
185
+ button.setAttribute("aria-pressed", String(branch.id === selected));
186
+ button.dataset.selected = String(branch.id === selected);
187
+ button.dataset.focused = String(isFocused);
188
+ paintMarker(button, spec, colors);
189
+ const showLabel = options2?.pinLabel === "all" || options2?.pinLabel === "selected" && isFocused;
190
+ pin.marker.setLabel(showLabel ? branch.name : void 0);
191
+ pin.marker.label.dataset.selected = String(branch.id === selected);
192
+ paintLabel(pin.marker.label, {
193
+ offset: labelOffset(spec),
194
+ capitalize: true,
195
+ colors
114
196
  });
115
- pin.setMap(map);
116
- pins.push(pin);
117
197
  }
118
- const shopper = validCoordinates(location) ? location : void 0;
198
+ const active = ensureClusterer(colors);
199
+ const markerSet = [...pins.keys()].join("\n");
200
+ if (markerSet !== clustered) {
201
+ clustered = markerSet;
202
+ active.clearMarkers(true);
203
+ active.addMarkers(
204
+ [...pins.values()].map(({ marker: marker2 }) => clusterable(marker2))
205
+ );
206
+ }
119
207
  if (shopper) {
120
- const pin = new Pin(shopper, {
121
- label: "Your location",
122
- selected: false
208
+ if (!shopperPin) {
209
+ const house = document.createElement("div");
210
+ house.setAttribute("role", "img");
211
+ house.setAttribute("aria-label", "Your location");
212
+ house.dataset.branchMapShopper = "";
213
+ shopperPin = new Marker("shopper", shopper, house);
214
+ shopperPin.setLabel("You");
215
+ shopperPin.setMap(map);
216
+ }
217
+ shopperPin.setPosition(shopper);
218
+ paintMarker(
219
+ shopperPin.element,
220
+ { kind: "house", color: colors.accentForeground },
221
+ colors
222
+ );
223
+ paintLabel(shopperPin.label, {
224
+ offset: labelOffset({ kind: "house", color: "" }),
225
+ capitalize: false,
226
+ colors
123
227
  });
124
- pin.setMap(map);
125
- pins.push(pin);
228
+ } else {
229
+ shopperPin?.setMap(null);
230
+ shopperPin = void 0;
126
231
  }
127
- const maxZoom = options2?.maxZoom ?? 14;
128
- const frame = JSON.stringify([
129
- visible.map(({ coordinates }) => [coordinates.lat, coordinates.lng]),
130
- shopper ? [shopper.lat, shopper.lng] : null,
131
- maxZoom
132
- ]);
232
+ if (shopper && focused) {
233
+ route ?? (route = new maps.Polyline({ map }));
234
+ route.setOptions({
235
+ path: [shopper, focused.coordinates],
236
+ strokeColor: colors.accent,
237
+ strokeOpacity: ROUTE_OPACITY,
238
+ strokeWeight: ROUTE_WEIGHT,
239
+ clickable: false
240
+ });
241
+ } else {
242
+ route?.setMap(null);
243
+ route = void 0;
244
+ }
245
+ const plan = fitPlan({
246
+ branches: visible,
247
+ shopper,
248
+ focused,
249
+ maxZoom: options2?.maxZoom ?? 14,
250
+ compact: options2?.compact ?? false
251
+ });
252
+ const frame = JSON.stringify(plan ?? null);
133
253
  const previous = lastSelected;
134
254
  lastSelected = selected;
135
255
  if (frame !== fitted) {
136
256
  fitted = frame;
137
- fit(branches, shopper, maxZoom);
138
- return;
257
+ if (plan) {
258
+ fit(plan);
259
+ }
260
+ } else {
261
+ const target = visible.find((branch) => branch.id === selected);
262
+ if (target && selected !== previous && !shopper && !options2?.compact) {
263
+ map.panTo(target.coordinates);
264
+ }
139
265
  }
140
- const target = visible.find((branch) => branch.id === selected);
141
- if (target && selected !== previous) {
142
- map.panTo(target.coordinates);
266
+ const marker = selected ? pins.get(selected)?.marker : void 0;
267
+ if (marker && selected !== previous) {
268
+ expandTo(marker);
143
269
  }
144
270
  },
145
271
  locate(location) {
@@ -147,13 +273,23 @@ async function createMap(element, key, signal, options) {
147
273
  },
148
274
  destroy() {
149
275
  fitListener?.remove();
150
- clear();
276
+ stopExpanding?.();
277
+ clusterer?.clearMarkers(true);
278
+ clusterer?.setMap(null);
279
+ for (const { marker } of pins.values()) {
280
+ marker.setMap(null);
281
+ marker.element.onclick = null;
282
+ }
283
+ pins.clear();
284
+ shopperPin?.setMap(null);
285
+ route?.setMap(null);
151
286
  maps.event.clearInstanceListeners(map);
152
287
  element.replaceChildren();
153
288
  }
154
289
  };
155
290
  }
156
291
  export {
292
+ clusterIcon,
157
293
  createMap
158
294
  };
159
295
  //# sourceMappingURL=runtime.js.map
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/branch-map/runtime.ts"],
4
- "sourcesContent": ["import type { Branch } from \"@zappie/sdk\";\nimport {\n branchBounds,\n validCoordinates,\n visibleBranches,\n type Coordinates,\n} from \"./geometry\";\nimport { loadMaps } from \"./loader\";\n\n/** Padding around fitted branches, and around branches plus the shopper. */\nconst BRANCH_PADDING = 32;\nconst SHOPPER_PADDING = 88;\n\nexport type MapUpdateOptions = {\n maxZoom: number;\n pinImageUrl: string | undefined;\n mapTypeControl: boolean;\n pinLabel?: \"selected\" | \"all\" | undefined;\n};\n\ntype PinOptions = {\n label: string;\n selected: boolean;\n imageUrl?: string | undefined;\n /** Text shown above the pin. */\n caption?: string | undefined;\n onClick?: () => void;\n};\n\nexport async function createMap(\n element: HTMLElement,\n key: string,\n signal: AbortSignal,\n options: { mapTypeControl: boolean },\n) {\n const maps = await loadMaps(key);\n signal.throwIfAborted();\n const map = new maps.Map(element, {\n center: { lat: 0, lng: 0 },\n zoom: 12,\n mapTypeControl: options.mapTypeControl,\n streetViewControl: false,\n });\n let pins: google.maps.OverlayView[] = [];\n let fitListener: google.maps.MapsEventListener | undefined;\n let fitted: string | undefined;\n let lastSelected: string | undefined;\n class Pin extends maps.OverlayView {\n private button: HTMLButtonElement;\n private caption: HTMLElement | undefined;\n constructor(\n private position: Coordinates,\n pin: PinOptions,\n ) {\n super();\n const { label, selected, imageUrl } = pin;\n const size = imageUrl\n ? `${selected ? 40 : 32}px`\n : \"var(--branch-map-pin-size,24px)\";\n this.button = document.createElement(\"button\");\n this.button.type = \"button\";\n this.button.setAttribute(\"aria-label\", label);\n this.button.setAttribute(\"aria-pressed\", String(selected));\n this.button.dataset.selected = String(selected);\n this.button.dataset.branchMapPin = \"\";\n this.button.style.cssText = imageUrl\n ? `position:absolute;transform:translate(-50%,-100%);width:${size};height:${size};padding:0;border-radius:50%;overflow:hidden;border:2px solid white;background:white;box-shadow:${selected ? \"0 2px 6px rgb(0 0 0 / 35%)\" : \"0 1px 3px rgb(0 0 0 / 25%)\"};cursor:pointer`\n : `position:absolute;transform:translate(-50%,-100%);width:${size};height:${size};border-radius:50%;border:2px solid var(--branch-map-pin-border,currentColor);background:var(${selected ? \"--branch-map-pin-selected\" : \"--branch-map-pin\"},currentColor);cursor:pointer`;\n if (imageUrl) {\n const image = document.createElement(\"img\");\n image.alt = \"\";\n image.decoding = \"async\";\n image.style.cssText =\n \"display:block;width:100%;height:100%;object-fit:cover\";\n image.src = imageUrl;\n this.button.appendChild(image);\n }\n if (pin.caption) {\n // The button already carries the name, so the caption is visual only.\n this.caption = document.createElement(\"span\");\n this.caption.textContent = pin.caption;\n this.caption.setAttribute(\"aria-hidden\", \"true\");\n this.caption.dataset.branchMapPinLabel = \"\";\n this.caption.dataset.selected = String(selected);\n this.caption.style.cssText = `position:absolute;transform:translate(-50%,calc(-100% - ${size} - 4px));white-space:nowrap;pointer-events:none;padding:2px 8px;border-radius:4px;font-size:12px;line-height:16px;font-weight:500;background:var(--branch-map-label-background,white);color:var(--branch-map-label-color,#111);border:1px solid var(--branch-map-label-border,transparent);box-shadow:0 1px 3px rgb(0 0 0 / 20%)`;\n }\n this.button.onclick = pin.onClick ?? null;\n }\n onAdd() {\n const pane = this.getPanes()?.overlayMouseTarget;\n pane?.appendChild(this.button);\n if (this.caption) {\n pane?.appendChild(this.caption);\n }\n maps.OverlayView.preventMapHitsAndGesturesFrom(this.button);\n }\n draw() {\n const point = this.getProjection().fromLatLngToDivPixel(\n new maps.LatLng(this.position),\n );\n if (point) {\n for (const node of [this.button, this.caption]) {\n if (node) {\n node.style.left = `${point.x}px`;\n node.style.top = `${point.y}px`;\n }\n }\n }\n }\n onRemove() {\n this.button.remove();\n this.caption?.remove();\n this.button.onclick = null;\n }\n }\n const clear = () => {\n pins.forEach((pin) => pin.setMap(null));\n pins = [];\n };\n const fit = (\n branches: Branch[],\n location: Coordinates | undefined,\n maxZoom: number,\n ) => {\n fitListener?.remove();\n fitListener = undefined;\n const bounds = branchBounds(branches, location);\n if (!bounds) {\n return;\n }\n map.fitBounds(bounds, location ? SHOPPER_PADDING : BRANCH_PADDING);\n fitListener = maps.event.addListenerOnce(map, \"idle\", () => {\n fitListener = undefined;\n const zoom = map.getZoom();\n if (typeof zoom === \"number\" && zoom > maxZoom) {\n map.setZoom(maxZoom);\n }\n });\n };\n return {\n update(\n branches: Branch[],\n selected: string | undefined,\n onSelect: (branch: Branch) => void,\n location?: Coordinates,\n options?: MapUpdateOptions,\n ) {\n clear();\n map.setOptions({ mapTypeControl: options?.mapTypeControl ?? false });\n const visible = visibleBranches(branches);\n for (const branch of visible) {\n const isSelected = branch.id === selected;\n const pin = new Pin(branch.coordinates, {\n label: branch.name,\n selected: isSelected,\n imageUrl: options?.pinImageUrl,\n caption:\n options?.pinLabel === \"all\" ||\n (options?.pinLabel === \"selected\" && isSelected)\n ? branch.name\n : undefined,\n onClick: () => onSelect(branch),\n });\n pin.setMap(map);\n pins.push(pin);\n }\n const shopper = validCoordinates(location) ? location : undefined;\n if (shopper) {\n const pin = new Pin(shopper, {\n label: \"Your location\",\n selected: false,\n });\n pin.setMap(map);\n pins.push(pin);\n }\n const maxZoom = options?.maxZoom ?? 14;\n // Refit only when what the map frames changes. A new selection pans instead.\n const frame = JSON.stringify([\n visible.map(({ coordinates }) => [coordinates.lat, coordinates.lng]),\n shopper ? [shopper.lat, shopper.lng] : null,\n maxZoom,\n ]);\n const previous = lastSelected;\n lastSelected = selected;\n if (frame !== fitted) {\n fitted = frame;\n fit(branches, shopper, maxZoom);\n return;\n }\n const target = visible.find((branch) => branch.id === selected);\n if (target && selected !== previous) {\n map.panTo(target.coordinates);\n }\n },\n locate(location: Coordinates) {\n map.panTo(location);\n },\n destroy() {\n fitListener?.remove();\n clear();\n maps.event.clearInstanceListeners(map);\n element.replaceChildren();\n },\n };\n}\n"],
5
- "mappings": "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",
6
- "names": ["options"]
4
+ "sourcesContent": ["import { MarkerClusterer, type Cluster } from \"@googlemaps/markerclusterer\";\nimport type { Branch } from \"@zappie/sdk\";\nimport { clusterSize, fitPlan, focusedBranch, type FitPlan } from \"./fit\";\nimport {\n validCoordinates,\n visibleBranches,\n type Coordinates,\n} from \"./geometry\";\nimport { loadMaps } from \"./loader\";\nimport {\n branchMarker,\n DEFAULT_COLORS,\n labelOffset,\n markerOverlayClass,\n paintLabel,\n paintMarker,\n type MapColors,\n type MarkerOverlay,\n} from \"./markers\";\n\nexport type MapUpdateOptions = {\n maxZoom: number;\n pinImageUrl: string | undefined;\n mapTypeControl: boolean;\n pinLabel?: \"selected\" | \"all\" | undefined;\n compact?: boolean | undefined;\n colors?: MapColors | undefined;\n};\n\n// The clusterer reads only getPosition, getVisible and setMap, which overlays provide.\nconst clusterable = (marker: MarkerOverlay) =>\n marker as unknown as google.maps.Marker;\n\nconst ROUTE_OPACITY = 0.7;\nconst ROUTE_WEIGHT = 4;\nconst CLUSTER_EXPAND_PADDING = 56;\nconst CLUSTER_EXPAND_MAX_ZOOM = 21;\nconst CLUSTER_EXPAND_MAX_ATTEMPTS = 6;\n\n/** The cluster icon: an accent circle with a white ring and the branch count. */\nexport function clusterIcon(count: number, colors: MapColors) {\n const size = clusterSize(count);\n const svg = `<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"${size}\" height=\"${size}\" viewBox=\"0 0 ${size} ${size}\"><circle cx=\"${size / 2}\" cy=\"${size / 2}\" r=\"${size / 2 - 3}\" fill=\"${colors.accent}\" stroke=\"#ffffff\" stroke-width=\"3\"/></svg>`;\n return {\n size,\n url: `data:image/svg+xml;base64,${btoa(svg)}`,\n label: {\n text: String(count),\n color: colors.accentForeground,\n fontSize: \"13px\",\n fontWeight: \"600\",\n },\n };\n}\n\nexport async function createMap(\n element: HTMLElement,\n key: string,\n signal: AbortSignal,\n options: { mapTypeControl: boolean },\n) {\n const maps = await loadMaps(key);\n signal.throwIfAborted();\n const map = new maps.Map(element, {\n center: { lat: 0, lng: 0 },\n zoom: 12,\n mapTypeControl: options.mapTypeControl,\n streetViewControl: false,\n });\n const Marker = markerOverlayClass(maps);\n const pins = new Map<string, { marker: MarkerOverlay; branch: Branch }>();\n let shopperPin: MarkerOverlay | undefined;\n let route: google.maps.Polyline | undefined;\n let clusterer: MarkerClusterer | undefined;\n let clusterColors: string | undefined;\n let clustered: string | undefined;\n let fitListener: google.maps.MapsEventListener | undefined;\n let stopExpanding: (() => void) | undefined;\n let fitted: string | undefined;\n let lastSelected: string | undefined;\n let select: (branch: Branch) => void = () => {};\n\n const fit = (plan: FitPlan) => {\n fitListener?.remove();\n fitListener = undefined;\n if (plan.kind === \"center\") {\n map.panTo(plan.center);\n map.setZoom(plan.zoom);\n return;\n }\n map.fitBounds(plan.bounds, plan.padding);\n fitListener = maps.event.addListenerOnce(map, \"idle\", () => {\n fitListener = undefined;\n const zoom = map.getZoom();\n if (typeof zoom === \"number\" && zoom > plan.maxZoom) {\n map.setZoom(plan.maxZoom);\n }\n });\n };\n\n const ensureClusterer = (colors: MapColors) => {\n const key = `${colors.accent}|${colors.accentForeground}`;\n if (clusterer && clusterColors === key) {\n return clusterer;\n }\n clusterer?.clearMarkers(true);\n clusterer?.setMap(null);\n clusterColors = key;\n clustered = undefined;\n // The default click handler fits the map to the cluster, which expands it.\n clusterer = new MarkerClusterer({\n map,\n renderer: {\n render: ({ count, position }) => {\n const icon = clusterIcon(count, colors);\n return new maps.Marker({\n position,\n icon: {\n url: icon.url,\n scaledSize: new maps.Size(icon.size, icon.size),\n },\n label: icon.label,\n zIndex: 999,\n });\n },\n },\n });\n return clusterer;\n };\n\n // A selected branch hidden inside a cluster: zoom in until its own pin shows.\n const expandTo = (marker: MarkerOverlay) => {\n stopExpanding?.();\n const active = clusterer;\n if (!active) {\n return;\n }\n let attempts = 0;\n let cancelled = false;\n let idle: google.maps.MapsEventListener | undefined;\n const expand = () => {\n if (cancelled) {\n return;\n }\n active.render();\n const cluster = (\n active as MarkerClusterer & { clusters?: Cluster[] }\n ).clusters?.find((candidate) =>\n candidate.markers?.includes(clusterable(marker)),\n );\n if (!cluster || cluster.count <= 1) {\n return;\n }\n if (attempts === 0 && cluster.bounds) {\n map.fitBounds(cluster.bounds, CLUSTER_EXPAND_PADDING);\n } else {\n const zoom = map.getZoom();\n if (typeof zoom !== \"number\" || zoom >= CLUSTER_EXPAND_MAX_ZOOM) {\n return;\n }\n map.setZoom(Math.min(zoom + 2, CLUSTER_EXPAND_MAX_ZOOM));\n }\n attempts += 1;\n if (attempts < CLUSTER_EXPAND_MAX_ATTEMPTS) {\n idle = maps.event.addListenerOnce(map, \"idle\", expand);\n }\n };\n expand();\n stopExpanding = () => {\n cancelled = true;\n idle?.remove();\n stopExpanding = undefined;\n };\n };\n\n return {\n update(\n branches: Branch[],\n selected: string | undefined,\n onSelect: (branch: Branch) => void,\n location?: Coordinates,\n options?: MapUpdateOptions,\n ) {\n select = onSelect;\n const colors = options?.colors ?? DEFAULT_COLORS;\n map.setOptions({ mapTypeControl: options?.mapTypeControl ?? false });\n const visible = visibleBranches(branches);\n const shopper = validCoordinates(location) ? location : undefined;\n const focused = focusedBranch(visible, selected, shopper);\n\n for (const [id, pin] of pins) {\n if (!visible.some((branch) => branch.id === id)) {\n pin.marker.setMap(null);\n pins.delete(id);\n }\n }\n for (const branch of visible) {\n let pin = pins.get(branch.id);\n if (!pin) {\n const button = document.createElement(\"button\");\n button.type = \"button\";\n button.dataset.branchMapPin = \"\";\n const id = branch.id;\n button.onclick = () => {\n const current = pins.get(id);\n if (current) {\n select(current.branch);\n }\n };\n pin = {\n marker: new Marker(branch.id, branch.coordinates, button),\n branch,\n };\n pin.marker.label.dataset.branchMapPinLabel = \"\";\n pins.set(branch.id, pin);\n }\n pin.branch = branch;\n pin.marker.setPosition(branch.coordinates);\n const isFocused = branch.id === focused?.id;\n const spec = branchMarker(options?.pinImageUrl, isFocused);\n const button = pin.marker.element;\n button.setAttribute(\"aria-label\", branch.name);\n button.setAttribute(\"aria-pressed\", String(branch.id === selected));\n button.dataset.selected = String(branch.id === selected);\n button.dataset.focused = String(isFocused);\n paintMarker(button, spec, colors);\n const showLabel =\n options?.pinLabel === \"all\" ||\n (options?.pinLabel === \"selected\" && isFocused);\n pin.marker.setLabel(showLabel ? branch.name : undefined);\n pin.marker.label.dataset.selected = String(branch.id === selected);\n paintLabel(pin.marker.label, {\n offset: labelOffset(spec),\n capitalize: true,\n colors,\n });\n }\n\n const active = ensureClusterer(colors);\n const markerSet = [...pins.keys()].join(\"\\n\");\n if (markerSet !== clustered) {\n clustered = markerSet;\n active.clearMarkers(true);\n active.addMarkers(\n [...pins.values()].map(({ marker }) => clusterable(marker)),\n );\n }\n\n if (shopper) {\n if (!shopperPin) {\n const house = document.createElement(\"div\");\n house.setAttribute(\"role\", \"img\");\n house.setAttribute(\"aria-label\", \"Your location\");\n house.dataset.branchMapShopper = \"\";\n shopperPin = new Marker(\"shopper\", shopper, house);\n shopperPin.setLabel(\"You\");\n shopperPin.setMap(map);\n }\n shopperPin.setPosition(shopper);\n paintMarker(\n shopperPin.element,\n { kind: \"house\", color: colors.accentForeground },\n colors,\n );\n paintLabel(shopperPin.label, {\n offset: labelOffset({ kind: \"house\", color: \"\" }),\n capitalize: false,\n colors,\n });\n } else {\n shopperPin?.setMap(null);\n shopperPin = undefined;\n }\n\n if (shopper && focused) {\n route ??= new maps.Polyline({ map });\n route.setOptions({\n path: [shopper, focused.coordinates],\n strokeColor: colors.accent,\n strokeOpacity: ROUTE_OPACITY,\n strokeWeight: ROUTE_WEIGHT,\n clickable: false,\n });\n } else {\n route?.setMap(null);\n route = undefined;\n }\n\n const plan = fitPlan({\n branches: visible,\n shopper,\n focused,\n maxZoom: options?.maxZoom ?? 14,\n compact: options?.compact ?? false,\n });\n // Refit only when what the map frames changes. A new selection pans instead.\n const frame = JSON.stringify(plan ?? null);\n const previous = lastSelected;\n lastSelected = selected;\n if (frame !== fitted) {\n fitted = frame;\n if (plan) {\n fit(plan);\n }\n } else {\n const target = visible.find((branch) => branch.id === selected);\n if (target && selected !== previous && !shopper && !options?.compact) {\n map.panTo(target.coordinates);\n }\n }\n const marker = selected ? pins.get(selected)?.marker : undefined;\n if (marker && selected !== previous) {\n expandTo(marker);\n }\n },\n locate(location: Coordinates) {\n map.panTo(location);\n },\n destroy() {\n fitListener?.remove();\n stopExpanding?.();\n clusterer?.clearMarkers(true);\n clusterer?.setMap(null);\n for (const { marker } of pins.values()) {\n marker.setMap(null);\n marker.element.onclick = null;\n }\n pins.clear();\n shopperPin?.setMap(null);\n route?.setMap(null);\n maps.event.clearInstanceListeners(map);\n element.replaceChildren();\n },\n };\n}\n"],
5
+ "mappings": ";AAAA,SAAS,uBAAqC;AAE9C,SAAS,aAAa,SAAS,qBAAmC;AAClE;AAAA,EACE;AAAA,EACA;AAAA,OAEK;AACP,SAAS,gBAAgB;AACzB;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OAGK;AAYP,IAAM,cAAc,CAAC,WACnB;AAEF,IAAM,gBAAgB;AACtB,IAAM,eAAe;AACrB,IAAM,yBAAyB;AAC/B,IAAM,0BAA0B;AAChC,IAAM,8BAA8B;AAG7B,SAAS,YAAY,OAAe,QAAmB;AAC5D,QAAM,OAAO,YAAY,KAAK;AAC9B,QAAM,MAAM,kDAAkD,IAAI,aAAa,IAAI,kBAAkB,IAAI,IAAI,IAAI,iBAAiB,OAAO,CAAC,SAAS,OAAO,CAAC,QAAQ,OAAO,IAAI,CAAC,WAAW,OAAO,MAAM;AACvM,SAAO;AAAA,IACL;AAAA,IACA,KAAK,6BAA6B,KAAK,GAAG,CAAC;AAAA,IAC3C,OAAO;AAAA,MACL,MAAM,OAAO,KAAK;AAAA,MAClB,OAAO,OAAO;AAAA,MACd,UAAU;AAAA,MACV,YAAY;AAAA,IACd;AAAA,EACF;AACF;AAEA,eAAsB,UACpB,SACA,KACA,QACA,SACA;AACA,QAAM,OAAO,MAAM,SAAS,GAAG;AAC/B,SAAO,eAAe;AACtB,QAAM,MAAM,IAAI,KAAK,IAAI,SAAS;AAAA,IAChC,QAAQ,EAAE,KAAK,GAAG,KAAK,EAAE;AAAA,IACzB,MAAM;AAAA,IACN,gBAAgB,QAAQ;AAAA,IACxB,mBAAmB;AAAA,EACrB,CAAC;AACD,QAAM,SAAS,mBAAmB,IAAI;AACtC,QAAM,OAAO,oBAAI,IAAuD;AACxE,MAAI;AACJ,MAAI;AACJ,MAAI;AACJ,MAAI;AACJ,MAAI;AACJ,MAAI;AACJ,MAAI;AACJ,MAAI;AACJ,MAAI;AACJ,MAAI,SAAmC,MAAM;AAAA,EAAC;AAE9C,QAAM,MAAM,CAAC,SAAkB;AAC7B,iBAAa,OAAO;AACpB,kBAAc;AACd,QAAI,KAAK,SAAS,UAAU;AAC1B,UAAI,MAAM,KAAK,MAAM;AACrB,UAAI,QAAQ,KAAK,IAAI;AACrB;AAAA,IACF;AACA,QAAI,UAAU,KAAK,QAAQ,KAAK,OAAO;AACvC,kBAAc,KAAK,MAAM,gBAAgB,KAAK,QAAQ,MAAM;AAC1D,oBAAc;AACd,YAAM,OAAO,IAAI,QAAQ;AACzB,UAAI,OAAO,SAAS,YAAY,OAAO,KAAK,SAAS;AACnD,YAAI,QAAQ,KAAK,OAAO;AAAA,MAC1B;AAAA,IACF,CAAC;AAAA,EACH;AAEA,QAAM,kBAAkB,CAAC,WAAsB;AAC7C,UAAMA,OAAM,GAAG,OAAO,MAAM,IAAI,OAAO,gBAAgB;AACvD,QAAI,aAAa,kBAAkBA,MAAK;AACtC,aAAO;AAAA,IACT;AACA,eAAW,aAAa,IAAI;AAC5B,eAAW,OAAO,IAAI;AACtB,oBAAgBA;AAChB,gBAAY;AAEZ,gBAAY,IAAI,gBAAgB;AAAA,MAC9B;AAAA,MACA,UAAU;AAAA,QACR,QAAQ,CAAC,EAAE,OAAO,SAAS,MAAM;AAC/B,gBAAM,OAAO,YAAY,OAAO,MAAM;AACtC,iBAAO,IAAI,KAAK,OAAO;AAAA,YACrB;AAAA,YACA,MAAM;AAAA,cACJ,KAAK,KAAK;AAAA,cACV,YAAY,IAAI,KAAK,KAAK,KAAK,MAAM,KAAK,IAAI;AAAA,YAChD;AAAA,YACA,OAAO,KAAK;AAAA,YACZ,QAAQ;AAAA,UACV,CAAC;AAAA,QACH;AAAA,MACF;AAAA,IACF,CAAC;AACD,WAAO;AAAA,EACT;AAGA,QAAM,WAAW,CAAC,WAA0B;AAC1C,oBAAgB;AAChB,UAAM,SAAS;AACf,QAAI,CAAC,QAAQ;AACX;AAAA,IACF;AACA,QAAI,WAAW;AACf,QAAI,YAAY;AAChB,QAAI;AACJ,UAAM,SAAS,MAAM;AACnB,UAAI,WAAW;AACb;AAAA,MACF;AACA,aAAO,OAAO;AACd,YAAM,UACJ,OACA,UAAU;AAAA,QAAK,CAAC,cAChB,UAAU,SAAS,SAAS,YAAY,MAAM,CAAC;AAAA,MACjD;AACA,UAAI,CAAC,WAAW,QAAQ,SAAS,GAAG;AAClC;AAAA,MACF;AACA,UAAI,aAAa,KAAK,QAAQ,QAAQ;AACpC,YAAI,UAAU,QAAQ,QAAQ,sBAAsB;AAAA,MACtD,OAAO;AACL,cAAM,OAAO,IAAI,QAAQ;AACzB,YAAI,OAAO,SAAS,YAAY,QAAQ,yBAAyB;AAC/D;AAAA,QACF;AACA,YAAI,QAAQ,KAAK,IAAI,OAAO,GAAG,uBAAuB,CAAC;AAAA,MACzD;AACA,kBAAY;AACZ,UAAI,WAAW,6BAA6B;AAC1C,eAAO,KAAK,MAAM,gBAAgB,KAAK,QAAQ,MAAM;AAAA,MACvD;AAAA,IACF;AACA,WAAO;AACP,oBAAgB,MAAM;AACpB,kBAAY;AACZ,YAAM,OAAO;AACb,sBAAgB;AAAA,IAClB;AAAA,EACF;AAEA,SAAO;AAAA,IACL,OACE,UACA,UACA,UACA,UACAC,UACA;AACA,eAAS;AACT,YAAM,SAASA,UAAS,UAAU;AAClC,UAAI,WAAW,EAAE,gBAAgBA,UAAS,kBAAkB,MAAM,CAAC;AACnE,YAAM,UAAU,gBAAgB,QAAQ;AACxC,YAAM,UAAU,iBAAiB,QAAQ,IAAI,WAAW;AACxD,YAAM,UAAU,cAAc,SAAS,UAAU,OAAO;AAExD,iBAAW,CAAC,IAAI,GAAG,KAAK,MAAM;AAC5B,YAAI,CAAC,QAAQ,KAAK,CAAC,WAAW,OAAO,OAAO,EAAE,GAAG;AAC/C,cAAI,OAAO,OAAO,IAAI;AACtB,eAAK,OAAO,EAAE;AAAA,QAChB;AAAA,MACF;AACA,iBAAW,UAAU,SAAS;AAC5B,YAAI,MAAM,KAAK,IAAI,OAAO,EAAE;AAC5B,YAAI,CAAC,KAAK;AACR,gBAAMC,UAAS,SAAS,cAAc,QAAQ;AAC9C,UAAAA,QAAO,OAAO;AACd,UAAAA,QAAO,QAAQ,eAAe;AAC9B,gBAAM,KAAK,OAAO;AAClB,UAAAA,QAAO,UAAU,MAAM;AACrB,kBAAM,UAAU,KAAK,IAAI,EAAE;AAC3B,gBAAI,SAAS;AACX,qBAAO,QAAQ,MAAM;AAAA,YACvB;AAAA,UACF;AACA,gBAAM;AAAA,YACJ,QAAQ,IAAI,OAAO,OAAO,IAAI,OAAO,aAAaA,OAAM;AAAA,YACxD;AAAA,UACF;AACA,cAAI,OAAO,MAAM,QAAQ,oBAAoB;AAC7C,eAAK,IAAI,OAAO,IAAI,GAAG;AAAA,QACzB;AACA,YAAI,SAAS;AACb,YAAI,OAAO,YAAY,OAAO,WAAW;AACzC,cAAM,YAAY,OAAO,OAAO,SAAS;AACzC,cAAM,OAAO,aAAaD,UAAS,aAAa,SAAS;AACzD,cAAM,SAAS,IAAI,OAAO;AAC1B,eAAO,aAAa,cAAc,OAAO,IAAI;AAC7C,eAAO,aAAa,gBAAgB,OAAO,OAAO,OAAO,QAAQ,CAAC;AAClE,eAAO,QAAQ,WAAW,OAAO,OAAO,OAAO,QAAQ;AACvD,eAAO,QAAQ,UAAU,OAAO,SAAS;AACzC,oBAAY,QAAQ,MAAM,MAAM;AAChC,cAAM,YACJA,UAAS,aAAa,SACrBA,UAAS,aAAa,cAAc;AACvC,YAAI,OAAO,SAAS,YAAY,OAAO,OAAO,MAAS;AACvD,YAAI,OAAO,MAAM,QAAQ,WAAW,OAAO,OAAO,OAAO,QAAQ;AACjE,mBAAW,IAAI,OAAO,OAAO;AAAA,UAC3B,QAAQ,YAAY,IAAI;AAAA,UACxB,YAAY;AAAA,UACZ;AAAA,QACF,CAAC;AAAA,MACH;AAEA,YAAM,SAAS,gBAAgB,MAAM;AACrC,YAAM,YAAY,CAAC,GAAG,KAAK,KAAK,CAAC,EAAE,KAAK,IAAI;AAC5C,UAAI,cAAc,WAAW;AAC3B,oBAAY;AACZ,eAAO,aAAa,IAAI;AACxB,eAAO;AAAA,UACL,CAAC,GAAG,KAAK,OAAO,CAAC,EAAE,IAAI,CAAC,EAAE,QAAAE,QAAO,MAAM,YAAYA,OAAM,CAAC;AAAA,QAC5D;AAAA,MACF;AAEA,UAAI,SAAS;AACX,YAAI,CAAC,YAAY;AACf,gBAAM,QAAQ,SAAS,cAAc,KAAK;AAC1C,gBAAM,aAAa,QAAQ,KAAK;AAChC,gBAAM,aAAa,cAAc,eAAe;AAChD,gBAAM,QAAQ,mBAAmB;AACjC,uBAAa,IAAI,OAAO,WAAW,SAAS,KAAK;AACjD,qBAAW,SAAS,KAAK;AACzB,qBAAW,OAAO,GAAG;AAAA,QACvB;AACA,mBAAW,YAAY,OAAO;AAC9B;AAAA,UACE,WAAW;AAAA,UACX,EAAE,MAAM,SAAS,OAAO,OAAO,iBAAiB;AAAA,UAChD;AAAA,QACF;AACA,mBAAW,WAAW,OAAO;AAAA,UAC3B,QAAQ,YAAY,EAAE,MAAM,SAAS,OAAO,GAAG,CAAC;AAAA,UAChD,YAAY;AAAA,UACZ;AAAA,QACF,CAAC;AAAA,MACH,OAAO;AACL,oBAAY,OAAO,IAAI;AACvB,qBAAa;AAAA,MACf;AAEA,UAAI,WAAW,SAAS;AACtB,0BAAU,IAAI,KAAK,SAAS,EAAE,IAAI,CAAC;AACnC,cAAM,WAAW;AAAA,UACf,MAAM,CAAC,SAAS,QAAQ,WAAW;AAAA,UACnC,aAAa,OAAO;AAAA,UACpB,eAAe;AAAA,UACf,cAAc;AAAA,UACd,WAAW;AAAA,QACb,CAAC;AAAA,MACH,OAAO;AACL,eAAO,OAAO,IAAI;AAClB,gBAAQ;AAAA,MACV;AAEA,YAAM,OAAO,QAAQ;AAAA,QACnB,UAAU;AAAA,QACV;AAAA,QACA;AAAA,QACA,SAASF,UAAS,WAAW;AAAA,QAC7B,SAASA,UAAS,WAAW;AAAA,MAC/B,CAAC;AAED,YAAM,QAAQ,KAAK,UAAU,QAAQ,IAAI;AACzC,YAAM,WAAW;AACjB,qBAAe;AACf,UAAI,UAAU,QAAQ;AACpB,iBAAS;AACT,YAAI,MAAM;AACR,cAAI,IAAI;AAAA,QACV;AAAA,MACF,OAAO;AACL,cAAM,SAAS,QAAQ,KAAK,CAAC,WAAW,OAAO,OAAO,QAAQ;AAC9D,YAAI,UAAU,aAAa,YAAY,CAAC,WAAW,CAACA,UAAS,SAAS;AACpE,cAAI,MAAM,OAAO,WAAW;AAAA,QAC9B;AAAA,MACF;AACA,YAAM,SAAS,WAAW,KAAK,IAAI,QAAQ,GAAG,SAAS;AACvD,UAAI,UAAU,aAAa,UAAU;AACnC,iBAAS,MAAM;AAAA,MACjB;AAAA,IACF;AAAA,IACA,OAAO,UAAuB;AAC5B,UAAI,MAAM,QAAQ;AAAA,IACpB;AAAA,IACA,UAAU;AACR,mBAAa,OAAO;AACpB,sBAAgB;AAChB,iBAAW,aAAa,IAAI;AAC5B,iBAAW,OAAO,IAAI;AACtB,iBAAW,EAAE,OAAO,KAAK,KAAK,OAAO,GAAG;AACtC,eAAO,OAAO,IAAI;AAClB,eAAO,QAAQ,UAAU;AAAA,MAC3B;AACA,WAAK,MAAM;AACX,kBAAY,OAAO,IAAI;AACvB,aAAO,OAAO,IAAI;AAClB,WAAK,MAAM,uBAAuB,GAAG;AACrC,cAAQ,gBAAgB;AAAA,IAC1B;AAAA,EACF;AACF;",
6
+ "names": ["key", "options", "button", "marker"]
7
7
  }
@@ -0,0 +1,53 @@
1
+ // src/branch-map/use-map-runtime.ts
2
+ import { useEffect, useRef, useState } from "react";
3
+ function useMapRuntime(browserKey, enabled, load, onLoadError) {
4
+ const element = useRef(null);
5
+ const [runtime, setRuntime] = useState();
6
+ const [failure, setFailure] = useState();
7
+ const [attempt, setAttempt] = useState(0);
8
+ const loader = useRef(load);
9
+ const reportError = useRef(onLoadError);
10
+ useEffect(() => {
11
+ loader.current = load;
12
+ reportError.current = onLoadError;
13
+ }, [load, onLoadError]);
14
+ useEffect(() => {
15
+ if (!browserKey || !element.current || !enabled) {
16
+ return;
17
+ }
18
+ let disposed = false;
19
+ const controller = new AbortController();
20
+ let instance;
21
+ const target = element.current;
22
+ setFailure(void 0);
23
+ void loader.current(target, browserKey, controller.signal).then((map) => {
24
+ instance = map;
25
+ if (disposed) {
26
+ map.destroy();
27
+ } else {
28
+ setRuntime(map);
29
+ }
30
+ }).catch((error) => {
31
+ if (!disposed) {
32
+ setFailure({ error });
33
+ reportError.current?.(error);
34
+ }
35
+ });
36
+ return () => {
37
+ disposed = true;
38
+ controller.abort();
39
+ instance?.destroy();
40
+ setRuntime(void 0);
41
+ };
42
+ }, [browserKey, enabled, attempt]);
43
+ return {
44
+ element,
45
+ runtime,
46
+ failure,
47
+ retry: () => setAttempt((count) => count + 1)
48
+ };
49
+ }
50
+ export {
51
+ useMapRuntime
52
+ };
53
+ //# sourceMappingURL=use-map-runtime.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../src/branch-map/use-map-runtime.ts"],
4
+ "sourcesContent": ["import { useEffect, useRef, useState } from \"react\";\n\n/** What a template gets to render when a map fails to load. */\nexport type MapLoadErrorState = {\n /** Why the map failed to load. */\n error: unknown;\n /** Load the map again. */\n retry: () => void;\n};\n\n/** Load a map runtime after mount, retrying on request and cleaning up on unmount. */\nexport function useMapRuntime<T extends { destroy(): void }>(\n browserKey: string | undefined,\n enabled: boolean,\n load: (target: HTMLElement, key: string, signal: AbortSignal) => Promise<T>,\n onLoadError: ((error: unknown) => void) | undefined,\n) {\n const element = useRef<HTMLDivElement>(null);\n const [runtime, setRuntime] = useState<T>();\n const [failure, setFailure] = useState<{ error: unknown }>();\n const [attempt, setAttempt] = useState(0);\n const loader = useRef(load);\n const reportError = useRef(onLoadError);\n useEffect(() => {\n loader.current = load;\n reportError.current = onLoadError;\n }, [load, onLoadError]);\n useEffect(() => {\n if (!browserKey || !element.current || !enabled) {\n return;\n }\n let disposed = false;\n const controller = new AbortController();\n let instance: T | undefined;\n const target = element.current;\n setFailure(undefined);\n void loader\n .current(target, browserKey, controller.signal)\n .then((map) => {\n instance = map;\n if (disposed) {\n map.destroy();\n } else {\n setRuntime(map);\n }\n })\n .catch((error: unknown) => {\n if (!disposed) {\n setFailure({ error });\n reportError.current?.(error);\n }\n });\n return () => {\n disposed = true;\n controller.abort();\n instance?.destroy();\n setRuntime(undefined);\n };\n }, [browserKey, enabled, attempt]);\n return {\n element,\n runtime,\n failure,\n retry: () => setAttempt((count) => count + 1),\n };\n}\n"],
5
+ "mappings": ";AAAA,SAAS,WAAW,QAAQ,gBAAgB;AAWrC,SAAS,cACd,YACA,SACA,MACA,aACA;AACA,QAAM,UAAU,OAAuB,IAAI;AAC3C,QAAM,CAAC,SAAS,UAAU,IAAI,SAAY;AAC1C,QAAM,CAAC,SAAS,UAAU,IAAI,SAA6B;AAC3D,QAAM,CAAC,SAAS,UAAU,IAAI,SAAS,CAAC;AACxC,QAAM,SAAS,OAAO,IAAI;AAC1B,QAAM,cAAc,OAAO,WAAW;AACtC,YAAU,MAAM;AACd,WAAO,UAAU;AACjB,gBAAY,UAAU;AAAA,EACxB,GAAG,CAAC,MAAM,WAAW,CAAC;AACtB,YAAU,MAAM;AACd,QAAI,CAAC,cAAc,CAAC,QAAQ,WAAW,CAAC,SAAS;AAC/C;AAAA,IACF;AACA,QAAI,WAAW;AACf,UAAM,aAAa,IAAI,gBAAgB;AACvC,QAAI;AACJ,UAAM,SAAS,QAAQ;AACvB,eAAW,MAAS;AACpB,SAAK,OACF,QAAQ,QAAQ,YAAY,WAAW,MAAM,EAC7C,KAAK,CAAC,QAAQ;AACb,iBAAW;AACX,UAAI,UAAU;AACZ,YAAI,QAAQ;AAAA,MACd,OAAO;AACL,mBAAW,GAAG;AAAA,MAChB;AAAA,IACF,CAAC,EACA,MAAM,CAAC,UAAmB;AACzB,UAAI,CAAC,UAAU;AACb,mBAAW,EAAE,MAAM,CAAC;AACpB,oBAAY,UAAU,KAAK;AAAA,MAC7B;AAAA,IACF,CAAC;AACH,WAAO,MAAM;AACX,iBAAW;AACX,iBAAW,MAAM;AACjB,gBAAU,QAAQ;AAClB,iBAAW,MAAS;AAAA,IACtB;AAAA,EACF,GAAG,CAAC,YAAY,SAAS,OAAO,CAAC;AACjC,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA;AAAA,IACA,OAAO,MAAM,WAAW,CAAC,UAAU,QAAQ,CAAC;AAAA,EAC9C;AACF;",
6
+ "names": []
7
+ }
@@ -19,15 +19,19 @@ var ignore = () => {
19
19
  function deferred(load) {
20
20
  let loaded;
21
21
  let pending;
22
+ const waiting = /* @__PURE__ */ new Set();
22
23
  const preload = () => {
23
24
  if (loaded) {
24
25
  return Promise.resolve(loaded);
25
26
  }
26
27
  if (!pending) {
27
- const attempt = new Promise((resolve) => resolve(load())).then(
28
- (value) => {
29
- loaded = component(value);
30
- return loaded;
28
+ const attempt = new Promise((resolve) => resolve(load())).then(component).then(
29
+ (result) => {
30
+ loaded = result;
31
+ for (const notify of waiting) {
32
+ notify(result);
33
+ }
34
+ return result;
31
35
  },
32
36
  (error) => {
33
37
  if (pending === attempt) {
@@ -58,33 +62,60 @@ function deferred(load) {
58
62
  const handle = window.setTimeout(start, options.timeout ?? 1);
59
63
  return () => window.clearTimeout(handle);
60
64
  };
61
- function DeferredComponent({ fallback = null, ...props }) {
65
+ function DeferredComponent({
66
+ fallback = null,
67
+ failed,
68
+ ...props
69
+ }) {
62
70
  const { open } = props;
63
71
  const [ready, setReady] = useState(() => loaded);
64
72
  const [opened, setOpened] = useState(open);
73
+ const [failure, setFailure] = useState(false);
74
+ const [retries, setRetries] = useState(0);
65
75
  if (open && !opened) {
66
76
  setOpened(true);
67
77
  }
68
78
  const Loaded = ready ?? loaded;
79
+ useEffect(() => {
80
+ if (Loaded) {
81
+ return;
82
+ }
83
+ const notify = (value) => setReady(() => value);
84
+ waiting.add(notify);
85
+ return () => {
86
+ waiting.delete(notify);
87
+ };
88
+ }, [Loaded]);
69
89
  useEffect(() => {
70
90
  if (!open || Loaded) {
71
91
  return;
72
92
  }
73
93
  let active = true;
74
- preload().then((value) => {
75
- if (active) {
76
- setReady(() => value);
94
+ setFailure(false);
95
+ preload().then(
96
+ (value) => {
97
+ if (active) {
98
+ setReady(() => value);
99
+ }
100
+ },
101
+ () => {
102
+ if (active) {
103
+ setFailure(true);
104
+ }
77
105
  }
78
- }, ignore);
106
+ );
79
107
  return () => {
80
108
  active = false;
81
109
  };
82
- }, [open, Loaded]);
110
+ }, [open, Loaded, retries]);
83
111
  if (!(open || opened)) {
84
112
  return null;
85
113
  }
86
114
  if (!Loaded) {
87
- return open ? /* @__PURE__ */ jsx(Fragment, { children: fallback }) : null;
115
+ if (!open) {
116
+ return null;
117
+ }
118
+ return /* @__PURE__ */ jsx(Fragment, { children: failure && failed ? failed(() => setRetries((count) => count + 1)) : fallback });
88
119
  }
89
120
  return /* @__PURE__ */ jsx(Loaded, { ...props });
90
121
  }