Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/map-layer-lifecycle-consolidation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
"web": patch
"@openmapx/integration-overlay-cycling": patch
"@openmapx/integration-overlay-earthquakes": patch
"@openmapx/integration-overlay-environment": patch
"@openmapx/integration-overlay-hiking": patch
"@openmapx/integration-overlay-live-transit": patch
"@openmapx/integration-overlay-natural-events": patch
"@openmapx/integration-overlay-nautical": patch
"@openmapx/integration-overlay-ourairports": patch
"@openmapx/integration-overlay-satellite": patch
"@openmapx/integration-overlay-schematic-transit": patch
"@openmapx/integration-overlay-sun-time": patch
"@openmapx/integration-overlay-tool-measurement": patch
"@openmapx/integration-overlay-tool-travel-time": patch
"@openmapx/integration-overlay-transit": patch
"@openmapx/integration-overlay-weather": patch
"@openmapx/integration-overlay-weather-alerts": patch
"@openmapx/integration-overlay-wildfires": patch
"@openmapx/integration-overlay-winter-sports": patch
"@openmapx/integration-road-conditions": patch
---

Map overlays now keep their layers and data across style changes through one shared lifecycle, recolour and refilter without blinking, stack in a fixed order, and leave the map clean when switched off, while style updates no longer copy the whole map style on every change.
144 changes: 49 additions & 95 deletions apps/web/src/app/settings/offline/AreaPickerMap.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,25 @@
"use client";

import Box from "@mui/material/Box";
import { useColorScheme } from "@mui/material/styles";
import type { AreaGeometry } from "@openmapx/core";
import type * as maplibregl from "maplibre-gl";
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import { MapCredits } from "@/components/map/MapCredits";
import { useStandaloneBasemapStyle, useStandaloneMap } from "@/components/map/useStandaloneMap";
import { createGeoJsonSourceDataBridge } from "@/integration-api/map/layerStyleUtils";
import { EMPTY_FEATURE_COLLECTION } from "@/integration-api/map/mapLayerGroup";
import { subscribeStyleLoaded } from "@/integration-api/map/styleLoadedSync";
import { useEnv } from "@/integration-api/runtime/EnvProvider";
import { baseMapVectorCredits, loadMaptilerStyle, loadOpenMapXStyle } from "@/lib/map";
import { loadMapLibreRuntime } from "@/lib/maplibreRuntime";
import { baseMapVectorCredits } from "@/lib/map";
import type { OfflinePackageBbox } from "@/lib/offlineAreas";
import { fitPackageBbox } from "./fitPackageBbox";

const BOUNDARY_SOURCE = "picker-boundary-source";
const BOUNDARY_FILL = "picker-boundary-fill";
const BOUNDARY_LINE = "picker-boundary-line";
// Muted red, matching PlaceBoundaryLayer's area highlight.
const BOUNDARY_COLOR = "#A52714";

const EMPTY: GeoJSON.FeatureCollection = { type: "FeatureCollection", features: [] };

