Skip to content

Commit a3de391

Browse files
authored
fix(map): patch Popup.prototype instead of the maplibre namespace (#1509)
Globe popup occlusion was installed by replacing `maplibregl.Popup` with a subclass. That relies on the namespace being a mutable object, which is only true of MapLibre v5's CJS/UMD default export. A v6 ESM module namespace is sealed, so the assignment throws at map construction: TypeError: Cannot assign to property 'Popup' of [object Module] and the map canvas never mounts (#1489, blocker 3). Patch `Popup.prototype.addTo` instead. The namespace binding is frozen but the `Popup` class it exposes is an ordinary mutable object, and `addTo` is the one prototype method every popup passes through on its way onto a map (`Marker#togglePopup` routes through it too). The wrap runs before MapLibre's own `_update()`/`_updateOpacity()`, so the first painted frame is correct. `_updateOpacity` is assigned as an instance arrow inside the Popup constructor, so it shadows the prototype and still has to be wrapped per instance. This keeps the behavior identical and widens coverage, since the namespace swap never reached: - plugins that `import { Popup } from "maplibre-gl"` by name and call `new Popup()` — `maplibre-gl-raster` and our own reverse-geocode plugin both do, so their popups had no occlusion at all - popups or `extends maplibregl.Popup` subclasses created before install ran Also fix a second v6 break in the same file that fails silently. v6 stopped having `Map extend Camera`, so `map.transform` is gone and its own Popup reads `_map._camera.transform`. The occlusion mirror read `_map.transform`, which is `undefined` under v6 — optional-chained, so no error, the check just never runs and back-of-globe popups stay visible. Both locations are now resolved in one documented helper. Verified against real maplibre-gl 5.24.0 and 6.0.0 module objects, and in a browser on the running app: a popup at [180, 0] with the camera at [0, 0] under the globe projection is hidden and restores when the globe is rotated to face it.
1 parent 3370bf8 commit a3de391

2 files changed

Lines changed: 282 additions & 154 deletions

File tree

packages/map/src/globe-popup-occlusion.ts

Lines changed: 101 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,54 @@
1-
import type maplibregl from "maplibre-gl";
1+
import type { Popup, PopupOptions } from "maplibre-gl";
22

33
/** JS-observable marker for occluded popups; visual hiding is applied inline. */
44
export const GLOBE_POPUP_OCCLUDED_CLASS = "geolibre-globe-popup-occluded";
55

6-
const PATCHED_POPUP_MARKER = "__geolibreGlobePopupOcclusionPatched";
76
const DEFAULT_OCCLUDED_OPACITY = 0;
87
const ZERO_OPACITY_STRING = /^[+-]?(?:0+(?:\.0*)?|\.(?:0+))$/;
98

10-
type PopupConstructor = new (options?: maplibregl.PopupOptions) => maplibregl.Popup;
9+
// Registered with Symbol.for so a second copy of this module (a duplicated
10+
// @geolibre/map inside a plugin bundle) recognizes the first copy's work and
11+
// does not wrap `addTo` — or a popup's `_updateOpacity` — twice.
12+
const PATCHED_PROTOTYPE = Symbol.for("geolibre.globePopupOcclusion.patchedPrototype");
13+
const INSTRUMENTED_POPUP = Symbol.for("geolibre.globePopupOcclusion.instrumentedPopup");
14+
15+
type PopupConstructor = new (options?: PopupOptions) => Popup;
16+
17+
/**
18+
* The slice of the MapLibre entry point this module touches.
19+
*
20+
* Typed structurally instead of as `typeof maplibregl` so it accepts both v5's
21+
* mutable default-export object and v6's ESM module namespace object.
22+
*/
23+
export interface MapLibrePopupNamespace {
24+
Popup: PopupConstructor;
25+
}
26+
27+
interface PatchablePopupPrototype {
28+
addTo: (this: unknown, map: unknown) => unknown;
29+
[PATCHED_PROTOTYPE]?: true;
30+
}
1131

12-
type PatchablePopupConstructor = PopupConstructor & {
13-
[PATCHED_POPUP_MARKER]?: true;
14-
};
32+
interface OccludableTransform {
33+
isLocationOccluded?: (lngLat: unknown) => boolean;
34+
}
1535

16-
interface PatchableMapLibre {
17-
Popup: PatchablePopupConstructor;
36+
interface PopupHostMap {
37+
/** MapLibre v6: `Map` no longer extends `Camera`, so the transform moved here. */
38+
_camera?: { transform?: OccludableTransform };
39+
/** MapLibre v5: `Map extends Camera`, so the transform is on the map itself. */
40+
transform?: OccludableTransform;
1841
}
1942

2043
interface PopupInternals {
2144
_container?: HTMLElement;
22-
_map?: {
23-
transform?: {
24-
isLocationOccluded?: (lngLat: unknown) => boolean;
25-
};
26-
};
27-
_updateOpacity?: () => void;
45+
_map?: PopupHostMap;
46+
_updateOpacity?: (...args: unknown[]) => void;
2847
getLngLat: () => unknown;
2948
options?: {
3049
locationOccludedOpacity?: number | string | null;
3150
};
51+
[INSTRUMENTED_POPUP]?: true;
3252
}
3353

3454
interface InteractiveStyles {
@@ -38,6 +58,20 @@ interface InteractiveStyles {
3858

3959
const hiddenPopupStyles = new WeakMap<HTMLElement, InteractiveStyles>();
4060

61+
/**
62+
* Resolve the transform MapLibre computes globe occlusion against.
63+
*
64+
* v6 split `Camera` out of `Map` and its own `Popup` reads
65+
* `_map._camera.transform`; v5 exposes `_map.transform` directly. Both are
66+
* checked because this module ships against either. Getting this wrong fails
67+
* silently — the occlusion check just never runs — so it stays in one place.
68+
*/
69+
function resolveOccludableTransform(
70+
map: PopupHostMap | undefined,
71+
): OccludableTransform | undefined {
72+
return map?._camera?.transform ?? map?.transform;
73+
}
74+
4175
function shouldSuppressInteraction(popup: PopupInternals): boolean {
4276
const opacity = popup.options?.locationOccludedOpacity;
4377
if (typeof opacity === "string") {
@@ -74,11 +108,11 @@ function setPopupOccluded(container: HTMLElement, occluded: boolean): void {
74108
container.style.visibility = "hidden";
75109
}
76110

77-
export function syncPopupGlobeOcclusion(popup: maplibregl.Popup): boolean {
111+
export function syncPopupGlobeOcclusion(popup: Popup): boolean {
78112
const popupInternals = popup as unknown as PopupInternals;
79113
const container = popupInternals._container;
80114
const opacity = popupInternals.options?.locationOccludedOpacity;
81-
const transform = popupInternals._map?.transform;
115+
const transform = resolveOccludableTransform(popupInternals._map);
82116
const isLocationOccluded = transform?.isLocationOccluded;
83117

84118
if (!container || opacity === undefined || opacity === null) {
@@ -93,27 +127,57 @@ export function syncPopupGlobeOcclusion(popup: maplibregl.Popup): boolean {
93127
return occluded;
94128
}
95129

96-
export function installGlobePopupOcclusion(maplibre: typeof maplibregl): void {
97-
const api = maplibre as unknown as PatchableMapLibre;
98-
const OriginalPopup = api.Popup;
99-
if (OriginalPopup[PATCHED_POPUP_MARKER]) return;
100-
101-
class GeoLibrePopup extends OriginalPopup {
102-
constructor(options: maplibregl.PopupOptions = {}) {
103-
super({
104-
...options,
105-
locationOccludedOpacity: options.locationOccludedOpacity ?? DEFAULT_OCCLUDED_OPACITY,
106-
});
107-
108-
const popup = this as unknown as PopupInternals;
109-
const updateOpacity = popup._updateOpacity;
110-
popup._updateOpacity = () => {
111-
if (popup.getLngLat()) updateOpacity?.call(this);
112-
syncPopupGlobeOcclusion(this);
113-
};
114-
}
130+
/**
131+
* Give one popup instance the occlusion behavior: opt it into MapLibre's own
132+
* occlusion math, then wrap the opacity update so the interaction suppression
133+
* and marker class ride along.
134+
*
135+
* MapLibre assigns `_updateOpacity` as an instance arrow inside the `Popup`
136+
* constructor, so it shadows anything installed on the prototype — the wrap has
137+
* to happen per instance rather than once on `Popup.prototype`.
138+
*/
139+
function instrumentPopup(popup: PopupInternals): void {
140+
if (popup[INSTRUMENTED_POPUP]) return;
141+
popup[INSTRUMENTED_POPUP] = true;
142+
143+
// MapLibre skips the occlusion check entirely unless this option is set, so
144+
// default it on every popup — including ones a plugin constructed itself.
145+
if (popup.options) {
146+
popup.options.locationOccludedOpacity ??= DEFAULT_OCCLUDED_OPACITY;
115147
}
116148

117-
GeoLibrePopup[PATCHED_POPUP_MARKER] = true;
118-
api.Popup = GeoLibrePopup;
149+
const updateOpacity = popup._updateOpacity;
150+
popup._updateOpacity = (...args: unknown[]) => {
151+
if (popup.getLngLat()) updateOpacity?.apply(popup, args);
152+
syncPopupGlobeOcclusion(popup as unknown as Popup);
153+
};
154+
}
155+
156+
/**
157+
* Install globe-occlusion behavior on every MapLibre popup, including ones
158+
* constructed by third-party plugins.
159+
*
160+
* This patches `Popup.prototype.addTo` rather than replacing `maplibre.Popup`.
161+
* A v6 module namespace object is sealed and rejects assignment
162+
* (`TypeError: Cannot assign to property 'Popup' of [object Module]`), while
163+
* the `Popup` class it exposes is an ordinary mutable object. Patching the
164+
* prototype also reaches consumers a namespace swap never could: plugins that
165+
* `import { Popup } from "maplibre-gl"` by name, and popups (or subclasses)
166+
* created before this runs.
167+
*
168+
* `addTo` is the hook because it is the one prototype method every popup goes
169+
* through on its way onto a map — `Marker#togglePopup` routes through it too —
170+
* and it wraps before MapLibre's own `_update()`/`_updateOpacity()` runs, so
171+
* the first painted frame is already correct.
172+
*/
173+
export function installGlobePopupOcclusion(maplibre: MapLibrePopupNamespace): void {
174+
const prototype = maplibre.Popup.prototype as unknown as PatchablePopupPrototype;
175+
if (prototype[PATCHED_PROTOTYPE]) return;
176+
177+
const originalAddTo = prototype.addTo;
178+
prototype.addTo = function (this: unknown, map: unknown) {
179+
instrumentPopup(this as PopupInternals);
180+
return originalAddTo.call(this, map);
181+
};
182+
prototype[PATCHED_PROTOTYPE] = true;
119183
}

0 commit comments

Comments
 (0)