@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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  # @zappie/foundations
2
2
 
3
+ ## 0.1.6
4
+
5
+ ### Patch Changes
6
+
7
+ - a4872fd: A `deferred()` overlay whose code fails to load can recover while it is still open. Pass `failed={(retry) => ...}` to show a message and a retry button. A later `preload()`, such as the one the next tap on the trigger starts, also retries, and the overlay renders as soon as that load succeeds. Before, a failed load showed nothing, and because the overlay was still marked open, further taps never loaded it again.
8
+ - afb46d4: Add storefront security lint rules, pass outbound hosts from `zappie.config.ts` to ESLint, and provide `safeRedirect` from `@zappie/sdk/redirect` for untrusted navigation targets.
9
+ - Updated dependencies [2b672c9]
10
+ - Updated dependencies [9b5b412]
11
+ - Updated dependencies [32ecccf]
12
+ - Updated dependencies [afb46d4]
13
+ - Updated dependencies [6a8e227]
14
+ - Updated dependencies [c0171ae]
15
+ - Updated dependencies [0cbc4da]
16
+ - @zappie/sdk@0.1.12
17
+
18
+ ## 0.1.5
19
+
20
+ ### Patch Changes
21
+
22
+ - 282781d: Maps draw the markers the default storefront draws. `BranchMap` shows each branch as the `pinImageUrl` logo in a white ring, 40 px for the focused branch and 32 px for the rest. The focused branch is the selected one or, once the shopper's location is known, the nearest. A shopper location adds a house marker labelled “You” and a line to the focused branch, and the map frames the two with 88 px of padding. Nearby branches group into numbered clusters that zoom in when clicked, and selecting a branch inside a cluster zooms in until its pin shows. Without a shopper the map frames every branch with 64 px of padding, and a single branch is centred at zoom 14. `compact` frames a small map. Coordinates of (0, 0) count as missing. Colour clusters, the house marker, the line and label borders with `--map-accent` and `--map-accent-foreground`.
23
+
24
+ The new `DeliveryMap` shows a delivery for a cart or checkout: the branch logo, the shopper's house and the line between them, fitted to all of it, with no controls. Pass `route` to draw a path instead of a straight line. Both maps load after mount, and the clusterer loads only with the branch map.
25
+
26
+ - 51d437f: An overlay's first open on a page paints on the tap. The first open used to run the overlay engine's modal code for the first time before anything showed, which took 60 to 90 ms on a slow phone. Now the backdrop and the panel with its content paint first, and the engine takes over on the next frame. Focus handling, scroll lock, Escape, outside presses and hiding the page from assistive technology work as before once it does. Until then the panel is hidden from assistive technology and holds every key, press and scroll, so nothing on the page can be reached. Escape and an outside press still close it. Later opens, nested overlays and `forceMount` content use the engine straight away.
27
+ - 1c3cbbf: `ItemForm.Root` accepts a typed `onInvalid={(groups: ItemFormInvalidGroup[]) => ...}` callback. `ItemFormProps` no longer inherits the DOM `onInvalid` handler, and the root never calls `onInvalid` for a DOM `invalid` event.
28
+ - Updated dependencies [e7cc5f0]
29
+ - Updated dependencies [1c3cbbf]
30
+ - @zappie/sdk@0.1.9
31
+
3
32
  ## 0.1.4
4
33
 
5
34
  ### Patch Changes
