An offline-first PWA on AWS Amplify, part 2: Leaflet, GPS capture, and a convex hull
Leaflet without the marker-icon hacks, geolocation as a small state machine, and a monotone-chain convex hull with a projected-shoelace area readout: the map layer of an offline-first PWA.
Part 2 of a three-part walkthrough of Waypoints, an offline-first PWA for marking and mapping GPS waypoints. Part 1 covered the data layer: Dexie, the outbox, and the sync engine. This part covers the map. Part 3 covers the service worker and tile caching.
Everything in part 1 (the outbox, the idempotent flush, the local-first reads) exists so that one page works in a dead zone: the map. Mark waypoints in the field, then stand back, select a handful, and read off the area they enclose. This part walks the Leaflet integration, the capture flows, and the two pieces of honest computational geometry (a convex hull and an area formula) that the measurement feature runs on.
Tiles without a key
The base map is CARTO’s Positron layer, and the most important thing about it is what’s missing from the URL:
const CARTO_URL =
"https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png";
const ATTRIBUTION =
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>';
<TileLayer url={CARTO_URL} attribution={ATTRIBUTION} subdomains="abcd" />
No API key, no token. For an offline-first app, that absence is a feature in itself: there’s nothing to inject at build time, nothing to leak in the client bundle, nothing to expire while you’re on a trail, and, most usefully for part 3, tile URLs are stable, predictable strings that a service worker can cache and a prefetcher can enumerate. The {s} placeholder shards requests across CARTO’s four subdomains (a–d), and {r} swaps in @2x tiles on retina displays. Attribution is part of the deal with a keyless provider; it stays. One caveat the convenience hides: keyless isn’t the same as free. CARTO’s terms cover a personal tool like this; a commercial deployment would need an Enterprise license.
Markers that dodge a famous bug
If you’ve used react-leaflet, you’ve probably met the broken-default-marker bug: Leaflet’s stock marker is a PNG whose path resolves at runtime, bundlers rewrite the asset paths, and every Vite/Webpack project ends up pasting the same L.Icon.Default.prototype._getIconUrl incantation from Stack Overflow. Waypoints never hits it, because its markers aren’t images at all. The whole of src/components/mapIcons.ts:
import L from "leaflet";
const pin = (color: string): L.DivIcon =>
L.divIcon({
className: "wp-pin",
html: `<span class="wp-pin__dot" style="background:${color}"></span>`,
iconSize: [18, 18],
iconAnchor: [9, 9],
});
export const amberIcon = pin("#d9821b");
export const tealIcon = pin("#1d9e75");
A divIcon is plain HTML positioned by Leaflet: here a colored dot styled by CSS (white ring and drop shadow live in the stylesheet). No image assets, no bundler path hacks, and marker state becomes a pure rendering concern: amber means unselected, teal means selected, and toggling is just choosing an icon.
<Marker
key={w.id}
position={[w.latitude, w.longitude]}
icon={selectedIds.has(w.id) ? tealIcon : amberIcon}
eventHandlers={{ click: () => onToggle(w.id) }}
/>
The colors come from the app’s “field cartography” palette: surveyor amber and sea teal on warm paper, with coordinates rendered in IBM Plex Mono wherever they appear. Design flavor aside, the engineering point stands on its own: HTML markers make an entire class of asset-pipeline bugs unrepresentable.
The react-leaflet quirk everybody works around
MapContainer reads its center and zoom props exactly once, on mount. Change them later and nothing happens, by design, because after mount the map’s viewport belongs to the user. So recentering is done imperatively, by a child component that grabs the map instance from context:
// MapContainer ignores `center` prop changes after mount, so recentering is
// done imperatively here: when `focus` changes (current location on load, or a
// selected waypoint), fly the map there — or setView under reduced motion.
const RecenterMap = ({ focus }: { focus: Coordinate | null }): null => {
const map = useMap();
useEffect(() => {
if (!focus) return;
const target: LatLngExpression = [focus.latitude, focus.longitude];
const zoom = Math.max(map.getZoom(), FOCUS_MIN_ZOOM);
const reduceMotion =
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduceMotion) map.setView(target, zoom);
else map.flyTo(target, zoom, { duration: 0.75 });
}, [focus, map]);
return null;
};
It renders null: it exists only for its effect. Two details worth stealing: Math.max(map.getZoom(), FOCUS_MIN_ZOOM) respects the user’s zoom if they’re already zoomed in further than the floor, and the prefers-reduced-motion check swaps the 750ms flyTo animation for an instant setView. Accessibility preferences apply to maps too.
Capture: a race, a state machine, and zod
A waypoint gets its coordinates one of two ways: the device’s GPS, or typed-in latitude and longitude. (The README mentions map-click capture as a third; honestly, it isn’t wired up: the form accepts initial coordinates as props, but nothing passes them. The scaffolding is there, the feature isn’t.)
The GPS side is a hook wrapping navigator.geolocation in an explicit idle | loading | success | error state machine:
const request = useCallback((): void => {
if (!("geolocation" in navigator)) {
setStatus("error");
setError("Geolocation is not available on this device.");
return;
}
setStatus("loading");
navigator.geolocation.getCurrentPosition(
(pos) => {
setCoords({
latitude: pos.coords.latitude,
longitude: pos.coords.longitude,
});
setStatus("success");
},
(err) => {
setStatus("error");
setError(err.message || "Could not get your location.");
},
{ enableHighAccuracy: true, timeout: 10000 },
);
}, []);
enableHighAccuracy asks for the real GPS radio rather than a cell-tower guess (the right call for a surveying tool, at some battery cost), and the 10-second timeout means a denied or stalled fix degrades into a visible error instead of an infinite spinner. Permission handling is the browser’s prompt plus the error path; there’s no navigator.permissions pre-check.
The map page requests a fix on load and centers there, and the way it merges that async result with user activity is a one-liner worth pausing on:
// On load, ask for the current location and center there. `current ?? here`
// means a late geolocation result won't override a waypoint the user has
// already selected — whichever they reach first wins.
useEffect(() => {
if (geoStatus === "success" && geoCoords) {
const here = geoCoords;
setFocus((current) => current ?? here);
}
}, [geoStatus, geoCoords]);
GPS fixes take seconds. If the user has already tapped a waypoint by the time the fix arrives, current is set and the late fix is discarded; if not, the fix wins. The functional-updater form of setFocus resolves the race with no flags, no refs, no effect ordering: whichever writes first, wins.
Manual entry funnels through the same zod schema that guards every write path (part 1’s addWaypoint takes a validated WaypointInput):
export const waypointInputSchema = z.object({
label: z.string().max(MAX_LABEL_LENGTH).optional(),
latitude: z.number().min(MIN_LATITUDE).max(MAX_LATITUDE), // -90..90
longitude: z.number().min(MIN_LONGITUDE).max(MAX_LONGITUDE), // -180..180
markedAt: z.string().datetime(),
groupId: z.string().nullable().optional(),
});
The form keeps inputs as raw strings and converts empty fields to NaN, and since NaN fails z.number().min(), blank fields are rejected by the same rule that rejects latitude 91, with no bespoke emptiness check.
Selection is the measurement
The map page holds one piece of state that matters: selectedIds, a Set toggled from three places (marker clicks, sidebar checkboxes, and a mobile chip drawer). Everything else (the polygon, the area readout) is derived from it in one memoized call:
const area = useMemo(
() => selectionArea(waypoints, selectedIds),
[waypoints, selectedIds],
);
selectionArea is the bridge between UI state and geometry, and it’s small enough to quote whole:
export const selectionArea = (
waypoints: LocalWaypoint[],
selectedIds: Set<string>,
): SelectionArea | null => {
const points: Coordinate[] = waypoints
.filter((w) => selectedIds.has(w.id))
.map((w) => ({ latitude: w.latitude, longitude: w.longitude }));
if (points.length < 3) return null;
const hull = convexHull(points);
if (hull.length < 3) return null;
return { hull, areaKm2: polygonAreaKm2(hull) };
};
Because polygon and readout are both derived from the same call, they can’t disagree with the checkboxes: there’s no imperative “recalculate” step to forget. The second < 3 guard matters: three selected waypoints that happen to be collinear produce a degenerate hull, and the function returns null rather than a zero-area polygon. The geometry itself is pure functions over plain coordinates, unit-tested without Leaflet anywhere in sight.
The hull: Andrew’s monotone chain
convexHull in src/domain/geometry.ts is the classic O(n log n) monotone chain: sort the points, build the lower hull, build the upper hull, concatenate:
// Cross product of OA x OB (longitude = x, latitude = y).
// > 0 left turn (CCW), < 0 right turn, = 0 collinear.
const cross = (o: Coordinate, a: Coordinate, b: Coordinate): number =>
(a.longitude - o.longitude) * (b.latitude - o.latitude) -
(a.latitude - o.latitude) * (b.longitude - o.longitude);
// Andrew's monotone chain, O(n log n). `<= 0` pops collinear points.
export const convexHull = (points: Coordinate[]): Coordinate[] => {
const unique = dedupe(points);
if (unique.length < 3) return unique;
const sorted = [...unique].sort((a, b) =>
a.longitude === b.longitude
? a.latitude - b.latitude
: a.longitude - b.longitude,
);
const half = (pts: Coordinate[]): Coordinate[] => {
const chain: Coordinate[] = [];
for (const p of pts) {
while (
chain.length >= 2 &&
cross(chain[chain.length - 2], chain[chain.length - 1], p) <= 0
) {
chain.pop();
}
chain.push(p);
}
chain.pop();
return chain;
};
const lower = half(sorted);
const upper = half([...sorted].reverse());
return [...lower, ...upper];
};
Notes for the reader following along: longitude plays x and latitude plays y: this is planar geometry on angular coordinates, which is fine for the hull (convexity survives the projection at waypoint scales) and gets corrected properly in the area step. Points are deduped first through a Map keyed on "lat,lng", since duplicate marks would break the chain invariants. And the <= 0 in the pop condition is a choice: collinear points get removed, so a straight line of five waypoints collapses to its two endpoints. The test suite pins all of this down, including a shoelace-sign test asserting the output winds counterclockwise.
The hull renders as a dashed teal polygon, only when it’s a real polygon:
{
polygon.length >= 3 && (
<Polygon
positions={polygon}
pathOptions={{
color: "#1d9e75",
weight: 2,
dashArray: "6 6",
fillOpacity: 0.12,
}}
/>
);
}
The area: a shoelace with a projection first
polygonAreaKm2 is deliberately not the naive shoelace over raw lat/lng: that would treat a degree of longitude in Alaska as equal to one at the equator. It’s also not full spherical excess. It projects onto a local tangent plane at the polygon’s centroid, then runs the shoelace on projected kilometers:
export const polygonAreaKm2 = (points: Coordinate[]): number => {
if (points.length < 3) return 0;
const lat0 = points.reduce((sum, p) => sum + p.latitude, 0) / points.length;
const lng0 = points.reduce((sum, p) => sum + p.longitude, 0) / points.length;
const cosLat0 = Math.cos(toRadians(lat0));
const projected = points.map((p) => ({
x: EARTH_RADIUS_KM * toRadians(p.longitude - lng0) * cosLat0,
y: EARTH_RADIUS_KM * toRadians(p.latitude - lat0),
}));
let doubleArea = 0;
for (let i = 0; i < projected.length; i += 1) {
const a = projected[i];
const b = projected[(i + 1) % projected.length];
doubleArea += a.x * b.y - b.x * a.y;
}
return Math.abs(doubleArea) / 2;
};
The cosLat0 factor is the whole trick: it shrinks longitude differences by the latitude’s convergence, so a field in Michigan measures correctly even though its longitude degrees are two-thirds the width of equatorial ones. The honest envelope: one cosLat0 for the whole polygon is a small-area approximation. For the acres-to-a-few-square-kilometers extents this app measures, it’s accurate to well under a percent; stretch a polygon across a continent or push it near a pole and the single-centroid correction degrades. The tests assert known values for small shapes near the equator (~1.236 km² for a 0.01° square), which is a fair statement of the domain it’s built for. The readout surfaces as Area · {area.areaKm2.toFixed(3)} km² in tabular-numeral mono.
What the map doesn’t do
Two honest gaps, both visible in the code. Map-click capture, as mentioned, is scaffolded but unwired: GPS and manual entry are the real flows. And groups (the organizational feature from part 1’s data model) don’t touch the map at all: no per-group marker colors, no filtering, no color field on the model. Marker color is spent on selection state instead. For a measurement tool that’s a defensible allocation of the one visual channel; it’s also simply where the app stopped.
What happens when you pan past the edge of what’s cached, though (gray void), is a service-worker problem, and solving it is part 3: the app shell, one Workbox rule, and a “cache this area” button that warms the tile cache with nothing but fetch().