@octanejs/react-map-gl 0.0.1
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/LICENSE +62 -0
- package/README.md +112 -0
- package/UPSTREAM.md +216 -0
- package/package.json +61 -0
- package/src/components/attribution-control.ts +27 -0
- package/src/components/fullscreen-control.ts +35 -0
- package/src/components/geolocate-control.ts +88 -0
- package/src/components/layer.ts +135 -0
- package/src/components/map.tsrx +173 -0
- package/src/components/marker.ts +217 -0
- package/src/components/navigation-control.ts +27 -0
- package/src/components/popup.ts +100 -0
- package/src/components/scale-control.ts +44 -0
- package/src/components/source.tsrx +146 -0
- package/src/components/use-control.ts +73 -0
- package/src/components/use-map.tsrx +64 -0
- package/src/index.ts +33 -0
- package/src/mapbox/create-ref.ts +114 -0
- package/src/mapbox/mapbox.ts +714 -0
- package/src/mapbox/proxy-transform.ts +155 -0
- package/src/types/common.ts +41 -0
- package/src/types/events.ts +130 -0
- package/src/types/internal.ts +20 -0
- package/src/types/lib.ts +70 -0
- package/src/types/octane.ts +27 -0
- package/src/types/style-spec.ts +51 -0
- package/src/utils/apply-react-style.ts +26 -0
- package/src/utils/assert.ts +10 -0
- package/src/utils/children.ts +26 -0
- package/src/utils/compare-class-names.ts +34 -0
- package/src/utils/deep-equal.ts +66 -0
- package/src/utils/set-globals.ts +62 -0
- package/src/utils/style-utils.ts +65 -0
- package/src/utils/transform.ts +115 -0
- package/src/utils/use-isomorphic-layout-effect.ts +7 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { useContext, useEffect, useMemo, useRef, useState } from 'octane';
|
|
2
|
+
import { MapContext } from './map.tsrx';
|
|
3
|
+
import { SourceContext } from './source.tsrx';
|
|
4
|
+
import assert from '../utils/assert';
|
|
5
|
+
import { deepEqual } from '../utils/deep-equal';
|
|
6
|
+
|
|
7
|
+
import type { MapInstance, CustomLayerInterface } from '../types/lib';
|
|
8
|
+
import type { LayerSpecification } from '../types/style-spec';
|
|
9
|
+
|
|
10
|
+
// Omiting property from a union type, see
|
|
11
|
+
// https://github.com/microsoft/TypeScript/issues/39556#issuecomment-656925230
|
|
12
|
+
type OptionalId<T> = T extends { id: string } ? Omit<T, 'id'> & { id?: string } : T;
|
|
13
|
+
type OptionalSource<T> = T extends { source: string } ? Omit<T, 'source'> & { source?: string } : T;
|
|
14
|
+
|
|
15
|
+
export type LayerProps = (OptionalSource<OptionalId<LayerSpecification>> | CustomLayerInterface) & {
|
|
16
|
+
/** If set, the layer will be inserted before the specified layer */
|
|
17
|
+
beforeId?: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/* eslint-disable complexity, max-statements */
|
|
21
|
+
function updateLayer(map: MapInstance, id: string, props: LayerProps, prevProps: LayerProps) {
|
|
22
|
+
assert(props.id === prevProps.id, 'layer id changed');
|
|
23
|
+
assert(props.type === prevProps.type, 'layer type changed');
|
|
24
|
+
|
|
25
|
+
if (props.type === 'custom' || prevProps.type === 'custom') {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// @ts-ignore filter does not exist in some Layer types
|
|
30
|
+
const { layout = {}, paint = {}, filter, minzoom, maxzoom, beforeId } = props;
|
|
31
|
+
|
|
32
|
+
if (beforeId !== prevProps.beforeId) {
|
|
33
|
+
map.moveLayer(id, beforeId!);
|
|
34
|
+
}
|
|
35
|
+
if (layout !== prevProps.layout) {
|
|
36
|
+
const nextLayout = layout as Record<string, unknown>;
|
|
37
|
+
const prevLayout = (prevProps.layout || {}) as Record<string, unknown>;
|
|
38
|
+
for (const key in nextLayout) {
|
|
39
|
+
if (!deepEqual(nextLayout[key], prevLayout[key])) {
|
|
40
|
+
map.setLayoutProperty(id, key as any, nextLayout[key]);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
for (const key in prevLayout) {
|
|
44
|
+
if (!nextLayout.hasOwnProperty(key)) {
|
|
45
|
+
map.setLayoutProperty(id, key as any, undefined);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (paint !== prevProps.paint) {
|
|
50
|
+
const nextPaint = paint as Record<string, unknown>;
|
|
51
|
+
const prevPaint = (prevProps.paint || {}) as Record<string, unknown>;
|
|
52
|
+
for (const key in nextPaint) {
|
|
53
|
+
if (!deepEqual(nextPaint[key], prevPaint[key])) {
|
|
54
|
+
map.setPaintProperty(id, key as any, nextPaint[key]);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
for (const key in prevPaint) {
|
|
58
|
+
if (!nextPaint.hasOwnProperty(key)) {
|
|
59
|
+
map.setPaintProperty(id, key as any, undefined);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// @ts-ignore filter does not exist in some Layer types
|
|
65
|
+
if (!deepEqual(filter, prevProps.filter)) {
|
|
66
|
+
map.setFilter(id, filter);
|
|
67
|
+
}
|
|
68
|
+
if (minzoom !== prevProps.minzoom || maxzoom !== prevProps.maxzoom) {
|
|
69
|
+
map.setLayerZoomRange(id, minzoom!, maxzoom!);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function createLayer(map: MapInstance, id: string, props: LayerProps) {
|
|
74
|
+
// @ts-ignore
|
|
75
|
+
if (map.style && map.style._loaded && (!('source' in props) || map.getSource(props.source))) {
|
|
76
|
+
const options: LayerProps = { ...props, id };
|
|
77
|
+
delete options.beforeId;
|
|
78
|
+
|
|
79
|
+
// @ts-ignore
|
|
80
|
+
map.addLayer(options, props.beforeId);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/* eslint-enable complexity, max-statements */
|
|
85
|
+
|
|
86
|
+
let layerCounter = 0;
|
|
87
|
+
|
|
88
|
+
export function Layer(props: LayerProps) {
|
|
89
|
+
const map = useContext(MapContext).map.getMap();
|
|
90
|
+
// An enclosing <Source> publishes its id here. Upstream instead clones this
|
|
91
|
+
// element with `{source: id}`, which likewise overrides an explicitly set
|
|
92
|
+
// source prop — see the divergence note in source.tsrx.
|
|
93
|
+
const inheritedSource = useContext(SourceContext);
|
|
94
|
+
const propsRef = useRef(props);
|
|
95
|
+
const [, setStyleLoaded] = useState(0);
|
|
96
|
+
|
|
97
|
+
const id = useMemo(() => props.id || `jsx-layer-${layerCounter++}`, []);
|
|
98
|
+
|
|
99
|
+
const layerProps: LayerProps =
|
|
100
|
+
inheritedSource === null ? props : ({ ...props, source: inheritedSource } as LayerProps);
|
|
101
|
+
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
if (map) {
|
|
104
|
+
const forceUpdate = () => setStyleLoaded((version) => version + 1);
|
|
105
|
+
map.on('styledata', forceUpdate);
|
|
106
|
+
forceUpdate();
|
|
107
|
+
|
|
108
|
+
return () => {
|
|
109
|
+
map.off('styledata', forceUpdate);
|
|
110
|
+
// @ts-ignore
|
|
111
|
+
if (map.style && map.style._loaded && map.getLayer(id)) {
|
|
112
|
+
map.removeLayer(id);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
return undefined;
|
|
117
|
+
}, [map]);
|
|
118
|
+
|
|
119
|
+
// @ts-ignore
|
|
120
|
+
const layer = map && map.style && map.getLayer(id);
|
|
121
|
+
if (layer) {
|
|
122
|
+
try {
|
|
123
|
+
updateLayer(map, id, layerProps, propsRef.current);
|
|
124
|
+
} catch (error) {
|
|
125
|
+
console.warn(error); // eslint-disable-line
|
|
126
|
+
}
|
|
127
|
+
} else {
|
|
128
|
+
createLayer(map, id, layerProps);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Store last rendered props
|
|
132
|
+
propsRef.current = layerProps;
|
|
133
|
+
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/* global document */
|
|
2
|
+
import {
|
|
3
|
+
createContext,
|
|
4
|
+
createPortal,
|
|
5
|
+
useContext,
|
|
6
|
+
useEffect,
|
|
7
|
+
useImperativeHandle,
|
|
8
|
+
useMemo,
|
|
9
|
+
useRef,
|
|
10
|
+
useState,
|
|
11
|
+
} from 'octane';
|
|
12
|
+
|
|
13
|
+
import { MountedMapsContext } from './use-map.tsrx';
|
|
14
|
+
import Mapbox, { type MapboxProps } from '../mapbox/mapbox';
|
|
15
|
+
import createRef, { type MapRef } from '../mapbox/create-ref';
|
|
16
|
+
|
|
17
|
+
import useIsomorphicLayoutEffect from '../utils/use-isomorphic-layout-effect';
|
|
18
|
+
import setGlobals, { type GlobalSettings } from '../utils/set-globals';
|
|
19
|
+
import type { MapLib, MapOptions } from '../types/lib';
|
|
20
|
+
import type { CSSProperties, MapChildren, Ref } from '../types/octane';
|
|
21
|
+
|
|
22
|
+
export type MapContextValue = {
|
|
23
|
+
mapLib: MapLib;
|
|
24
|
+
map: MapRef;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const MapContext = createContext<MapContextValue>(null as unknown as MapContextValue);
|
|
28
|
+
|
|
29
|
+
type MapInitOptions =
|
|
30
|
+
Omit<MapOptions, 'style' | 'container' | 'bounds' | 'fitBoundsOptions' | 'center'>;
|
|
31
|
+
|
|
32
|
+
export type MapProps =
|
|
33
|
+
MapInitOptions & MapboxProps & GlobalSettings & {
|
|
34
|
+
mapLib?: MapLib | Promise<MapLib>;
|
|
35
|
+
reuseMaps?: boolean;
|
|
36
|
+
/** Map container id */
|
|
37
|
+
id?: string;
|
|
38
|
+
/** Map container CSS style */
|
|
39
|
+
style?: CSSProperties;
|
|
40
|
+
children?: MapChildren;
|
|
41
|
+
/** Octane passes refs as ordinary props; there is no forwardRef. */
|
|
42
|
+
ref?: Ref<MapRef>;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export function Map(props: MapProps) @{
|
|
46
|
+
const mountedMapsContext = useContext(MountedMapsContext);
|
|
47
|
+
const [mapInstance, setMapInstance] = useState<Mapbox | null>(null);
|
|
48
|
+
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
49
|
+
const childContainerRef = useRef<HTMLDivElement | null>(null);
|
|
50
|
+
|
|
51
|
+
const { current: contextValue } = useRef<MapContextValue>({
|
|
52
|
+
mapLib: null as unknown as MapLib,
|
|
53
|
+
map: null as unknown as MapRef,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
const mapLib = props.mapLib;
|
|
58
|
+
let isMounted = true;
|
|
59
|
+
let mapbox: Mapbox;
|
|
60
|
+
|
|
61
|
+
Promise.resolve(mapLib || import('mapbox-gl')).then((module: MapLib | { default: MapLib }) => {
|
|
62
|
+
if (!isMounted) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (!module) {
|
|
66
|
+
throw new Error('Invalid mapLib');
|
|
67
|
+
}
|
|
68
|
+
const mapboxgl =
|
|
69
|
+
'Map' in module ? module : module.default;
|
|
70
|
+
if (!mapboxgl.Map) {
|
|
71
|
+
throw new Error('Invalid mapLib');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
setGlobals(mapboxgl, props);
|
|
75
|
+
if (props.reuseMaps) {
|
|
76
|
+
mapbox = Mapbox.reuse(props, containerRef.current!)!;
|
|
77
|
+
}
|
|
78
|
+
if (!mapbox) {
|
|
79
|
+
mapbox = new Mapbox(mapboxgl.Map, props, containerRef.current!);
|
|
80
|
+
}
|
|
81
|
+
contextValue.map = createRef(mapbox)!;
|
|
82
|
+
contextValue.mapLib = mapboxgl;
|
|
83
|
+
|
|
84
|
+
// The children container is created and APPENDED here rather than
|
|
85
|
+
// rendered into the template, so it lands after the DOM mapbox-gl has
|
|
86
|
+
// just built — which is where React puts it, and the only arrangement
|
|
87
|
+
// that reproduces upstream's behavior.
|
|
88
|
+
//
|
|
89
|
+
// Rendered in the template, Octane's conditional block anchors it at its
|
|
90
|
+
// authored position, ahead of the library's nodes. In flow it then
|
|
91
|
+
// displaces `.mapboxgl-canvas-container`, the element mapbox-gl measures
|
|
92
|
+
// pointer coordinates against; taken out of flow it instead paints
|
|
93
|
+
// before the canvas, so a consumer's positioned overlay ends up hidden
|
|
94
|
+
// and unclickable beneath an opaque map. Appending sidesteps both: same
|
|
95
|
+
// `height: 100%` container as upstream, in the same place.
|
|
96
|
+
const childContainer = document.createElement('div');
|
|
97
|
+
childContainer.setAttribute('mapboxgl-children', '');
|
|
98
|
+
childContainer.style.height = '100%';
|
|
99
|
+
containerRef.current!.appendChild(childContainer);
|
|
100
|
+
childContainerRef.current = childContainer;
|
|
101
|
+
|
|
102
|
+
setMapInstance(mapbox);
|
|
103
|
+
}).catch((error) => {
|
|
104
|
+
const { onError } = props;
|
|
105
|
+
if (onError) {
|
|
106
|
+
onError({
|
|
107
|
+
type: 'error',
|
|
108
|
+
target: null,
|
|
109
|
+
error,
|
|
110
|
+
});
|
|
111
|
+
} else {
|
|
112
|
+
console.error(error); // eslint-disable-line
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
return () => {
|
|
117
|
+
isMounted = false;
|
|
118
|
+
childContainerRef.current?.remove();
|
|
119
|
+
childContainerRef.current = null;
|
|
120
|
+
if (mapbox) {
|
|
121
|
+
if (props.reuseMaps) {
|
|
122
|
+
mapbox.recycle();
|
|
123
|
+
} else {
|
|
124
|
+
mapbox.destroy();
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
}, []);
|
|
129
|
+
|
|
130
|
+
// Registration lives in its own effect rather than inside the promise above.
|
|
131
|
+
// `MapProvider` reports a duplicate or reserved map id by throwing from its
|
|
132
|
+
// state updater, and Octane runs updaters synchronously inside the setter, so
|
|
133
|
+
// called from that promise the throw landed in its `.catch` and surfaced as a
|
|
134
|
+
// map `onError` — the channel for style and network failures — instead of as
|
|
135
|
+
// a real error. Here it propagates like any other effect error.
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
if (!mapInstance) {
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
mountedMapsContext?.onMapMount(contextValue.map, props.id);
|
|
141
|
+
return () => mountedMapsContext?.onMapUnmount(props.id);
|
|
142
|
+
}, [mapInstance]);
|
|
143
|
+
|
|
144
|
+
useIsomorphicLayoutEffect(() => {
|
|
145
|
+
if (mapInstance) {
|
|
146
|
+
mapInstance.setProps(props);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
useImperativeHandle(props.ref, () => contextValue.map, [mapInstance]);
|
|
151
|
+
|
|
152
|
+
const style: CSSProperties = useMemo(
|
|
153
|
+
() => ({
|
|
154
|
+
position: 'relative',
|
|
155
|
+
width: '100%',
|
|
156
|
+
height: '100%',
|
|
157
|
+
...props.style,
|
|
158
|
+
}),
|
|
159
|
+
[props.style],
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
// The children container itself is appended by the effect above, next to the
|
|
163
|
+
// DOM mapbox-gl builds; only its contents are rendered here, through a portal.
|
|
164
|
+
// `Marker` and `Popup` already reach their library-owned elements this way.
|
|
165
|
+
<div id={props.id} ref={containerRef} style={style}>
|
|
166
|
+
@if (mapInstance) {
|
|
167
|
+
<MapContext.Provider value={contextValue}>{createPortal(
|
|
168
|
+
props.children,
|
|
169
|
+
childContainerRef.current!,
|
|
170
|
+
)}</MapContext.Provider>
|
|
171
|
+
}
|
|
172
|
+
</div>
|
|
173
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/* global document */
|
|
2
|
+
import {
|
|
3
|
+
createPortal,
|
|
4
|
+
memo,
|
|
5
|
+
useContext,
|
|
6
|
+
useEffect,
|
|
7
|
+
useImperativeHandle,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
} from 'octane';
|
|
11
|
+
import { applyReactStyle } from '../utils/apply-react-style';
|
|
12
|
+
import { hasRenderableChildren } from '../utils/children';
|
|
13
|
+
|
|
14
|
+
import type { PopupInstance, MarkerInstance, MarkerOptions } from '../types/lib';
|
|
15
|
+
import type { MarkerEvent, MarkerDragEvent } from '../types/events';
|
|
16
|
+
import type { CSSProperties, MapChildren, Ref } from '../types/octane';
|
|
17
|
+
|
|
18
|
+
import { MapContext } from './map.tsrx';
|
|
19
|
+
import { arePointsEqual } from '../utils/deep-equal';
|
|
20
|
+
import { compareClassNames } from '../utils/compare-class-names';
|
|
21
|
+
|
|
22
|
+
export type MarkerProps = MarkerOptions & {
|
|
23
|
+
/** Longitude of the anchor location */
|
|
24
|
+
longitude: number;
|
|
25
|
+
/** Latitude of the anchor location */
|
|
26
|
+
latitude: number;
|
|
27
|
+
|
|
28
|
+
popup?: PopupInstance;
|
|
29
|
+
|
|
30
|
+
/** CSS style override, applied to the control's container */
|
|
31
|
+
style?: CSSProperties;
|
|
32
|
+
onClick?: (e: MarkerEvent<MouseEvent>) => void;
|
|
33
|
+
onDragStart?: (e: MarkerDragEvent) => void;
|
|
34
|
+
onDrag?: (e: MarkerDragEvent) => void;
|
|
35
|
+
onDragEnd?: (e: MarkerDragEvent) => void;
|
|
36
|
+
children?: MapChildren;
|
|
37
|
+
/** Octane passes refs as ordinary props; there is no forwardRef. */
|
|
38
|
+
ref?: Ref<MarkerInstance>;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/* eslint-disable complexity,max-statements */
|
|
42
|
+
export const Marker = memo(function Marker(props: MarkerProps) {
|
|
43
|
+
const { map, mapLib } = useContext(MapContext);
|
|
44
|
+
const thisRef = useRef({ props });
|
|
45
|
+
|
|
46
|
+
// The portal target, owned by the binding and stable for the component's
|
|
47
|
+
// whole life. It is deliberately never `marker.getElement()`: aimed there,
|
|
48
|
+
// content that arrives after mount lands inside Mapbox's own pin and both
|
|
49
|
+
// are drawn at once. Here, late content lands somewhere harmless and the
|
|
50
|
+
// marker can be rebuilt around it.
|
|
51
|
+
const contentRef = useRef<HTMLDivElement | null>(null);
|
|
52
|
+
if (contentRef.current === null) {
|
|
53
|
+
contentRef.current = document.createElement('div');
|
|
54
|
+
}
|
|
55
|
+
const contentEl = contentRef.current;
|
|
56
|
+
|
|
57
|
+
// Built from the latest committed props, not the mount-time ones. Upstream
|
|
58
|
+
// constructs once and can safely read mount props; a rebuild here happens
|
|
59
|
+
// after arbitrary updates, and the render body below only heals options it
|
|
60
|
+
// compares against the live instance. `className` is diffed against the
|
|
61
|
+
// previous props instead, and those do not change on the rebuild render, so a
|
|
62
|
+
// marker rebuilt from mount props would silently drop every class toggled
|
|
63
|
+
// since — and then diff further toggles against the wrong baseline.
|
|
64
|
+
const createMarker = (ownElement: boolean): MarkerInstance => {
|
|
65
|
+
const current = thisRef.current.props;
|
|
66
|
+
const options = {
|
|
67
|
+
...current,
|
|
68
|
+
element: ownElement ? contentEl : null,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const mk = new mapLib.Marker(options as unknown as MarkerOptions);
|
|
72
|
+
mk.setLngLat([current.longitude, current.latitude]);
|
|
73
|
+
|
|
74
|
+
mk.on('dragstart', (e) => {
|
|
75
|
+
const evt = e as MarkerDragEvent;
|
|
76
|
+
evt.lngLat = mk.getLngLat();
|
|
77
|
+
thisRef.current.props.onDragStart?.(evt);
|
|
78
|
+
});
|
|
79
|
+
mk.on('drag', (e) => {
|
|
80
|
+
const evt = e as MarkerDragEvent;
|
|
81
|
+
evt.lngLat = mk.getLngLat();
|
|
82
|
+
thisRef.current.props.onDrag?.(evt);
|
|
83
|
+
});
|
|
84
|
+
mk.on('dragend', (e) => {
|
|
85
|
+
const evt = e as MarkerDragEvent;
|
|
86
|
+
evt.lngLat = mk.getLngLat();
|
|
87
|
+
thisRef.current.props.onDragEnd?.(evt);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
return mk;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// A `.tsrx` children block cannot be inspected without evaluating it, and
|
|
94
|
+
// evaluating it here would run any hooks inside it a second time and collide
|
|
95
|
+
// on their call-site slots. Upstream asks `React.Children.forEach` whether it
|
|
96
|
+
// was handed anything truthy; the binding has to infer the same answer from
|
|
97
|
+
// what the block actually rendered into `contentEl`, which it can only see
|
|
98
|
+
// once a commit has happened.
|
|
99
|
+
const [marker, setMarker] = useState<MarkerInstance>(() =>
|
|
100
|
+
createMarker(hasRenderableChildren(props.children)),
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
marker.addTo(map.getMap());
|
|
105
|
+
|
|
106
|
+
return () => {
|
|
107
|
+
marker.remove();
|
|
108
|
+
};
|
|
109
|
+
}, [marker]);
|
|
110
|
+
|
|
111
|
+
// Upstream binds this once and lets the listener die with the element. The
|
|
112
|
+
// port can swap the marker underneath, and `contentEl` outlives that swap, so
|
|
113
|
+
// binding per marker instance would stack a second listener on the same node
|
|
114
|
+
// and fire `onClick` twice.
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
const element = marker.getElement();
|
|
117
|
+
const handleClick = (e: MouseEvent) => {
|
|
118
|
+
thisRef.current.props.onClick?.({
|
|
119
|
+
type: 'click',
|
|
120
|
+
target: marker,
|
|
121
|
+
originalEvent: e,
|
|
122
|
+
});
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
element.addEventListener('click', handleClick);
|
|
126
|
+
return () => element.removeEventListener('click', handleClick);
|
|
127
|
+
}, [marker]);
|
|
128
|
+
|
|
129
|
+
// Reconcile the guess above with what the block really rendered. A block that
|
|
130
|
+
// produced nothing hands the pin back to Mapbox, which is what upstream draws
|
|
131
|
+
// for a falsy child — otherwise a conditional pin that starts hidden leaves an
|
|
132
|
+
// empty element and the marker is invisible. Content that shows up later
|
|
133
|
+
// takes the element back, because upstream would have given a marker with
|
|
134
|
+
// truthy children its own element from the start.
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
if (marker.getElement() === contentEl) {
|
|
137
|
+
if (!hasRenderedContent(contentEl)) {
|
|
138
|
+
setMarker(createMarker(false));
|
|
139
|
+
}
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (hasRenderedContent(contentEl)) {
|
|
144
|
+
setMarker(createMarker(true));
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Children can render on their own schedule — a component whose first
|
|
149
|
+
// paint waits on an effect, a fetch, or a transition — and that never
|
|
150
|
+
// re-renders `Marker`, so there is no render to observe it from.
|
|
151
|
+
const observer = new MutationObserver(() => {
|
|
152
|
+
if (hasRenderedContent(contentEl)) {
|
|
153
|
+
setMarker(createMarker(true));
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
observer.observe(contentEl, { childList: true, subtree: true, characterData: true });
|
|
157
|
+
return () => observer.disconnect();
|
|
158
|
+
}, [marker]);
|
|
159
|
+
|
|
160
|
+
const {
|
|
161
|
+
longitude,
|
|
162
|
+
latitude,
|
|
163
|
+
offset,
|
|
164
|
+
style,
|
|
165
|
+
draggable = false,
|
|
166
|
+
popup = null,
|
|
167
|
+
rotation = 0,
|
|
168
|
+
rotationAlignment = 'auto',
|
|
169
|
+
pitchAlignment = 'auto',
|
|
170
|
+
} = props;
|
|
171
|
+
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
applyReactStyle(marker.getElement(), style);
|
|
174
|
+
}, [style, marker]);
|
|
175
|
+
|
|
176
|
+
useImperativeHandle(props.ref, () => marker, [marker]);
|
|
177
|
+
|
|
178
|
+
const oldProps = thisRef.current.props;
|
|
179
|
+
if (marker.getLngLat().lng !== longitude || marker.getLngLat().lat !== latitude) {
|
|
180
|
+
marker.setLngLat([longitude, latitude]);
|
|
181
|
+
}
|
|
182
|
+
if (offset && !arePointsEqual(marker.getOffset(), offset)) {
|
|
183
|
+
marker.setOffset(offset);
|
|
184
|
+
}
|
|
185
|
+
if (marker.isDraggable() !== draggable) {
|
|
186
|
+
marker.setDraggable(draggable);
|
|
187
|
+
}
|
|
188
|
+
if (marker.getRotation() !== rotation) {
|
|
189
|
+
marker.setRotation(rotation);
|
|
190
|
+
}
|
|
191
|
+
if (marker.getRotationAlignment() !== rotationAlignment) {
|
|
192
|
+
marker.setRotationAlignment(rotationAlignment);
|
|
193
|
+
}
|
|
194
|
+
if (marker.getPitchAlignment() !== pitchAlignment) {
|
|
195
|
+
marker.setPitchAlignment(pitchAlignment);
|
|
196
|
+
}
|
|
197
|
+
if (marker.getPopup() !== popup) {
|
|
198
|
+
marker.setPopup(popup);
|
|
199
|
+
}
|
|
200
|
+
const classNameDiff = compareClassNames(oldProps.className, props.className);
|
|
201
|
+
if (classNameDiff) {
|
|
202
|
+
for (const c of classNameDiff) {
|
|
203
|
+
marker.toggleClassName(c);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
thisRef.current.props = props;
|
|
208
|
+
return createPortal(props.children, contentEl);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Portal anchors are comments, so elements and text are what say whether a
|
|
213
|
+
* children block rendered anything at all.
|
|
214
|
+
*/
|
|
215
|
+
function hasRenderedContent(element: HTMLElement): boolean {
|
|
216
|
+
return element.children.length > 0 || (element.textContent ?? '').trim() !== '';
|
|
217
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { memo, useEffect } from 'octane';
|
|
2
|
+
import { applyReactStyle } from '../utils/apply-react-style';
|
|
3
|
+
import { useControl } from './use-control';
|
|
4
|
+
|
|
5
|
+
import type { ControlPosition, NavigationControlOptions } from '../types/lib';
|
|
6
|
+
import type { CSSProperties } from '../types/octane';
|
|
7
|
+
|
|
8
|
+
export type NavigationControlProps = NavigationControlOptions & {
|
|
9
|
+
/** Placement of the control relative to the map. */
|
|
10
|
+
position?: ControlPosition;
|
|
11
|
+
/** CSS style override, applied to the control's container */
|
|
12
|
+
style?: CSSProperties;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function _NavigationControl(props: NavigationControlProps) {
|
|
16
|
+
const ctrl = useControl(({ mapLib }) => new mapLib.NavigationControl(props), {
|
|
17
|
+
position: props.position,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
applyReactStyle(ctrl._container, props.style);
|
|
22
|
+
}, [props.style]);
|
|
23
|
+
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const NavigationControl = memo(_NavigationControl);
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/* global document */
|
|
2
|
+
import {
|
|
3
|
+
createPortal,
|
|
4
|
+
memo,
|
|
5
|
+
useContext,
|
|
6
|
+
useEffect,
|
|
7
|
+
useImperativeHandle,
|
|
8
|
+
useMemo,
|
|
9
|
+
useRef,
|
|
10
|
+
} from 'octane';
|
|
11
|
+
import { applyReactStyle } from '../utils/apply-react-style';
|
|
12
|
+
|
|
13
|
+
import type { PopupInstance, PopupOptions } from '../types/lib';
|
|
14
|
+
import type { PopupEvent } from '../types/events';
|
|
15
|
+
import type { CSSProperties, MapChildren, Ref } from '../types/octane';
|
|
16
|
+
|
|
17
|
+
import { MapContext } from './map.tsrx';
|
|
18
|
+
import { deepEqual } from '../utils/deep-equal';
|
|
19
|
+
import { compareClassNames } from '../utils/compare-class-names';
|
|
20
|
+
|
|
21
|
+
export type PopupProps = PopupOptions & {
|
|
22
|
+
/** Longitude of the anchor location */
|
|
23
|
+
longitude: number;
|
|
24
|
+
/** Latitude of the anchor location */
|
|
25
|
+
latitude: number;
|
|
26
|
+
|
|
27
|
+
/** CSS style override, applied to the control's container */
|
|
28
|
+
style?: CSSProperties;
|
|
29
|
+
|
|
30
|
+
onOpen?: (e: PopupEvent) => void;
|
|
31
|
+
onClose?: (e: PopupEvent) => void;
|
|
32
|
+
children?: MapChildren;
|
|
33
|
+
/** Octane passes refs as ordinary props; there is no forwardRef. */
|
|
34
|
+
ref?: Ref<PopupInstance>;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/* eslint-disable complexity,max-statements */
|
|
38
|
+
export const Popup = memo(function Popup(props: PopupProps) {
|
|
39
|
+
const { map, mapLib } = useContext(MapContext);
|
|
40
|
+
const container = useMemo(() => {
|
|
41
|
+
return document.createElement('div');
|
|
42
|
+
}, []);
|
|
43
|
+
const thisRef = useRef({ props });
|
|
44
|
+
|
|
45
|
+
const popup: PopupInstance = useMemo(() => {
|
|
46
|
+
const options = { ...props };
|
|
47
|
+
const pp = new mapLib.Popup(options);
|
|
48
|
+
pp.setLngLat([props.longitude, props.latitude]);
|
|
49
|
+
pp.once('open', (e) => {
|
|
50
|
+
thisRef.current.props.onOpen?.(e as PopupEvent);
|
|
51
|
+
});
|
|
52
|
+
return pp;
|
|
53
|
+
}, []);
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
const onClose = (e: unknown) => {
|
|
57
|
+
thisRef.current.props.onClose?.(e as PopupEvent);
|
|
58
|
+
};
|
|
59
|
+
popup.on('close', onClose);
|
|
60
|
+
popup.setDOMContent(container).addTo(map.getMap());
|
|
61
|
+
|
|
62
|
+
return () => {
|
|
63
|
+
// https://github.com/visgl/react-map-gl/issues/1825
|
|
64
|
+
// onClose should not be fired if the popup is removed by unmounting
|
|
65
|
+
popup.off('close', onClose);
|
|
66
|
+
if (popup.isOpen()) {
|
|
67
|
+
popup.remove();
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
}, []);
|
|
71
|
+
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
applyReactStyle(popup.getElement(), props.style);
|
|
74
|
+
}, [props.style]);
|
|
75
|
+
|
|
76
|
+
useImperativeHandle(props.ref, () => popup, []);
|
|
77
|
+
|
|
78
|
+
if (popup.isOpen()) {
|
|
79
|
+
const oldProps = thisRef.current.props;
|
|
80
|
+
if (popup.getLngLat().lng !== props.longitude || popup.getLngLat().lat !== props.latitude) {
|
|
81
|
+
popup.setLngLat([props.longitude, props.latitude]);
|
|
82
|
+
}
|
|
83
|
+
if (props.offset && !deepEqual(oldProps.offset, props.offset)) {
|
|
84
|
+
popup.options.anchor = props.anchor;
|
|
85
|
+
popup.setOffset(props.offset);
|
|
86
|
+
}
|
|
87
|
+
if (oldProps.anchor !== props.anchor || oldProps.maxWidth !== props.maxWidth) {
|
|
88
|
+
popup.setMaxWidth(props.maxWidth!);
|
|
89
|
+
}
|
|
90
|
+
const classNameDiff = compareClassNames(oldProps.className, props.className);
|
|
91
|
+
if (classNameDiff) {
|
|
92
|
+
for (const c of classNameDiff) {
|
|
93
|
+
popup.toggleClassName(c);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
thisRef.current.props = props;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return createPortal(props.children, container);
|
|
100
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { memo, useEffect, useRef } from 'octane';
|
|
2
|
+
import { applyReactStyle } from '../utils/apply-react-style';
|
|
3
|
+
import { useControl } from './use-control';
|
|
4
|
+
|
|
5
|
+
import type { ControlPosition, ScaleControlOptions } from '../types/lib';
|
|
6
|
+
import type { CSSProperties } from '../types/octane';
|
|
7
|
+
|
|
8
|
+
export type ScaleControlProps = ScaleControlOptions & {
|
|
9
|
+
// These props will be further constraint by OptionsT
|
|
10
|
+
unit?: string;
|
|
11
|
+
maxWidth?: number;
|
|
12
|
+
|
|
13
|
+
/** Placement of the control relative to the map. */
|
|
14
|
+
position?: ControlPosition;
|
|
15
|
+
/** CSS style override, applied to the control's container */
|
|
16
|
+
style?: CSSProperties;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function _ScaleControl(props: ScaleControlProps) {
|
|
20
|
+
const ctrl = useControl(({ mapLib }) => new mapLib.ScaleControl(props), {
|
|
21
|
+
position: props.position,
|
|
22
|
+
});
|
|
23
|
+
const propsRef = useRef<ScaleControlProps>(props);
|
|
24
|
+
|
|
25
|
+
const prevProps = propsRef.current;
|
|
26
|
+
propsRef.current = props;
|
|
27
|
+
|
|
28
|
+
const { style } = props;
|
|
29
|
+
|
|
30
|
+
if (props.maxWidth !== undefined && props.maxWidth !== prevProps.maxWidth) {
|
|
31
|
+
ctrl.options.maxWidth = props.maxWidth;
|
|
32
|
+
}
|
|
33
|
+
if (props.unit !== undefined && props.unit !== prevProps.unit) {
|
|
34
|
+
ctrl.setUnit(props.unit);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
applyReactStyle(ctrl._container, style);
|
|
39
|
+
}, [style]);
|
|
40
|
+
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const ScaleControl = memo(_ScaleControl);
|