package/README.md CHANGED
@@ -87,6 +87,11 @@ function Header() {
87
87
  - Pass `fallback` to hold an inline part's place while its code loads, for
88
88
  example a placeholder label. It shows only while `open` is true and the code
89
89
  is not in yet, and it never suspends.
90
+ - Pass `failed` to show something when the code fails to load, such as a
91
+ message with a button that calls `retry`. It shows only while `open` is true.
92
+ Without it nothing shows, so a shopper who taps and sees nothing never knows
93
+ to tap again. A later `preload()`, such as the one the next tap on the
94
+ trigger starts, also retries, and the component renders when it succeeds.
90
95
  - `preload()` starts the load and returns its promise. Calls share one load. A
91
96
  failed load is forgotten, so the next `preload()` or the next open tries again.
92
97
  - `preloadWhenIdle({ timeout? })` loads the code when the browser is idle and
@@ -98,6 +103,13 @@ function Header() {
98
103
  Importing the helper never touches `window`. On the server `Component` renders
99
104
  nothing, and it works under `StrictMode`.
100
105
 
106
+ The first overlay you open on a page paints on the tap. Its backdrop and panel,
107
+ with the content, show in the engine's markup straight away, and the overlay's
108
+ focus handling, scroll lock and dismissal take over on the next frame. Until
109
+ then the panel is hidden from assistive technology and takes no keys, presses or
110
+ scrolls, apart from Escape and an outside press, which close it. Style the panel
111
+ and `[data-zappie-backdrop]` as usual: both frames match them.
112
+
101
113
  ## Item form focus
102
114
 
103
115
  When an add fails validation, `ItemForm.Root` focuses the first invalid group
@@ -149,21 +161,36 @@ import { BranchMap } from "@zappie/foundations";
149
161
  />;
150
162
  ```
151
163
 
152
- Set a height on `.branch-map`. Theme pins with `--branch-map-pin`,
153
- `--branch-map-pin-selected`, `--branch-map-pin-border`, and
154
- `--branch-map-pin-size`. Pins expose `data-selected` and `aria-pressed`. Theme
155
- pin labels with `--branch-map-label-background`, `--branch-map-label-color` and
156
- `--branch-map-label-border`. Labels are marked `data-branch-map-pin-label` and
157
- hidden from assistive technology, because the pin already carries the name.
164
+ Set a height on `.branch-map`. With a `pinImageUrl`, such as the business logo,
165
+ each branch pin is the logo in a white ring: 32 px, or 40 px for the focused
166
+ branch. The focused branch is the selected one or, once a `shopperLocation` is
167
+ known, the nearest. Without a `pinImageUrl`, pins are dots themed with
168
+ `--branch-map-pin`, `--branch-map-pin-selected`, `--branch-map-pin-border` and
169
+ `--branch-map-pin-size`. Pins expose `data-selected`, `data-focused` and
170
+ `aria-pressed`. Theme pin labels with `--branch-map-label-background`,
171
+ `--branch-map-label-color` and `--branch-map-label-border`. Labels are marked
172
+ `data-branch-map-pin-label` and hidden from assistive technology, because the pin
173
+ already carries the name. `pinLabel="selected"` labels the focused pin.
174
+
175
+ With a `shopperLocation`, the map shows a house marker labelled “You” and draws a
176
+ line from it to the focused branch. Nearby branches group into numbered
177
+ clusters. Clicking a cluster zooms into it, and selecting a branch inside a
178
+ cluster zooms in until its pin shows. Colour the clusters, the house marker, the
179
+ line and the label borders with `--map-accent`, and the cluster count and house
180
+ icon with `--map-accent-foreground`. The map reads these when it updates, because
181
+ Maps draws clusters and lines without CSS.
158
182
 
159
183
  The map fits branches with valid coordinates, including across the date line.
160
- With a `shopperLocation`, it fits the shopper with the branches, with 88 px of
161
- padding. After that first fit, selecting another branch pans to it and keeps the
162
- zoom. The map fits again only when the branches, the shopper location or
163
- `maxZoom` change. Branches without coordinates remain selectable with “Map
164
- location unavailable”. `maxZoom` defaults to `15` with a shopper location and
165
- `14` without one. `mapTypeControl` defaults to `false`. Without a `pinImageUrl`,
166
- branch pins keep using the existing CSS variables.
184
+ Coordinates of (0, 0) count as missing. With a `shopperLocation`, it fits the
185
+ shopper and the focused branch with 88 px of padding, and fits again when the
186
+ focused branch changes. Without one, it fits every branch with 64 px of padding
187
+ and a new selection pans to that branch. A single point is centred at zoom 14.
188
+ `compact` frames a small map: the route gets 72 px of padding above, 52 px at
189
+ the sides and 36 px below, and a single point is centred at zoom 13. The map
190
+ fits again only when what it frames or `maxZoom` changes. Branches without
191
+ coordinates remain selectable with “Map location unavailable”. `maxZoom`
192
+ defaults to `15` with a shopper location and `14` without one. `mapTypeControl`
193
+ defaults to `false`.
167
194
 
168
195
  The component renders nothing without a key. It loads its map runtime and the
169
196
  Maps JavaScript API after mount, sharing one script across mounted maps.
@@ -172,3 +199,36 @@ With `renderLoadError({ error, retry })` you render the message, and `retry`
172
199
  loads the map again. Without it the map shows “Map location unavailable”.
173
200
  The optional location button pans to coordinates supplied by your app. Your app
174
201
  requests geolocation permission and supplies those coordinates.
202
+
203
+ ## Delivery map
204
+
205
+ `DeliveryMap` shows where an order goes: the branch logo marker, the shopper's
206
+ house marker and the line between them, fitted to all of it. Use it in a cart or
207
+ checkout in place of a static map image. It is a preview, so it has no controls
208
+ and ignores gestures. Pass `route`, such as a decoded driving route, to draw that
209
+ path instead of a straight line.
210
+
211
+ ```tsx
212
+ import { DeliveryMap } from "@zappie/foundations";
213
+
214
+ <DeliveryMap
215
+ branch={branch}
216
+ shopper={deliveryAddress.coordinates}
217
+ browserKey={integrations.googleMaps?.browserKey}
218
+ pinImageUrl={store.brand.logoUrl}
219
+ renderLoadError={({ retry }) => (
220
+ <button type="button" onClick={retry}>
221
+ Try again
222
+ </button>
223
+ )}
224
+ className="delivery-map"
225
+ />;
226
+ ```
227
+
228
+ Set a height on `.delivery-map`. The default storefront uses 180 px. The branch
229
+ marker is a 36 px logo, the house marker uses `--map-accent` and the line uses
230
+ `--delivery-map-route` at 60% opacity. Without a `pinImageUrl` the branch marker
231
+ is the themed dot. Like `BranchMap`, it renders nothing without a key, loads
232
+ Maps after mount, calls `onLoadError(error)` on a failure and shows “Map location
233
+ unavailable” when either point has no valid coordinates. It fits again when the
234
+ points, the route or its size change.
@@ -0,0 +1,27 @@
1
+ // src/branch-map/colors.ts
2
+ var DEFAULT_COLORS = {
3
+ accent: "#111111",
4
+ accentForeground: "#ffffff"
5
+ };
6
+ var COLOR_PROBE_STYLE = {
7
+ color: "var(--map-accent, currentColor)",
8
+ backgroundColor: "var(--map-accent-foreground, #ffffff)",
9
+ borderColor: "var(--delivery-map-route, #163300)"
10
+ };
11
+ function readColors(probe) {
12
+ if (!probe || typeof getComputedStyle !== "function") {
13
+ return DEFAULT_COLORS;
14
+ }
15
+ const style = getComputedStyle(probe);
16
+ return {
17
+ accent: style.color || DEFAULT_COLORS.accent,
18
+ accentForeground: style.backgroundColor || DEFAULT_COLORS.accentForeground,
19
+ route: style.borderTopColor || void 0
20
+ };
21
+ }
22
+ export {
23
+ COLOR_PROBE_STYLE,
24
+ DEFAULT_COLORS,
25
+ readColors
26
+ };
27
+ //# sourceMappingURL=colors.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../src/branch-map/colors.ts"],
4
+ "sourcesContent": ["/** Colours resolved from CSS, for map parts that cannot read CSS variables. */\nexport type MapColors = {\n accent: string;\n accentForeground: string;\n /** The delivery map's line. */\n route?: string | undefined;\n};\n\nexport const DEFAULT_COLORS: MapColors = {\n accent: \"#111111\",\n accentForeground: \"#ffffff\",\n};\n\n/** Inline style for the hidden element whose computed colours the map reads. */\nexport const COLOR_PROBE_STYLE = {\n color: \"var(--map-accent, currentColor)\",\n backgroundColor: \"var(--map-accent-foreground, #ffffff)\",\n borderColor: \"var(--delivery-map-route, #163300)\",\n} as const;\n\n// Cluster icons and lines are drawn by Maps, which cannot read CSS variables.\nexport function readColors(probe: HTMLElement | null): MapColors {\n if (!probe || typeof getComputedStyle !== \"function\") {\n return DEFAULT_COLORS;\n }\n const style = getComputedStyle(probe);\n return {\n accent: style.color || DEFAULT_COLORS.accent,\n accentForeground: style.backgroundColor || DEFAULT_COLORS.accentForeground,\n route: style.borderTopColor || undefined,\n };\n}\n"],
5
+ "mappings": ";AAQO,IAAM,iBAA4B;AAAA,EACvC,QAAQ;AAAA,EACR,kBAAkB;AACpB;AAGO,IAAM,oBAAoB;AAAA,EAC/B,OAAO;AAAA,EACP,iBAAiB;AAAA,EACjB,aAAa;AACf;AAGO,SAAS,WAAW,OAAsC;AAC/D,MAAI,CAAC,SAAS,OAAO,qBAAqB,YAAY;AACpD,WAAO;AAAA,EACT;AACA,QAAM,QAAQ,iBAAiB,KAAK;AACpC,SAAO;AAAA,IACL,QAAQ,MAAM,SAAS,eAAe;AAAA,IACtC,kBAAkB,MAAM,mBAAmB,eAAe;AAAA,IAC1D,OAAO,MAAM,kBAAkB;AAAA,EACjC;AACF;",
6
+ "names": []
7
+ }
@@ -0,0 +1,60 @@
1
+ "use client";
2
+
3
+ // src/branch-map/delivery-map.tsx
4
+ import { useEffect, useRef } from "react";
5
+ import { COLOR_PROBE_STYLE, readColors } from "./colors.js";
6
+ import { validCoordinates } from "./geometry.js";
7
+ import { useMapRuntime } from "./use-map-runtime.js";
8
+ import { jsx, jsxs } from "react/jsx-runtime";
9
+ var loadDeliveryMap = (target, key, signal) => import("./delivery-runtime.js").then(
10
+ ({ createDeliveryMap }) => createDeliveryMap(target, key, signal)
11
+ );
12
+ function DeliveryMap({
13
+ branch,
14
+ shopper,
15
+ route,
16
+ browserKey,
17
+ pinImageUrl,
18
+ onLoadError,
19
+ renderLoadError,
20
+ ...props
21
+ }) {
22
+ const probe = useRef(null);
23
+ const origin = validCoordinates(branch.coordinates) ? branch.coordinates : void 0;
24
+ const destination = validCoordinates(shopper) ? shopper : void 0;
25
+ const mappable = !!origin && !!destination;
26
+ const { element, runtime, failure, retry } = useMapRuntime(browserKey, mappable, loadDeliveryMap, onLoadError);
27
+ const failed = failure !== void 0;
28
+ const coordinates = branch.coordinates;
29
+ useEffect(() => {
30
+ if (runtime && validCoordinates(coordinates) && validCoordinates(shopper)) {
31
+ runtime.update({
32
+ branch: coordinates,
33
+ shopper,
34
+ route,
35
+ pinImageUrl,
36
+ colors: readColors(probe.current)
37
+ });
38
+ }
39
+ }, [runtime, coordinates, shopper, route, pinImageUrl]);
40
+ if (!browserKey) {
41
+ return null;
42
+ }
43
+ return /* @__PURE__ */ jsxs("div", { ...props, "data-delivery-map": "", children: [
44
+ /* @__PURE__ */ jsx("span", { ref: probe, hidden: true, style: COLOR_PROBE_STYLE }),
45
+ /* @__PURE__ */ jsx(
46
+ "div",
47
+ {
48
+ hidden: failed || !mappable,
49
+ ref: element,
50
+ "data-delivery-map-canvas": "",
51
+ style: { height: "100%" }
52
+ }
53
+ ),
54
+ failure && renderLoadError ? renderLoadError({ error: failure.error, retry }) : failed || !mappable ? /* @__PURE__ */ jsx("p", { role: "status", children: "Map location unavailable" }) : null
55
+ ] });
56
+ }
57
+ export {
58
+ DeliveryMap
59
+ };
60
+ //# sourceMappingURL=delivery-map.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../src/branch-map/delivery-map.tsx"],
4
+ "sourcesContent": ["\"use client\";\nimport type { Branch } from \"@zappie/sdk\";\nimport { useEffect, useRef, type HTMLAttributes, type ReactNode } from \"react\";\nimport { COLOR_PROBE_STYLE, readColors } from \"./colors\";\nimport type { createDeliveryMap } from \"./delivery-runtime\";\nimport { validCoordinates, type Coordinates } from \"./geometry\";\nimport { useMapRuntime, type MapLoadErrorState } from \"./use-map-runtime\";\n\n/** The branch, the shopper's address and an optional route for DeliveryMap. */\nexport type DeliveryMapProps = HTMLAttributes<HTMLDivElement> & {\n /** The branch the order leaves from. Only its coordinates are read. */\n branch: Pick<Branch, \"coordinates\">;\n /** Coordinates of the delivery address. */\n shopper?: Coordinates | undefined;\n /**\n * The path to draw, such as a decoded driving route, from the branch to the\n * shopper. Without it the map draws a straight line.\n */\n route?: Coordinates[];\n browserKey?: string;\n /** Image shown inside the branch marker, such as the business logo. */\n pinImageUrl?: string;\n /** Called each time the map fails to load. */\n onLoadError?: (error: unknown) => void;\n /**\n * Render your own message when the map fails to load, such as a retry\n * button that calls `retry`. Without it the map shows \"Map location unavailable\".\n */\n renderLoadError?: (state: MapLoadErrorState) => ReactNode;\n};\n\nconst loadDeliveryMap = (\n target: HTMLElement,\n key: string,\n signal: AbortSignal,\n) =>\n import(\"./delivery-runtime\").then(({ createDeliveryMap }) =>\n createDeliveryMap(target, key, signal),\n );\n\n/**\n * A static map of a delivery: the branch marker, the shopper's house marker\n * and the line between them, fitted to both. It has no controls and ignores\n * gestures. Maps loads after mount when you supply a browser key. Set the\n * container height with CSS. Colour the house marker with --map-accent and\n * the line with --delivery-map-route.\n */\nexport function DeliveryMap({\n branch,\n shopper,\n route,\n browserKey,\n pinImageUrl,\n onLoadError,\n renderLoadError,\n ...props\n}: DeliveryMapProps) {\n const probe = useRef<HTMLSpanElement>(null);\n const origin = validCoordinates(branch.coordinates)\n ? branch.coordinates\n : undefined;\n const destination = validCoordinates(shopper) ? shopper : undefined;\n const mappable = !!origin && !!destination;\n const { element, runtime, failure, retry } = useMapRuntime<\n Awaited<ReturnType<typeof createDeliveryMap>>\n >(browserKey, mappable, loadDeliveryMap, onLoadError);\n const failed = failure !== undefined;\n const coordinates = branch.coordinates;\n useEffect(() => {\n if (runtime && validCoordinates(coordinates) && validCoordinates(shopper)) {\n runtime.update({\n branch: coordinates,\n shopper,\n route,\n pinImageUrl,\n colors: readColors(probe.current),\n });\n }\n }, [runtime, coordinates, shopper, route, pinImageUrl]);\n if (!browserKey) {\n return null;\n }\n return (\n <div {...props} data-delivery-map=\"\">\n <span ref={probe} hidden style={COLOR_PROBE_STYLE} />\n <div\n hidden={failed || !mappable}\n ref={element}\n data-delivery-map-canvas=\"\"\n style={{ height: \"100%\" }}\n />\n {failure && renderLoadError ? (\n renderLoadError({ error: failure.error, retry })\n ) : failed || !mappable ? (\n <p role=\"status\">Map location unavailable</p>\n ) : null}\n </div>\n );\n}\n"],
5
+ "mappings": ";;;AAEA,SAAS,WAAW,cAAmD;AACvE,SAAS,mBAAmB,kBAAkB;AAE9C,SAAS,wBAA0C;AACnD,SAAS,qBAA6C;AA6ElD,SACE,KADF;AApDJ,IAAM,kBAAkB,CACtB,QACA,KACA,WAEA,OAAO,uBAAoB,EAAE;AAAA,EAAK,CAAC,EAAE,kBAAkB,MACrD,kBAAkB,QAAQ,KAAK,MAAM;AACvC;AASK,SAAS,YAAY;AAAA,EAC1B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,GAAG;AACL,GAAqB;AACnB,QAAM,QAAQ,OAAwB,IAAI;AAC1C,QAAM,SAAS,iBAAiB,OAAO,WAAW,IAC9C,OAAO,cACP;AACJ,QAAM,cAAc,iBAAiB,OAAO,IAAI,UAAU;AAC1D,QAAM,WAAW,CAAC,CAAC,UAAU,CAAC,CAAC;AAC/B,QAAM,EAAE,SAAS,SAAS,SAAS,MAAM,IAAI,cAE3C,YAAY,UAAU,iBAAiB,WAAW;AACpD,QAAM,SAAS,YAAY;AAC3B,QAAM,cAAc,OAAO;AAC3B,YAAU,MAAM;AACd,QAAI,WAAW,iBAAiB,WAAW,KAAK,iBAAiB,OAAO,GAAG;AACzE,cAAQ,OAAO;AAAA,QACb,QAAQ;AAAA,QACR;AAAA,QACA;AAAA,QACA;AAAA,QACA,QAAQ,WAAW,MAAM,OAAO;AAAA,MAClC,CAAC;AAAA,IACH;AAAA,EACF,GAAG,CAAC,SAAS,aAAa,SAAS,OAAO,WAAW,CAAC;AACtD,MAAI,CAAC,YAAY;AACf,WAAO;AAAA,EACT;AACA,SACE,qBAAC,SAAK,GAAG,OAAO,qBAAkB,IAChC;AAAA,wBAAC,UAAK,KAAK,OAAO,QAAM,MAAC,OAAO,mBAAmB;AAAA,IACnD;AAAA,MAAC;AAAA;AAAA,QACC,QAAQ,UAAU,CAAC;AAAA,QACnB,KAAK;AAAA,QACL,4BAAyB;AAAA,QACzB,OAAO,EAAE,QAAQ,OAAO;AAAA;AAAA,IAC1B;AAAA,IACC,WAAW,kBACV,gBAAgB,EAAE,OAAO,QAAQ,OAAO,MAAM,CAAC,IAC7C,UAAU,CAAC,WACb,oBAAC,OAAE,MAAK,UAAS,sCAAwB,IACvC;AAAA,KACN;AAEJ;",
6
+ "names": []
7
+ }
@@ -0,0 +1,118 @@
1
+ // src/branch-map/delivery-runtime.ts
2
+ import { pointBounds } from "./fit.js";
3
+ import { loadMaps } from "./loader.js";
4
+ import {
5
+ DEFAULT_COLORS,
6
+ markerOverlayClass,
7
+ paintMarker
8
+ } from "./markers.js";
9
+ var DELIVERY_ROUTE = {
10
+ color: "#163300",
11
+ opacity: 0.6,
12
+ weight: 5
13
+ };
14
+ var DELIVERY_LOGO_SIZE = 36;
15
+ function deliveryPath(update) {
16
+ const via = (update.route ?? []).filter(
17
+ (point) => Number.isFinite(point.lat) && Number.isFinite(point.lng)
18
+ );
19
+ return via.length >= 2 ? via : [update.branch, update.shopper];
20
+ }
21
+ function deliveryBounds(update) {
22
+ return pointBounds([update.branch, update.shopper, ...deliveryPath(update)]);
23
+ }
24
+ async function createDeliveryMap(element, key, signal) {
25
+ const maps = await loadMaps(key);
26
+ signal.throwIfAborted();
27
+ const map = new maps.Map(element, {
28
+ center: { lat: 0, lng: 0 },
29
+ zoom: 12,
30
+ gestureHandling: "none",
31
+ disableDefaultUI: true,
32
+ keyboardShortcuts: false,
33
+ clickableIcons: false
34
+ });
35
+ const Marker = markerOverlayClass(maps);
36
+ let branchPin;
37
+ let shopperPin;
38
+ let line;
39
+ let framed;
40
+ let last;
41
+ const pin = (key2, position) => {
42
+ const node = document.createElement("div");
43
+ node.setAttribute("aria-hidden", "true");
44
+ node.dataset.deliveryMapPin = key2;
45
+ const marker = new Marker(key2, position, node);
46
+ marker.setMap(map);
47
+ return marker;
48
+ };
49
+ const frame = (update) => {
50
+ map.fitBounds(deliveryBounds(update));
51
+ };
52
+ const resize = typeof ResizeObserver === "function" ? new ResizeObserver(() => {
53
+ if (last) {
54
+ frame(last);
55
+ }
56
+ }) : void 0;
57
+ resize?.observe(element);
58
+ return {
59
+ update(update) {
60
+ last = update;
61
+ const colors = update.colors ?? DEFAULT_COLORS;
62
+ branchPin ?? (branchPin = pin("branch", update.branch));
63
+ branchPin.setPosition(update.branch);
64
+ if (update.pinImageUrl) {
65
+ paintMarker(
66
+ branchPin.element,
67
+ {
68
+ kind: "logo",
69
+ size: DELIVERY_LOGO_SIZE,
70
+ imageUrl: update.pinImageUrl,
71
+ shadow: "sm",
72
+ lift: DELIVERY_LOGO_SIZE / 2
73
+ },
74
+ colors
75
+ );
76
+ } else {
77
+ paintMarker(branchPin.element, { kind: "dot", selected: true }, colors);
78
+ }
79
+ branchPin.element.style.cursor = "default";
80
+ shopperPin ?? (shopperPin = pin("shopper", update.shopper));
81
+ shopperPin.setPosition(update.shopper);
82
+ paintMarker(
83
+ shopperPin.element,
84
+ { kind: "house", color: "#ffffff", lift: 16 },
85
+ colors
86
+ );
87
+ line ?? (line = new maps.Polyline({ map }));
88
+ line.setOptions({
89
+ path: deliveryPath(update),
90
+ strokeColor: update.colors?.route ?? DELIVERY_ROUTE.color,
91
+ strokeOpacity: DELIVERY_ROUTE.opacity,
92
+ strokeWeight: DELIVERY_ROUTE.weight,
93
+ clickable: false
94
+ });
95
+ const key2 = JSON.stringify(deliveryBounds(update));
96
+ if (key2 !== framed) {
97
+ framed = key2;
98
+ frame(update);
99
+ }
100
+ },
101
+ destroy() {
102
+ resize?.disconnect();
103
+ branchPin?.setMap(null);
104
+ shopperPin?.setMap(null);
105
+ line?.setMap(null);
106
+ maps.event.clearInstanceListeners(map);
107
+ element.replaceChildren();
108
+ }
109
+ };
110
+ }
111
+ export {
112
+ DELIVERY_LOGO_SIZE,
113
+ DELIVERY_ROUTE,
114
+ createDeliveryMap,
115
+ deliveryBounds,
116
+ deliveryPath
117
+ };
118
+ //# sourceMappingURL=delivery-runtime.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../src/branch-map/delivery-runtime.ts"],
4
+ "sourcesContent": ["import { pointBounds } from \"./fit\";\nimport type { Coordinates } from \"./geometry\";\nimport { loadMaps } from \"./loader\";\nimport {\n DEFAULT_COLORS,\n markerOverlayClass,\n paintMarker,\n type MapColors,\n type MarkerOverlay,\n} from \"./markers\";\n\nexport type DeliveryMapUpdate = {\n branch: Coordinates;\n shopper: Coordinates;\n route?: Coordinates[] | undefined;\n pinImageUrl?: string | undefined;\n colors?: MapColors | undefined;\n};\n\n/** The line between the branch and the shopper. */\nexport const DELIVERY_ROUTE = {\n color: \"#163300\",\n opacity: 0.6,\n weight: 5,\n} as const;\n/** Diameter of the branch logo marker. */\nexport const DELIVERY_LOGO_SIZE = 36;\n\n/** The points the delivery map frames and the path it draws. */\nexport function deliveryPath(update: DeliveryMapUpdate) {\n const via = (update.route ?? []).filter(\n (point) => Number.isFinite(point.lat) && Number.isFinite(point.lng),\n );\n return via.length >= 2 ? via : [update.branch, update.shopper];\n}\n\nexport function deliveryBounds(update: DeliveryMapUpdate) {\n return pointBounds([update.branch, update.shopper, ...deliveryPath(update)])!;\n}\n\nexport async function createDeliveryMap(\n element: HTMLElement,\n key: string,\n signal: AbortSignal,\n) {\n const maps = await loadMaps(key);\n signal.throwIfAborted();\n // A static preview: no panning, zooming, dragging or controls.\n const map = new maps.Map(element, {\n center: { lat: 0, lng: 0 },\n zoom: 12,\n gestureHandling: \"none\",\n disableDefaultUI: true,\n keyboardShortcuts: false,\n clickableIcons: false,\n });\n const Marker = markerOverlayClass(maps);\n let branchPin: MarkerOverlay | undefined;\n let shopperPin: MarkerOverlay | undefined;\n let line: google.maps.Polyline | undefined;\n let framed: string | undefined;\n let last: DeliveryMapUpdate | undefined;\n const pin = (key: string, position: Coordinates) => {\n const node = document.createElement(\"div\");\n node.setAttribute(\"aria-hidden\", \"true\");\n node.dataset.deliveryMapPin = key;\n const marker = new Marker(key, position, node);\n marker.setMap(map);\n return marker;\n };\n const frame = (update: DeliveryMapUpdate) => {\n map.fitBounds(deliveryBounds(update));\n };\n const resize =\n typeof ResizeObserver === \"function\"\n ? new ResizeObserver(() => {\n if (last) {\n frame(last);\n }\n })\n : undefined;\n resize?.observe(element);\n return {\n update(update: DeliveryMapUpdate) {\n last = update;\n const colors = update.colors ?? DEFAULT_COLORS;\n branchPin ??= pin(\"branch\", update.branch);\n branchPin.setPosition(update.branch);\n if (update.pinImageUrl) {\n // The logo is the marker itself, so its centre sits half its size above the location.\n paintMarker(\n branchPin.element,\n {\n kind: \"logo\",\n size: DELIVERY_LOGO_SIZE,\n imageUrl: update.pinImageUrl,\n shadow: \"sm\",\n lift: DELIVERY_LOGO_SIZE / 2,\n },\n colors,\n );\n } else {\n paintMarker(branchPin.element, { kind: \"dot\", selected: true }, colors);\n }\n branchPin.element.style.cursor = \"default\";\n shopperPin ??= pin(\"shopper\", update.shopper);\n shopperPin.setPosition(update.shopper);\n paintMarker(\n shopperPin.element,\n { kind: \"house\", color: \"#ffffff\", lift: 16 },\n colors,\n );\n line ??= new maps.Polyline({ map });\n line.setOptions({\n path: deliveryPath(update),\n strokeColor: update.colors?.route ?? DELIVERY_ROUTE.color,\n strokeOpacity: DELIVERY_ROUTE.opacity,\n strokeWeight: DELIVERY_ROUTE.weight,\n clickable: false,\n });\n const key = JSON.stringify(deliveryBounds(update));\n if (key !== framed) {\n framed = key;\n frame(update);\n }\n },\n destroy() {\n resize?.disconnect();\n branchPin?.setMap(null);\n shopperPin?.setMap(null);\n line?.setMap(null);\n maps.event.clearInstanceListeners(map);\n element.replaceChildren();\n },\n };\n}\n"],
5
+ "mappings": "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",
6
+ "names": ["key"]
7
+ }
@@ -0,0 +1,123 @@
1
+ // src/branch-map/fit.ts
2
+ import {
3
+ validCoordinates,
4
+ visibleBranches
5
+ } from "./geometry.js";
6
+ var OVERVIEW_PADDING = 64;
7
+ var ROUTE_PADDING = {
8
+ top: 88,
9
+ right: 88,
10
+ bottom: 88,
11
+ left: 88
12
+ };
13
+ var COMPACT_ROUTE_PADDING = {
14
+ top: 72,
15
+ right: 52,
16
+ bottom: 36,
17
+ left: 52
18
+ };
19
+ var SINGLE_POINT_ZOOM = 14;
20
+ var COMPACT_SINGLE_POINT_ZOOM = 13;
21
+ function distanceKm(from, to) {
22
+ const radians = (degrees) => degrees * Math.PI / 180;
23
+ const dLat = radians(to.lat - from.lat);
24
+ const dLng = radians(to.lng - from.lng);
25
+ const a = Math.sin(dLat / 2) ** 2 + Math.cos(radians(from.lat)) * Math.cos(radians(to.lat)) * Math.sin(dLng / 2) ** 2;
26
+ return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
27
+ }
28
+ function focusedBranch(branches, selectedId, shopper) {
29
+ const visible = visibleBranches(branches);
30
+ const selected = visible.find((branch) => branch.id === selectedId);
31
+ if (selected || !validCoordinates(shopper)) {
32
+ return selected;
33
+ }
34
+ let nearest;
35
+ let best = Infinity;
36
+ for (const branch of visible) {
37
+ const distance = distanceKm(shopper, branch.coordinates);
38
+ if (distance < best) {
39
+ best = distance;
40
+ nearest = branch;
41
+ }
42
+ }
43
+ return nearest;
44
+ }
45
+ function clusterSize(count) {
46
+ return count < 10 ? 40 : count < 50 ? 48 : 56;
47
+ }
48
+ function pointBounds(points) {
49
+ if (!points.length) {
50
+ return void 0;
51
+ }
52
+ const wrapped = (lng) => (lng + 360) % 360;
53
+ const longitudes = points.map((point) => point.lng).sort((a, b) => wrapped(a) - wrapped(b));
54
+ let gap = -1;
55
+ let start = 0;
56
+ for (let index = 0; index < longitudes.length; index++) {
57
+ const next = wrapped(longitudes[(index + 1) % longitudes.length]) + (index === longitudes.length - 1 ? 360 : 0);
58
+ if (next - wrapped(longitudes[index]) > gap) {
59
+ gap = next - wrapped(longitudes[index]);
60
+ start = (index + 1) % longitudes.length;
61
+ }
62
+ }
63
+ return {
64
+ north: Math.max(...points.map((point) => point.lat)),
65
+ south: Math.min(...points.map((point) => point.lat)),
66
+ west: longitudes[start],
67
+ east: longitudes[(start + longitudes.length - 1) % longitudes.length]
68
+ };
69
+ }
70
+ function branchBounds(branches, location) {
71
+ const points = visibleBranches(branches).map((branch) => branch.coordinates);
72
+ if (validCoordinates(location)) {
73
+ points.push(location);
74
+ }
75
+ return pointBounds(points);
76
+ }
77
+ function fitPlan({
78
+ branches,
79
+ shopper,
80
+ focused,
81
+ maxZoom,
82
+ compact = false
83
+ }) {
84
+ const location = validCoordinates(shopper) ? shopper : void 0;
85
+ const points = location && focused ? [location, focused.coordinates] : [
86
+ ...location ? [location] : [],
87
+ ...visibleBranches(branches).map((branch) => branch.coordinates)
88
+ ];
89
+ if (!points.length) {
90
+ return void 0;
91
+ }
92
+ const route = !!(location && focused);
93
+ if (points.length === 1) {
94
+ return {
95
+ kind: "center",
96
+ center: points[0],
97
+ zoom: Math.min(
98
+ maxZoom,
99
+ compact ? COMPACT_SINGLE_POINT_ZOOM : SINGLE_POINT_ZOOM
100
+ )
101
+ };
102
+ }
103
+ return {
104
+ kind: "bounds",
105
+ bounds: pointBounds(points),
106
+ padding: route ? compact ? COMPACT_ROUTE_PADDING : ROUTE_PADDING : OVERVIEW_PADDING,
107
+ maxZoom
108
+ };
109
+ }
110
+ export {
111
+ COMPACT_ROUTE_PADDING,
112
+ COMPACT_SINGLE_POINT_ZOOM,
113
+ OVERVIEW_PADDING,
114
+ ROUTE_PADDING,
115
+ SINGLE_POINT_ZOOM,
116
+ branchBounds,
117
+ clusterSize,
118
+ distanceKm,
119
+ fitPlan,
120
+ focusedBranch,
121
+ pointBounds
122
+ };
123
+ //# sourceMappingURL=fit.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../src/branch-map/fit.ts"],
4
+ "sourcesContent": ["import type { Branch } from \"@zappie/sdk\";\nimport {\n validCoordinates,\n visibleBranches,\n type Bounds,\n type Coordinates,\n} from \"./geometry\";\n\nexport type Padding =\n number | { top: number; right: number; bottom: number; left: number };\n\n/** Padding around every fitted point when no route is drawn. */\nexport const OVERVIEW_PADDING = 64;\n/** Padding around the shopper-to-branch route. */\nexport const ROUTE_PADDING: Padding = {\n top: 88,\n right: 88,\n bottom: 88,\n left: 88,\n};\n/** Route padding in a compact map, with room for both pin labels. */\nexport const COMPACT_ROUTE_PADDING: Padding = {\n top: 72,\n right: 52,\n bottom: 36,\n left: 52,\n};\n/** Zoom for a single point, in a full and a compact map. */\nexport const SINGLE_POINT_ZOOM = 14;\nexport const COMPACT_SINGLE_POINT_ZOOM = 13;\n\n/** Great-circle distance in kilometres. */\nexport function distanceKm(from: Coordinates, to: Coordinates) {\n const radians = (degrees: number) => (degrees * Math.PI) / 180;\n const dLat = radians(to.lat - from.lat);\n const dLng = radians(to.lng - from.lng);\n const a =\n Math.sin(dLat / 2) ** 2 +\n Math.cos(radians(from.lat)) *\n Math.cos(radians(to.lat)) *\n Math.sin(dLng / 2) ** 2;\n return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));\n}\n\n/**\n * The branch the map highlights: the selected one, or, once the shopper's\n * location is known, the nearest branch.\n */\nexport function focusedBranch(\n branches: Branch[],\n selectedId: string | undefined,\n shopper: Coordinates | undefined,\n) {\n const visible = visibleBranches(branches);\n const selected = visible.find((branch) => branch.id === selectedId);\n if (selected || !validCoordinates(shopper)) {\n return selected;\n }\n let nearest: (typeof visible)[number] | undefined;\n let best = Infinity;\n for (const branch of visible) {\n const distance = distanceKm(shopper, branch.coordinates);\n if (distance < best) {\n best = distance;\n nearest = branch;\n }\n }\n return nearest;\n}\n\n/** Diameter of a cluster icon for the number of branches it holds. */\nexport function clusterSize(count: number) {\n return count < 10 ? 40 : count < 50 ? 48 : 56;\n}\n\nexport function pointBounds(points: Coordinates[]): Bounds | undefined {\n if (!points.length) {\n return undefined;\n }\n // The largest longitude gap lies outside the bounds, including across the date line.\n // Sort by the wrapped longitude but report the original values, so they stay exact.\n const wrapped = (lng: number) => (lng + 360) % 360;\n const longitudes = points\n .map((point) => point.lng)\n .sort((a, b) => wrapped(a) - wrapped(b));\n let gap = -1;\n let start = 0;\n for (let index = 0; index < longitudes.length; index++) {\n const next =\n wrapped(longitudes[(index + 1) % longitudes.length]!) +\n (index === longitudes.length - 1 ? 360 : 0);\n if (next - wrapped(longitudes[index]!) > gap) {\n gap = next - wrapped(longitudes[index]!);\n start = (index + 1) % longitudes.length;\n }\n }\n return {\n north: Math.max(...points.map((point) => point.lat)),\n south: Math.min(...points.map((point) => point.lat)),\n west: longitudes[start]!,\n east: longitudes[(start + longitudes.length - 1) % longitudes.length]!,\n };\n}\n\nexport function branchBounds(branches: Branch[], location?: Coordinates) {\n const points = visibleBranches(branches).map((branch) => branch.coordinates);\n if (validCoordinates(location)) {\n points.push(location);\n }\n return pointBounds(points);\n}\n\n/** How the map frames its points. */\nexport type FitPlan =\n | { kind: \"center\"; center: Coordinates; zoom: number }\n | { kind: \"bounds\"; bounds: Bounds; padding: Padding; maxZoom: number };\n\n/**\n * With a route, frame the shopper and the focused branch. Otherwise frame the\n * shopper and every branch. A single point is centred at a fixed zoom.\n */\nexport function fitPlan({\n branches,\n shopper,\n focused,\n maxZoom,\n compact = false,\n}: {\n branches: Branch[];\n shopper?: Coordinates | undefined;\n focused?: (Branch & { coordinates: Coordinates }) | undefined;\n maxZoom: number;\n compact?: boolean;\n}): FitPlan | undefined {\n const location = validCoordinates(shopper) ? shopper : undefined;\n const points =\n location && focused\n ? [location, focused.coordinates]\n : [\n ...(location ? [location] : []),\n ...visibleBranches(branches).map((branch) => branch.coordinates),\n ];\n if (!points.length) {\n return undefined;\n }\n const route = !!(location && focused);\n if (points.length === 1) {\n return {\n kind: \"center\",\n center: points[0]!,\n zoom: Math.min(\n maxZoom,\n compact ? COMPACT_SINGLE_POINT_ZOOM : SINGLE_POINT_ZOOM,\n ),\n };\n }\n return {\n kind: \"bounds\",\n bounds: pointBounds(points)!,\n padding: route\n ? compact\n ? COMPACT_ROUTE_PADDING\n : ROUTE_PADDING\n : OVERVIEW_PADDING,\n maxZoom,\n };\n}\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
@@ -4,39 +4,10 @@ function validCoordinates(value) {
4
4
  }
5
5
  function visibleBranches(branches) {
6
6
  return branches.filter(
7
- (branch) => validCoordinates(branch.coordinates)
7
+ (branch) => validCoordinates(branch.coordinates) && (branch.coordinates.lat !== 0 || branch.coordinates.lng !== 0)
8
8
  );
9
9
  }
10
- function branchBounds(branches, location) {
11
- const points = visibleBranches(branches).map((branch) => branch.coordinates);
12
- if (validCoordinates(location)) {
13
- points.push(location);
14
- }
15
- if (!points.length) {
16
- return void 0;
17
- }
18
- const longitudes = points.map((point) => (point.lng + 360) % 360).sort((a, b) => a - b);
19
- let gap = -1;
20
- let start = 0;
21
- for (let index = 0; index < longitudes.length; index++) {
22
- const next = longitudes[(index + 1) % longitudes.length] + (index === longitudes.length - 1 ? 360 : 0);
23
- if (next - longitudes[index] > gap) {
24
- gap = next - longitudes[index];
25
- start = (index + 1) % longitudes.length;
26
- }
27
- }
28
- const normalize = (lng) => lng > 180 ? lng - 360 : lng;
29
- return {
30
- north: Math.max(...points.map((point) => point.lat)),
31
- south: Math.min(...points.map((point) => point.lat)),
32
- west: normalize(longitudes[start]),
33
- east: normalize(
34
- longitudes[(start + longitudes.length - 1) % longitudes.length]
35
- )
36
- };
37
- }
38
10
  export {
39
- branchBounds,
40
11
  validCoordinates,
41
12
  visibleBranches
42
13
  };
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/branch-map/geometry.ts"],
4
- "sourcesContent": ["import type { Branch } from \"@zappie/sdk\";\n\nexport type Coordinates = { lat: number; lng: number };\nexport function validCoordinates(value?: Coordinates): value is Coordinates {\n return (\n !!value &&\n Number.isFinite(value.lat) &&\n Number.isFinite(value.lng) &&\n Math.abs(value.lat) <= 90 &&\n Math.abs(value.lng) <= 180\n );\n}\nexport function visibleBranches(branches: Branch[]) {\n return branches.filter(\n (branch): branch is Branch & { coordinates: Coordinates } =>\n validCoordinates(branch.coordinates),\n );\n}\nexport function branchBounds(branches: Branch[], location?: Coordinates) {\n const points = visibleBranches(branches).map((branch) => branch.coordinates);\n if (validCoordinates(location)) {\n points.push(location);\n }\n if (!points.length) {\n return undefined;\n }\n // The largest longitude gap lies outside the bounds, including across the date line.\n const longitudes = points\n .map((point) => (point.lng + 360) % 360)\n .sort((a, b) => a - b);\n let gap = -1;\n let start = 0;\n for (let index = 0; index < longitudes.length; index++) {\n const next =\n longitudes[(index + 1) % longitudes.length]! +\n (index === longitudes.length - 1 ? 360 : 0);\n if (next - longitudes[index]! > gap) {\n gap = next - longitudes[index]!;\n start = (index + 1) % longitudes.length;\n }\n }\n const normalize = (lng: number) => (lng > 180 ? lng - 360 : lng);\n return {\n north: Math.max(...points.map((point) => point.lat)),\n south: Math.min(...points.map((point) => point.lat)),\n west: normalize(longitudes[start]!),\n east: normalize(\n longitudes[(start + longitudes.length - 1) % longitudes.length]!,\n ),\n };\n}\n"],
5
- "mappings": ";AAGO,SAAS,iBAAiB,OAA2C;AAC1E,SACE,CAAC,CAAC,SACF,OAAO,SAAS,MAAM,GAAG,KACzB,OAAO,SAAS,MAAM,GAAG,KACzB,KAAK,IAAI,MAAM,GAAG,KAAK,MACvB,KAAK,IAAI,MAAM,GAAG,KAAK;AAE3B;AACO,SAAS,gBAAgB,UAAoB;AAClD,SAAO,SAAS;AAAA,IACd,CAAC,WACC,iBAAiB,OAAO,WAAW;AAAA,EACvC;AACF;AACO,SAAS,aAAa,UAAoB,UAAwB;AACvE,QAAM,SAAS,gBAAgB,QAAQ,EAAE,IAAI,CAAC,WAAW,OAAO,WAAW;AAC3E,MAAI,iBAAiB,QAAQ,GAAG;AAC9B,WAAO,KAAK,QAAQ;AAAA,EACtB;AACA,MAAI,CAAC,OAAO,QAAQ;AAClB,WAAO;AAAA,EACT;AAEA,QAAM,aAAa,OAChB,IAAI,CAAC,WAAW,MAAM,MAAM,OAAO,GAAG,EACtC,KAAK,CAAC,GAAG,MAAM,IAAI,CAAC;AACvB,MAAI,MAAM;AACV,MAAI,QAAQ;AACZ,WAAS,QAAQ,GAAG,QAAQ,WAAW,QAAQ,SAAS;AACtD,UAAM,OACJ,YAAY,QAAQ,KAAK,WAAW,MAAM,KACzC,UAAU,WAAW,SAAS,IAAI,MAAM;AAC3C,QAAI,OAAO,WAAW,KAAK,IAAK,KAAK;AACnC,YAAM,OAAO,WAAW,KAAK;AAC7B,eAAS,QAAQ,KAAK,WAAW;AAAA,IACnC;AAAA,EACF;AACA,QAAM,YAAY,CAAC,QAAiB,MAAM,MAAM,MAAM,MAAM;AAC5D,SAAO;AAAA,IACL,OAAO,KAAK,IAAI,GAAG,OAAO,IAAI,CAAC,UAAU,MAAM,GAAG,CAAC;AAAA,IACnD,OAAO,KAAK,IAAI,GAAG,OAAO,IAAI,CAAC,UAAU,MAAM,GAAG,CAAC;AAAA,IACnD,MAAM,UAAU,WAAW,KAAK,CAAE;AAAA,IAClC,MAAM;AAAA,MACJ,YAAY,QAAQ,WAAW,SAAS,KAAK,WAAW,MAAM;AAAA,IAChE;AAAA,EACF;AACF;",
4
+ "sourcesContent": ["import type { Branch } from \"@zappie/sdk\";\n\nexport type Coordinates = { lat: number; lng: number };\nexport type Bounds = {\n north: number;\n south: number;\n east: number;\n west: number;\n};\n\nexport function validCoordinates(value?: Coordinates): value is Coordinates {\n return (\n !!value &&\n Number.isFinite(value.lat) &&\n Number.isFinite(value.lng) &&\n Math.abs(value.lat) <= 90 &&\n Math.abs(value.lng) <= 180\n );\n}\n\n/** Branches with a real location. (0, 0) is the placeholder for \"no location on file\". */\nexport function visibleBranches(branches: Branch[]) {\n return branches.filter(\n (branch): branch is Branch & { coordinates: Coordinates } =>\n validCoordinates(branch.coordinates) &&\n (branch.coordinates.lat !== 0 || branch.coordinates.lng !== 0),\n );\n}\n"],
5
+ "mappings": ";AAUO,SAAS,iBAAiB,OAA2C;AAC1E,SACE,CAAC,CAAC,SACF,OAAO,SAAS,MAAM,GAAG,KACzB,OAAO,SAAS,MAAM,GAAG,KACzB,KAAK,IAAI,MAAM,GAAG,KAAK,MACvB,KAAK,IAAI,MAAM,GAAG,KAAK;AAE3B;AAGO,SAAS,gBAAgB,UAAoB;AAClD,SAAO,SAAS;AAAA,IACd,CAAC,WACC,iBAAiB,OAAO,WAAW,MAClC,OAAO,YAAY,QAAQ,KAAK,OAAO,YAAY,QAAQ;AAAA,EAChE;AACF;",
6
6
  "names": []
7
7
  }