interface Props {
initialCenter: [number, number];
initialZoom: number;
Expand All @@ -33,7 +32,7 @@ interface Props {

function addBoundaryLayers(map: maplibregl.Map): void {
if (map.getSource(BOUNDARY_SOURCE)) return;
map.addSource(BOUNDARY_SOURCE, { type: "geojson", data: EMPTY });
map.addSource(BOUNDARY_SOURCE, { type: "geojson", data: EMPTY_FEATURE_COLLECTION });
map.addLayer({
id: BOUNDARY_FILL,
type: "fill",
Expand All @@ -60,18 +59,10 @@ function boundaryData(boundary: AreaGeometry | null): GeoJSON.FeatureCollection
type: "FeatureCollection",
features: [{ type: "Feature", properties: {}, geometry: boundary }],
}
: EMPTY;
: EMPTY_FEATURE_COLLECTION;
}

function fitToBbox(map: maplibregl.Map, bbox: OfflinePackageBbox): void {
map.fitBounds(
[
[bbox.west, bbox.south],
[bbox.east, bbox.north],
],
{ padding: 24, duration: 600 },
);
}
const FIT_OPTIONS = { padding: 24, duration: 600 };

/**
* Mini MapLibre instance used as the bbox picker. Whatever the user has visible
Expand All @@ -82,10 +73,7 @@ function fitToBbox(map: maplibregl.Map, bbox: OfflinePackageBbox): void {
*/
export function AreaPickerMap({ initialCenter, initialZoom, onChange, fitBbox, boundary }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<maplibregl.Map | null>(null);
const boundaryBridgeRef = useRef<ReturnType<typeof createGeoJsonSourceDataBridge> | null>(null);
if (!boundaryBridgeRef.current) boundaryBridgeRef.current = createGeoJsonSourceDataBridge();
const boundaryBridge = boundaryBridgeRef.current;
const [boundaryBridge] = useState(createGeoJsonSourceDataBridge);
// The parent updates its bbox state from this callback. Keep the callback
// current without making it a map-lifecycle dependency: an inline callback
// from the parent must not tear down and recreate the map on every move.
Expand All @@ -95,100 +83,66 @@ export function AreaPickerMap({ initialCenter, initialZoom, onChange, fitBbox, b
const fitBboxRef = useRef(fitBbox);
fitBboxRef.current = fitBbox;
const env = useEnv();
const { mode, systemMode } = useColorScheme();
const resolvedMode = mode === "system" ? systemMode : mode;
const styleName = resolvedMode === "dark" ? "streets-v2-dark" : "bright-v2";
const variant = resolvedMode === "dark" ? "dark" : "light";

// biome-ignore lint/correctness/useExhaustiveDependencies: initialCenter / initialZoom and onChange intentionally captured at mount only — re-creating the map on every prop change would lose the user's pan/zoom state mid-selection; refs provide the latest values.
useEffect(() => {
if (!containerRef.current) return;
let destroyed = false;
let map: maplibregl.Map | null = null;
let unsubscribeStyle: (() => void) | undefined;

const init = async () => {
const maplibregl = await loadMapLibreRuntime();
if (destroyed || !containerRef.current) return;
const style =
env.styleProvider === "openmapx"
? await loadOpenMapXStyle(env, variant)
: await loadMaptilerStyle(styleName, env);
if (destroyed || !containerRef.current) return;
map = new maplibregl.Map({
container: containerRef.current,
style: style as string | maplibregl.StyleSpecification,
const loadStyle = useStandaloneBasemapStyle();

// initialCenter / initialZoom are captured at construction only: recreating
// the map on every prop change would lose the user's pan/zoom mid-selection.
const mapRef = useStandaloneMap(
containerRef,
() => ({
loadStyle,
options: {
center: initialCenter,
zoom: initialZoom,
// Credits render inline below via `<MapCredits>`, like the main map's
// footer, instead of behind MapLibre's ⓘ toggle.
attributionControl: false,
canvasContextAttributes: { antialias: true },
// Keep the picker north-up: a rotated/pitched viewport makes the
// downloaded bounding box (always axis-aligned) confusing to reason about.
dragRotate: false,
pitchWithRotate: false,
rollEnabled: false,
touchPitch: false,
});
// touchZoomRotate stays enabled for pinch-zoom, but disable its rotation.
map.touchZoomRotate.disableRotation();
map.keyboard.disableRotation();
mapRef.current = map;
if (fitBboxRef.current) fitToBbox(map, fitBboxRef.current);

const emit = () => {
if (!map) return;
const b = map.getBounds();
onChangeRef.current(
{
west: b.getWest(),
south: b.getSouth(),
east: b.getEast(),
north: b.getNorth(),
},
map.getZoom(),
);
};

const activeMap = map;
unsubscribeStyle = subscribeStyleLoaded(activeMap, () => {
addBoundaryLayers(activeMap);
boundaryBridge.apply(activeMap);
});
emit();
map.on("moveend", emit);
};

// `loadMaptilerStyle`/`loadOpenMapXStyle` reject on a failed style fetch;
// swallow it (leave the container empty) rather than emit an unhandled
// rejection — same degradation as the place mini-map.
void init().catch(() => {});

return () => {
destroyed = true;
unsubscribeStyle?.();
map?.remove();
mapRef.current = null;
};
}, [env, styleName, variant]);
},
setup: (map) => {
// touchZoomRotate stays enabled for pinch-zoom, but disable its rotation.
map.touchZoomRotate.disableRotation();
map.keyboard.disableRotation();
if (fitBboxRef.current) fitPackageBbox(map, fitBboxRef.current, FIT_OPTIONS);

const emit = () => {
const b = map.getBounds();
onChangeRef.current(
{ west: b.getWest(), south: b.getSouth(), east: b.getEast(), north: b.getNorth() },
map.getZoom(),
);
};
const unsubscribeStyle = subscribeStyleLoaded(map, () => {
addBoundaryLayers(map);
boundaryBridge.apply(map);
});
emit();
map.on("moveend", emit);
return () => {
unsubscribeStyle();
map.off("moveend", emit);
};
},
}),
[boundaryBridge, loadStyle],
);

// Frame the map to an externally-provided bbox (e.g. a searched admin area).
// The ensuing `moveend` re-emits the viewport bbox, so the download captures
// exactly what's framed.
useEffect(() => {
const map = mapRef.current;
if (!map || !fitBbox) return;
// Camera framing does not depend on source tiles or style readiness.
fitToBbox(map, fitBbox);
}, [fitBbox]);
if (map && fitBbox) fitPackageBbox(map, fitBbox, FIT_OPTIONS);
}, [fitBbox, mapRef]);

// Sync the highlighted boundary outline whenever it changes.
useEffect(() => {
const map = mapRef.current;
boundaryBridge.publish([{ sourceId: BOUNDARY_SOURCE, data: boundaryData(boundary ?? null) }]);
if (map) boundaryBridge.apply(map);
}, [boundary, boundaryBridge]);
}, [boundary, boundaryBridge, mapRef]);

return (
<Box
Expand Down
88 changes: 31 additions & 57 deletions apps/web/src/app/settings/offline/OfflineMapView.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
"use client";

import Box from "@mui/material/Box";
import { useColorScheme } from "@mui/material/styles";
import type * as maplibregl from "maplibre-gl";
import { useEffect, useRef } from "react";
import { useRef } from "react";
import { MapCredits } from "@/components/map/MapCredits";
import { useStandaloneMap, useStandaloneMapVariant } from "@/components/map/useStandaloneMap";
import { subscribeStyleLoaded } from "@/integration-api/map/styleLoadedSync";
import { useEnv } from "@/integration-api/runtime/EnvProvider";
import { baseMapVectorCredits, loadOpenMapXStyle } from "@/lib/map";
import { loadMapLibreRuntime } from "@/lib/maplibreRuntime";
import {
configureDefaultOfflinePackageResolver,
getDefaultOfflinePackageResolver,
type OfflinePackageBbox,
type OfflinePackageRecord,
registerOfflinePmtilesProtocol,
} from "@/lib/offlineAreas";
import { fitPackageBbox } from "./fitPackageBbox";

const RECT_SOURCE = "offline-packages-source";
const RECT_FILL = "offline-packages-fill";
Expand Down Expand Up @@ -85,68 +85,42 @@ interface Props {
export function OfflineMapView({ packages, fitTo, height = 360 }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const env = useEnv();
const { mode, systemMode } = useColorScheme();
const variant = (mode === "system" ? systemMode : mode) === "dark" ? "dark" : "light";
const variant = useStandaloneMapVariant();

useEffect(() => {
if (!containerRef.current || packages.length === 0) return;
let destroyed = false;
let map: maplibregl.Map | undefined;
let unsubscribeStyle: (() => void) | undefined;
useStandaloneMap(containerRef, () => {
if (packages.length === 0) return null;
const frame = fitTo ?? unionBbox(packages);

void (async () => {
const maplibregl = await loadMapLibreRuntime();
let resolver = getDefaultOfflinePackageResolver();
if (!resolver) {
resolver = configureDefaultOfflinePackageResolver({
tileSchema: "openmaptiles",
});
}
if (!resolver) throw new Error("offline package resolver is not initialized");
await resolver.refresh();
const style = await loadOpenMapXStyle(
env,
variant,
packages.map((record) => ({ packageId: record.id, manifest: record.manifest })),
);
if (destroyed || !containerRef.current) return;
registerOfflinePmtilesProtocol(maplibregl, resolver);
map = new maplibregl.Map({
container: containerRef.current,
style: style as maplibregl.StyleSpecification,
return {
loadStyle: async (runtime) => {
const resolver =
getDefaultOfflinePackageResolver() ??
configureDefaultOfflinePackageResolver({ tileSchema: "openmaptiles" });
if (!resolver) throw new Error("offline package resolver is not initialized");
await resolver.refresh();
const style = await loadOpenMapXStyle(
env,
variant,
packages.map((record) => ({ packageId: record.id, manifest: record.manifest })),
);
registerOfflinePmtilesProtocol(runtime, resolver);
return style as maplibregl.StyleSpecification;
},
options: {
center: frame ? [(frame.west + frame.east) / 2, (frame.south + frame.north) / 2] : [0, 20],
zoom: frame ? 6 : 1,
attributionControl: false,
canvasContextAttributes: { antialias: true },
dragRotate: false,
pitchWithRotate: false,
rollEnabled: false,
touchPitch: false,
});
map.touchZoomRotate.disableRotation();
map.keyboard.disableRotation();
const activeMap = map;
const features = packages.map(packageToFeature);
unsubscribeStyle = subscribeStyleLoaded(activeMap, () => addRectLayers(activeMap, features));
if (frame) {
activeMap.fitBounds(
[
[frame.west, frame.south],
[frame.east, frame.north],
],
{ padding: 32, duration: 0 },
);
}
})().catch(() => {
// The surrounding settings UI still exposes package metadata when a
// browser cannot render a local archive.
});

return () => {
destroyed = true;
unsubscribeStyle?.();
map?.remove();
},
setup: (map) => {
map.touchZoomRotate.disableRotation();
map.keyboard.disableRotation();
const features = packages.map(packageToFeature);
const unsubscribeStyle = subscribeStyleLoaded(map, () => addRectLayers(map, features));
if (frame) fitPackageBbox(map, frame, { padding: 32, duration: 0 });
return unsubscribeStyle;
},
};
}, [env, fitTo, packages, variant]);

Expand Down
20 changes: 20 additions & 0 deletions apps/web/src/app/settings/offline/fitPackageBbox.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type * as maplibregl from "maplibre-gl";
import type { OfflinePackageBbox } from "@/lib/offlineAreas";

/**
* Frame an offline package extent. Camera framing needs neither a parsed
* stylesheet nor loaded tiles, so it can run as soon as the map exists.
*/
export function fitPackageBbox(
map: Pick<maplibregl.Map, "fitBounds">,
bbox: OfflinePackageBbox,
options: maplibregl.FitBoundsOptions,
): void {
map.fitBounds(
[
[bbox.west, bbox.south],
[bbox.east, bbox.north],
],
options,
);
}
Loading
Loading