@zappie/foundations 0.1.3 → 0.1.5
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 +24 -0
- package/README.md +154 -7
- package/dist/branch-map/colors.js +27 -0
- package/dist/branch-map/colors.js.map +7 -0
- package/dist/branch-map/delivery-map.js +60 -0
- package/dist/branch-map/delivery-map.js.map +7 -0
- package/dist/branch-map/delivery-runtime.js +118 -0
- package/dist/branch-map/delivery-runtime.js.map +7 -0
- package/dist/branch-map/fit.js +123 -0
- package/dist/branch-map/fit.js.map +7 -0
- package/dist/branch-map/geometry.js +1 -27
- package/dist/branch-map/geometry.js.map +2 -2
- package/dist/branch-map/index.js +42 -36
- package/dist/branch-map/index.js.map +2 -2
- package/dist/branch-map/markers.js +143 -0
- package/dist/branch-map/markers.js.map +7 -0
- package/dist/branch-map/runtime.js +258 -53
- package/dist/branch-map/runtime.js.map +3 -3
- package/dist/branch-map/use-map-runtime.js +53 -0
- package/dist/branch-map/use-map-runtime.js.map +7 -0
- package/dist/deferred/index.js +111 -0
- package/dist/deferred/index.js.map +7 -0
- package/dist/index.d.ts +179 -8
- package/dist/index.js +5 -1
- package/dist/index.js.map +2 -2
- package/dist/item-form/index.js.map +1 -1
- package/dist/item-form/root.js +25 -8
- package/dist/item-form/root.js.map +2 -2
- package/dist/overlay/react.js +245 -24
- package/dist/overlay/react.js.map +3 -3
- package/dist/version.js +1 -1
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# @zappie/foundations
|
|
2
2
|
|
|
3
|
+
## 0.1.5
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 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`.
|
|
8
|
+
|
|
9
|
+
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.
|
|
10
|
+
|
|
11
|
+
- 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.
|
|
12
|
+
- 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.
|
|
13
|
+
- Updated dependencies [e7cc5f0]
|
|
14
|
+
- Updated dependencies [1c3cbbf]
|
|
15
|
+
- @zappie/sdk@0.1.9
|
|
16
|
+
|
|
17
|
+
## 0.1.4
|
|
18
|
+
|
|
19
|
+
### Patch Changes
|
|
20
|
+
|
|
21
|
+
- 4f3cdc3: Add `deferred()` and `useIntentPreload()`, which load an overlay's code on demand and render it without suspending, so a first open no longer waits for React's 300 ms reveal. `BranchMap` now fits the shopper's location with the branches, pans to a newly selected branch instead of fitting again, reports load failures through `onLoadError` and `renderLoadError` with a retry, and can label pins with the branch name. `ItemForm.Root` focuses the first invalid group without scrolling and reports the invalid groups through `onInvalid`.
|
|
22
|
+
- e1f4cf0: `Overlay` prefetches its engine once the page has loaded and the browser is idle, without waiting for an `Overlay.Root` to mount. An overlay loaded with `deferred()` mounts its Root only on the tap, so its first open used to wait for the engine's download as well.
|
|
23
|
+
- 3d56337: Add a fitted-zoom limit, image pins, and an optional map and satellite switch to `BranchMap`.
|
|
24
|
+
- Updated dependencies [4f3cdc3]
|
|
25
|
+
- @zappie/sdk@0.1.8
|
|
26
|
+
|
|
3
27
|
## 0.1.3
|
|
4
28
|
|
|
5
29
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -50,11 +50,86 @@ Foundations is at `0.x`. Its API can change in a minor release while it settles.
|
|
|
50
50
|
|
|
51
51
|
MIT
|
|
52
52
|
|
|
53
|
+
## Deferred overlays
|
|
54
|
+
|
|
55
|
+
Keep a bag, branch finder or search dialog off the first load without `React.lazy`.
|
|
56
|
+
React holds a suspended boundary's first reveal for about 300 ms, so a lazy overlay
|
|
57
|
+
opens late. `deferred()` loads the code on demand and renders on the frame it arrives.
|
|
58
|
+
It never suspends.
|
|
59
|
+
|
|
60
|
+
```tsx
|
|
61
|
+
import { deferred, useIntentPreload } from "@zappie/foundations";
|
|
62
|
+
|
|
63
|
+
const BagSheet = deferred(() => import("./bag-sheet"));
|
|
64
|
+
// Or a named export: deferred(() => import("./finder").then((m) => m.Finder))
|
|
65
|
+
|
|
66
|
+
function Header() {
|
|
67
|
+
const [open, setOpen] = useState(false);
|
|
68
|
+
const intent = useIntentPreload(BagSheet);
|
|
69
|
+
return (
|
|
70
|
+
<>
|
|
71
|
+
<button {...intent} onClick={() => setOpen(true)}>
|
|
72
|
+
Bag
|
|
73
|
+
</button>
|
|
74
|
+
<BagSheet.Component open={open} onClose={() => setOpen(false)} />
|
|
75
|
+
</>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
- `deferred(load)` takes a function that imports a module whose default export
|
|
81
|
+
is the component, or that resolves to the component. Create it once at module
|
|
82
|
+
scope.
|
|
83
|
+
- `Component` renders nothing until `open` is first true and the code is in.
|
|
84
|
+
When `open` turns true it loads the code and renders as soon as it arrives.
|
|
85
|
+
After that first open the component stays mounted and receives every prop,
|
|
86
|
+
`open` included, so it can animate out and return focus when it closes.
|
|
87
|
+
- Pass `fallback` to hold an inline part's place while its code loads, for
|
|
88
|
+
example a placeholder label. It shows only while `open` is true and the code
|
|
89
|
+
is not in yet, and it never suspends.
|
|
90
|
+
- `preload()` starts the load and returns its promise. Calls share one load. A
|
|
91
|
+
failed load is forgotten, so the next `preload()` or the next open tries again.
|
|
92
|
+
- `preloadWhenIdle({ timeout? })` loads the code when the browser is idle and
|
|
93
|
+
returns a function that cancels it. Use it only for small chunks. It does
|
|
94
|
+
nothing on the server.
|
|
95
|
+
- `useIntentPreload(target)` returns `{ onPointerEnter, onPointerDown, onFocus }`
|
|
96
|
+
for the trigger, so the code is usually in by the click.
|
|
97
|
+
|
|
98
|
+
Importing the helper never touches `window`. On the server `Component` renders
|
|
99
|
+
nothing, and it works under `StrictMode`.
|
|
100
|
+
|
|
101
|
+
The first overlay you open on a page paints on the tap. Its backdrop and panel,
|
|
102
|
+
with the content, show in the engine's markup straight away, and the overlay's
|
|
103
|
+
focus handling, scroll lock and dismissal take over on the next frame. Until
|
|
104
|
+
then the panel is hidden from assistive technology and takes no keys, presses or
|
|
105
|
+
scrolls, apart from Escape and an outside press, which close it. Style the panel
|
|
106
|
+
and `[data-zappie-backdrop]` as usual: both frames match them.
|
|
107
|
+
|
|
108
|
+
## Item form focus
|
|
109
|
+
|
|
110
|
+
When an add fails validation, `ItemForm.Root` focuses the first invalid group
|
|
111
|
+
with `preventScroll`, so the page doesn't jump. Pass `onInvalid(groups)` to
|
|
112
|
+
scroll it into view yourself. `groups` lists the invalid groups in display
|
|
113
|
+
order, each with its `groupId`, its `group` (undefined for a group the
|
|
114
|
+
template renders from its own model), its `element` and its `issues`.
|
|
115
|
+
|
|
116
|
+
```tsx
|
|
117
|
+
<ItemForm.Root
|
|
118
|
+
item={item}
|
|
119
|
+
onInvalid={(groups) =>
|
|
120
|
+
groups[0]?.element?.scrollIntoView({ behavior: "smooth", block: "center" })
|
|
121
|
+
}
|
|
122
|
+
>
|
|
123
|
+
```
|
|
124
|
+
|
|
53
125
|
## Branch map
|
|
54
126
|
|
|
55
127
|
`BranchMap` accepts SDK `Branch[]` data, `selectedBranchId`, `onSelect(branch)`,
|
|
56
|
-
`browserKey`, and optional `shopperLocation: { lat, lng }`.
|
|
57
|
-
|
|
128
|
+
`browserKey`, and optional `shopperLocation: { lat, lng }`. Use `maxZoom` to
|
|
129
|
+
limit the zoom after fitting, `pinImageUrl` to show an image in each branch pin,
|
|
130
|
+
`mapTypeControl` to show the map and satellite switch, and `pinLabel` to show
|
|
131
|
+
the branch name above the selected pin (`"selected"`) or every pin (`"all"`).
|
|
132
|
+
It also accepts standard div attributes such as `className` and `style`.
|
|
58
133
|
|
|
59
134
|
```tsx
|
|
60
135
|
import { BranchMap } from "@zappie/foundations";
|
|
@@ -65,18 +140,90 @@ import { BranchMap } from "@zappie/foundations";
|
|
|
65
140
|
onSelect={(branch) => setSelectedBranchId(branch.id)}
|
|
66
141
|
browserKey={integrations.googleMaps?.browserKey}
|
|
67
142
|
shopperLocation={shopperLocation}
|
|
143
|
+
pinImageUrl={store.brand.logoUrl}
|
|
144
|
+
pinLabel="selected"
|
|
145
|
+
mapTypeControl
|
|
146
|
+
onLoadError={(error) => report(error)}
|
|
147
|
+
renderLoadError={({ retry }) => (
|
|
148
|
+
<div role="status">
|
|
149
|
+
<p>Map didn't load</p>
|
|
150
|
+
<button type="button" onClick={retry}>
|
|
151
|
+
Try again
|
|
152
|
+
</button>
|
|
153
|
+
</div>
|
|
154
|
+
)}
|
|
68
155
|
className="branch-map"
|
|
69
156
|
/>;
|
|
70
157
|
```
|
|
71
158
|
|
|
72
|
-
Set a height on `.branch-map`.
|
|
73
|
-
|
|
74
|
-
|
|
159
|
+
Set a height on `.branch-map`. With a `pinImageUrl`, such as the business logo,
|
|
160
|
+
each branch pin is the logo in a white ring: 32 px, or 40 px for the focused
|
|
161
|
+
branch. The focused branch is the selected one or, once a `shopperLocation` is
|
|
162
|
+
known, the nearest. Without a `pinImageUrl`, pins are dots themed with
|
|
163
|
+
`--branch-map-pin`, `--branch-map-pin-selected`, `--branch-map-pin-border` and
|
|
164
|
+
`--branch-map-pin-size`. Pins expose `data-selected`, `data-focused` and
|
|
165
|
+
`aria-pressed`. Theme pin labels with `--branch-map-label-background`,
|
|
166
|
+
`--branch-map-label-color` and `--branch-map-label-border`. Labels are marked
|
|
167
|
+
`data-branch-map-pin-label` and hidden from assistive technology, because the pin
|
|
168
|
+
already carries the name. `pinLabel="selected"` labels the focused pin.
|
|
169
|
+
|
|
170
|
+
With a `shopperLocation`, the map shows a house marker labelled “You” and draws a
|
|
171
|
+
line from it to the focused branch. Nearby branches group into numbered
|
|
172
|
+
clusters. Clicking a cluster zooms into it, and selecting a branch inside a
|
|
173
|
+
cluster zooms in until its pin shows. Colour the clusters, the house marker, the
|
|
174
|
+
line and the label borders with `--map-accent`, and the cluster count and house
|
|
175
|
+
icon with `--map-accent-foreground`. The map reads these when it updates, because
|
|
176
|
+
Maps draws clusters and lines without CSS.
|
|
177
|
+
|
|
75
178
|
The map fits branches with valid coordinates, including across the date line.
|
|
76
|
-
|
|
179
|
+
Coordinates of (0, 0) count as missing. With a `shopperLocation`, it fits the
|
|
180
|
+
shopper and the focused branch with 88 px of padding, and fits again when the
|
|
181
|
+
focused branch changes. Without one, it fits every branch with 64 px of padding
|
|
182
|
+
and a new selection pans to that branch. A single point is centred at zoom 14.
|
|
183
|
+
`compact` frames a small map: the route gets 72 px of padding above, 52 px at
|
|
184
|
+
the sides and 36 px below, and a single point is centred at zoom 13. The map
|
|
185
|
+
fits again only when what it frames or `maxZoom` changes. Branches without
|
|
186
|
+
coordinates remain selectable with “Map location unavailable”. `maxZoom`
|
|
187
|
+
defaults to `15` with a shopper location and `14` without one. `mapTypeControl`
|
|
188
|
+
defaults to `false`.
|
|
77
189
|
|
|
78
190
|
The component renders nothing without a key. It loads its map runtime and the
|
|
79
191
|
Maps JavaScript API after mount, sharing one script across mounted maps.
|
|
80
|
-
Use one browser key per page.
|
|
192
|
+
Use one browser key per page. A loading failure calls `onLoadError(error)`.
|
|
193
|
+
With `renderLoadError({ error, retry })` you render the message, and `retry`
|
|
194
|
+
loads the map again. Without it the map shows “Map location unavailable”.
|
|
81
195
|
The optional location button pans to coordinates supplied by your app. Your app
|
|
82
196
|
requests geolocation permission and supplies those coordinates.
|
|
197
|
+
|
|
198
|
+
## Delivery map
|
|
199
|
+
|
|
200
|
+
`DeliveryMap` shows where an order goes: the branch logo marker, the shopper's
|
|
201
|
+
house marker and the line between them, fitted to all of it. Use it in a cart or
|
|
202
|
+
checkout in place of a static map image. It is a preview, so it has no controls
|
|
203
|
+
and ignores gestures. Pass `route`, such as a decoded driving route, to draw that
|
|
204
|
+
path instead of a straight line.
|
|
205
|
+
|
|
206
|
+
```tsx
|
|
207
|
+
import { DeliveryMap } from "@zappie/foundations";
|
|
208
|
+
|
|
209
|
+
<DeliveryMap
|
|
210
|
+
branch={branch}
|
|
211
|
+
shopper={deliveryAddress.coordinates}
|
|
212
|
+
browserKey={integrations.googleMaps?.browserKey}
|
|
213
|
+
pinImageUrl={store.brand.logoUrl}
|
|
214
|
+
renderLoadError={({ retry }) => (
|
|
215
|
+
<button type="button" onClick={retry}>
|
|
216
|
+
Try again
|
|
217
|
+
</button>
|
|
218
|
+
)}
|
|
219
|
+
className="delivery-map"
|
|
220
|
+
/>;
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Set a height on `.delivery-map`. The default storefront uses 180 px. The branch
|
|
224
|
+
marker is a 36 px logo, the house marker uses `--map-accent` and the line uses
|
|
225
|
+
`--delivery-map-route` at 60% opacity. Without a `pinImageUrl` the branch marker
|
|
226
|
+
is the themed dot. Like `BranchMap`, it renders nothing without a key, loads
|
|
227
|
+
Maps after mount, calls `onLoadError(error)` on a failure and shows “Map location
|
|
228
|
+
unavailable” when either point has no valid coordinates. It fits again when the
|
|
229
|
+
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,36 +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) {
|
|
11
|
-
const points = visibleBranches(branches).map((branch) => branch.coordinates);
|
|
12
|
-
if (!points.length) {
|
|
13
|
-
return void 0;
|
|
14
|
-
}
|
|
15
|
-
const longitudes = points.map((point) => (point.lng + 360) % 360).sort((a, b) => a - b);
|
|
16
|
-
let gap = -1;
|
|
17
|
-
let start = 0;
|
|
18
|
-
for (let index = 0; index < longitudes.length; index++) {
|
|
19
|
-
const next = longitudes[(index + 1) % longitudes.length] + (index === longitudes.length - 1 ? 360 : 0);
|
|
20
|
-
if (next - longitudes[index] > gap) {
|
|
21
|
-
gap = next - longitudes[index];
|
|
22
|
-
start = (index + 1) % longitudes.length;
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
const normalize = (lng) => lng > 180 ? lng - 360 : lng;
|
|
26
|
-
return {
|
|
27
|
-
north: Math.max(...points.map((point) => point.lat)),
|
|
28
|
-
south: Math.min(...points.map((point) => point.lat)),
|
|
29
|
-
west: normalize(longitudes[start]),
|
|
30
|
-
east: normalize(
|
|
31
|
-
longitudes[(start + longitudes.length - 1) % longitudes.length]
|
|
32
|
-
)
|
|
33
|
-
};
|
|
34
|
-
}
|
|
35
10
|
export {
|
|
36
|
-
branchBounds,
|
|
37
11
|
validCoordinates,
|
|
38
12
|
visibleBranches
|
|
39
13
|
};
|