@trackunit/react-map 0.1.50 → 0.1.51
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/index.cjs.js +133 -48
- package/index.esm.js +133 -48
- package/package.json +1 -1
- package/src/layers/useShapes/useShapeFillTiling.d.ts +5 -3
- package/src/panel/Panel.d.ts +8 -1
- package/src/panel/PanelOrchestrator.d.ts +5 -1
package/index.cjs.js
CHANGED
|
@@ -818,7 +818,7 @@ const PANEL_SCROLL_INTERNAL_CLASS_NAME = "flex flex-1 min-h-0 flex-col overflow-
|
|
|
818
818
|
*
|
|
819
819
|
* @internal
|
|
820
820
|
*/
|
|
821
|
-
const Panel = ({ anchor, placement = DEFAULT_PLACEMENT$1, onDismiss, closeLabel = "Close", onCommit, containerEl = null, "aria-label": ariaLabel, contentScroll = "shell", children, }) => {
|
|
821
|
+
const Panel = ({ anchor, placement = DEFAULT_PLACEMENT$1, onDismiss, closeLabel = "Close", onCommit, containerEl = null, mapMoveSource, "aria-label": ariaLabel, contentScroll = "shell", children, }) => {
|
|
822
822
|
const scrollSlotClassName = contentScroll === "internal" ? PANEL_SCROLL_INTERNAL_CLASS_NAME : PANEL_SCROLL_SHELL_CLASS_NAME;
|
|
823
823
|
const shellRef = react.useRef(null);
|
|
824
824
|
const scrollRef = react.useRef(null);
|
|
@@ -876,7 +876,7 @@ const Panel = ({ anchor, placement = DEFAULT_PLACEMENT$1, onDismiss, closeLabel
|
|
|
876
876
|
react$1.shift({ padding: 8, ...(containerEl !== null ? { boundary: containerEl } : {}) }),
|
|
877
877
|
react$1.hide(),
|
|
878
878
|
], [containerEl]);
|
|
879
|
-
const { context, floatingStyles, middlewareData, placement: resolvedPlacement, elements, refs: { setFloating }, } = react$1.useFloating({
|
|
879
|
+
const { context, floatingStyles, middlewareData, placement: resolvedPlacement, elements, refs: { setFloating }, update, } = react$1.useFloating({
|
|
880
880
|
open: effectiveAnchor !== null,
|
|
881
881
|
onOpenChange: nextOpen => {
|
|
882
882
|
if (!nextOpen)
|
|
@@ -886,8 +886,38 @@ const Panel = ({ anchor, placement = DEFAULT_PLACEMENT$1, onDismiss, closeLabel
|
|
|
886
886
|
strategy: "absolute",
|
|
887
887
|
middleware,
|
|
888
888
|
elements: { reference: effectiveAnchor },
|
|
889
|
-
whileElementsMounted: (reference, floating, autoUpdateFn) => react$1.autoUpdate(reference, floating, autoUpdateFn
|
|
889
|
+
whileElementsMounted: (reference, floating, autoUpdateFn) => react$1.autoUpdate(reference, floating, autoUpdateFn),
|
|
890
890
|
});
|
|
891
|
+
// Reposition while the map pans/zooms: anchors move without scroll/resize events.
|
|
892
|
+
react.useEffect(() => {
|
|
893
|
+
if (mapMoveSource === undefined || effectiveAnchor === null)
|
|
894
|
+
return;
|
|
895
|
+
let rafId = null;
|
|
896
|
+
const loop = () => {
|
|
897
|
+
update();
|
|
898
|
+
rafId = requestAnimationFrame(loop);
|
|
899
|
+
};
|
|
900
|
+
const stopLoop = () => {
|
|
901
|
+
if (rafId !== null) {
|
|
902
|
+
cancelAnimationFrame(rafId);
|
|
903
|
+
rafId = null;
|
|
904
|
+
}
|
|
905
|
+
};
|
|
906
|
+
const offMoveStart = mapMoveSource.on("movestart", () => {
|
|
907
|
+
if (rafId === null) {
|
|
908
|
+
rafId = requestAnimationFrame(loop);
|
|
909
|
+
}
|
|
910
|
+
});
|
|
911
|
+
const offMoveEnd = mapMoveSource.on("moveend", () => {
|
|
912
|
+
stopLoop();
|
|
913
|
+
update();
|
|
914
|
+
});
|
|
915
|
+
return () => {
|
|
916
|
+
offMoveStart();
|
|
917
|
+
offMoveEnd();
|
|
918
|
+
stopLoop();
|
|
919
|
+
};
|
|
920
|
+
}, [effectiveAnchor, mapMoveSource, update]);
|
|
891
921
|
// Hide until the first placement commit so there is no flash at (0,0).
|
|
892
922
|
const isCommitted = middlewareData.hide !== undefined;
|
|
893
923
|
// Do NOT re-enable outsidePress here (neither `true` nor `outsidePressEvent: "click"`).
|
|
@@ -950,13 +980,13 @@ const sortByPriority = (descriptors) => {
|
|
|
950
980
|
*
|
|
951
981
|
* @internal
|
|
952
982
|
*/
|
|
953
|
-
const PanelOrchestrator = ({ store, containerEl, anchoredPortalEl }) => {
|
|
983
|
+
const PanelOrchestrator = ({ store, containerEl, mapMoveSource, anchoredPortalEl }) => {
|
|
954
984
|
const descriptors = react.useSyncExternalStore(store.subscribe, store.getSnapshot);
|
|
955
985
|
const sortedDescriptors = react.useMemo(() => sortByPriority(descriptors), [descriptors]);
|
|
956
986
|
if (sortedDescriptors.length === 0) {
|
|
957
987
|
return null;
|
|
958
988
|
}
|
|
959
|
-
const anchoredContent = sortedDescriptors.map(descriptor => (jsxRuntime.jsx(Panel, { anchor: descriptor.anchor, "aria-label": descriptor["aria-label"], closeLabel: descriptor.closeLabel, containerEl: containerEl, contentScroll: descriptor.contentScroll, onCommit: descriptor.onCommit, onDismiss: descriptor.onDismiss, placement: descriptor.placement, children: descriptor.children }, descriptor.id)));
|
|
989
|
+
const anchoredContent = sortedDescriptors.map(descriptor => (jsxRuntime.jsx(Panel, { anchor: descriptor.anchor, "aria-label": descriptor["aria-label"], closeLabel: descriptor.closeLabel, containerEl: containerEl, contentScroll: descriptor.contentScroll, mapMoveSource: mapMoveSource, onCommit: descriptor.onCommit, onDismiss: descriptor.onDismiss, placement: descriptor.placement, children: descriptor.children }, descriptor.id)));
|
|
960
990
|
return anchoredPortalEl !== null && anchoredPortalEl !== undefined ? (reactDom.createPortal(anchoredContent, anchoredPortalEl)) : (jsxRuntime.jsx(jsxRuntime.Fragment, { children: anchoredContent }));
|
|
961
991
|
};
|
|
962
992
|
PanelOrchestrator.displayName = "PanelOrchestrator";
|
|
@@ -1144,11 +1174,12 @@ const createMapComponent = ({ adapter, Renderer, adapterName, containerRefHolder
|
|
|
1144
1174
|
availableSpace,
|
|
1145
1175
|
responsiveMode,
|
|
1146
1176
|
}), [availableSpace, responsiveMode]);
|
|
1177
|
+
const mapMoveSource = react.useMemo(() => ({ on: adapter.on.bind(adapter) }), []);
|
|
1147
1178
|
const [safeAreaContentEl, setSafeAreaContentEl] = react.useState(null);
|
|
1148
1179
|
const [safeAreaPanelPortalEl, setSafeAreaPanelPortalEl] = react.useState(null);
|
|
1149
1180
|
const content = (jsxRuntime.jsxs("div", { "aria-describedby": ariaDescribedBy, "aria-label": ariaLabel ?? "Interactive map", className: tailwindMerge.twMerge(
|
|
1150
1181
|
// ZStack: full-size grid with a single cell so children overlay the map
|
|
1151
|
-
"isolate grid h-full w-full grid-cols-1 grid-rows-1", className), "data-testid": dataTestId, id: containerId, ref: mergedRef, role: "application", style: style, tabIndex: 0, children: [jsxRuntime.jsxs("div", { className: "relative col-start-1 row-start-1 grid h-full min-h-[200px] w-full", children: [jsxRuntime.jsx(Renderer, { adapterInstance: adapter, className: "h-full w-full", style: { height: "100%", width: "100%" } }), jsxRuntime.jsx(AdapterReadinessOverlay, { adapter: adapter })] }), jsxRuntime.jsx("div", { className: "pointer-events-none absolute inset-0 col-start-1 row-start-1 overflow-visible", "data-testid": "map-marker-overlay-portal" }), jsxRuntime.jsx("div", { className: "pointer-events-none relative isolate col-start-1 row-start-1 h-full min-h-0 w-full overflow-clip", "data-testid": "map-panel-overlay-portal", ref: setSafeAreaPanelPortalEl }), panelStore !== undefined ? (jsxRuntime.jsx(PanelOrchestrator, { anchoredPortalEl: safeAreaPanelPortalEl, containerEl: safeAreaContentEl ?? containerEl, store: panelStore })) : null, jsxRuntime.jsx(SafeAreaLayoutContext.Provider, { value: layout, children: jsxRuntime.jsxs(SafeAreaOverlay, { safeAreaContentRef: setSafeAreaContentEl, safeAreaInsets: safeAreaInsets ?? null, children: [children, annotationStore !== undefined ? jsxRuntime.jsx(FallbackAnnotationOverlay, { store: annotationStore }) : null, loadingIndicatorStore !== undefined ? (jsxRuntime.jsx(LoadingOverlay, { adapter: adapter, store: loadingIndicatorStore })) : null] }) })] }));
|
|
1182
|
+
"isolate grid h-full w-full grid-cols-1 grid-rows-1", className), "data-testid": dataTestId, id: containerId, ref: mergedRef, role: "application", style: style, tabIndex: 0, children: [jsxRuntime.jsxs("div", { className: "relative col-start-1 row-start-1 grid h-full min-h-[200px] w-full", children: [jsxRuntime.jsx(Renderer, { adapterInstance: adapter, className: "h-full w-full", style: { height: "100%", width: "100%" } }), jsxRuntime.jsx(AdapterReadinessOverlay, { adapter: adapter })] }), jsxRuntime.jsx("div", { className: "pointer-events-none absolute inset-0 col-start-1 row-start-1 overflow-visible", "data-testid": "map-marker-overlay-portal" }), jsxRuntime.jsx("div", { className: "pointer-events-none relative isolate col-start-1 row-start-1 h-full min-h-0 w-full overflow-clip", "data-testid": "map-panel-overlay-portal", ref: setSafeAreaPanelPortalEl }), panelStore !== undefined ? (jsxRuntime.jsx(PanelOrchestrator, { anchoredPortalEl: safeAreaPanelPortalEl, containerEl: safeAreaContentEl ?? containerEl, mapMoveSource: mapMoveSource, store: panelStore })) : null, jsxRuntime.jsx(SafeAreaLayoutContext.Provider, { value: layout, children: jsxRuntime.jsxs(SafeAreaOverlay, { safeAreaContentRef: setSafeAreaContentEl, safeAreaInsets: safeAreaInsets ?? null, children: [children, annotationStore !== undefined ? jsxRuntime.jsx(FallbackAnnotationOverlay, { store: annotationStore }) : null, loadingIndicatorStore !== undefined ? (jsxRuntime.jsx(LoadingOverlay, { adapter: adapter, store: loadingIndicatorStore })) : null] }) })] }));
|
|
1152
1183
|
// Provide the adapter's layer port to children (e.g. <Layers>)
|
|
1153
1184
|
// so they can communicate layer data to the map adapter.
|
|
1154
1185
|
const layerPort = adapter.layers;
|
|
@@ -9977,6 +10008,11 @@ const DEFAULT_STROKE_WIDTH = 2;
|
|
|
9977
10008
|
* (which equals groupSize, bounded by the number of features in a viewport).
|
|
9978
10009
|
*/
|
|
9979
10010
|
const HOVER_PROMOTION_Z_INDEX = 10000;
|
|
10011
|
+
/**
|
|
10012
|
+
* Z-index for selection-forces-front promotion. Must exceed
|
|
10013
|
+
* {@link HOVER_PROMOTION_Z_INDEX} so selection outranks sticky hover.
|
|
10014
|
+
*/
|
|
10015
|
+
const SELECTED_PROMOTION_Z_INDEX = 20000;
|
|
9980
10016
|
/**
|
|
9981
10017
|
* FNV-1a 32-bit hash over an array of coordinate numbers. Cheap and
|
|
9982
10018
|
* change-detecting: any moved vertex changes the output; identical polls keep
|
|
@@ -10108,7 +10144,7 @@ const pickFillPromotionCandidates = (fillHits, compareByArea) => {
|
|
|
10108
10144
|
*
|
|
10109
10145
|
* @internal
|
|
10110
10146
|
*/
|
|
10111
|
-
const useShapeFillTiling = ({ api, handles, onFillGeometriesChange, onZIndexOverridesChange, onSettledHover, selectedFeatureId, suppressedFillIdsByHandle, }) => {
|
|
10147
|
+
const useShapeFillTiling = ({ api, handles, onFillGeometriesChange, onZIndexOverridesChange, onSettledHover, selectedFeatureId, selectedHandleId, suppressedFillIdsByHandle, }) => {
|
|
10112
10148
|
const tilingContentKey = react.useMemo(() => buildTilingContentKey(handles), [handles]);
|
|
10113
10149
|
const tilingHandles = react.useMemo(() => handles.filter((handle) => handle.layerType === "shapes" && handle.overlap?.mode === "tile"), [handles]);
|
|
10114
10150
|
const hasTilingHandles = tilingHandles.length > 0;
|
|
@@ -10125,18 +10161,12 @@ const useShapeFillTiling = ({ api, handles, onFillGeometriesChange, onZIndexOver
|
|
|
10125
10161
|
react.useEffect(() => {
|
|
10126
10162
|
onSettledHoverRef.current = onSettledHover;
|
|
10127
10163
|
}, [onSettledHover]);
|
|
10128
|
-
const selectedFeatureIdRef = react.useRef(selectedFeatureId);
|
|
10129
|
-
react.useEffect(() => {
|
|
10130
|
-
selectedFeatureIdRef.current = selectedFeatureId;
|
|
10131
|
-
}, [selectedFeatureId]);
|
|
10132
10164
|
const suppressedFillIdsByHandleRef = react.useRef(suppressedFillIdsByHandle);
|
|
10133
10165
|
react.useEffect(() => {
|
|
10134
10166
|
suppressedFillIdsByHandleRef.current = suppressedFillIdsByHandle;
|
|
10135
10167
|
}, [suppressedFillIdsByHandle]);
|
|
10136
10168
|
const debounceTimerRef = react.useRef(null);
|
|
10137
10169
|
const idleRecomputeTimerRef = react.useRef(null);
|
|
10138
|
-
/** Pending deferred selection re-tile (double-rAF handle); see selection effect. */
|
|
10139
|
-
const selectionRecomputeRafRef = react.useRef(null);
|
|
10140
10170
|
// Currently emitted overrides — used for hit-testing visible fills in handleSettle.
|
|
10141
10171
|
const prevFillOverridesRef = react.useRef(EMPTY_OVERRIDES);
|
|
10142
10172
|
const prevZIndexOverridesRef = react.useRef(EMPTY_ZINDEX_OVERRIDES);
|
|
@@ -10145,10 +10175,15 @@ const useShapeFillTiling = ({ api, handles, onFillGeometriesChange, onZIndexOver
|
|
|
10145
10175
|
const restingZIndexByHandleRef = react.useRef(new Map());
|
|
10146
10176
|
// Per-handle: groupKey → promoted feature id (sticky hover winner).
|
|
10147
10177
|
const promotedByHandleRef = react.useRef(new Map());
|
|
10178
|
+
// Per-handle: groupKey → selected feature id (selection-forces-front).
|
|
10179
|
+
const selectedPromotedByHandleRef = react.useRef(new Map());
|
|
10180
|
+
const prevSelectedPromotionRef = react.useRef(null);
|
|
10148
10181
|
// Per-handle: featureId → groupKey (populated from last computeFillTiling result).
|
|
10149
10182
|
const featureToGroupKeyByHandleRef = react.useRef(new Map());
|
|
10150
10183
|
/** True while an interactive shape decoration (edge label, badge, …) is hovered. */
|
|
10151
10184
|
const decorationHoverActiveRef = react.useRef(false);
|
|
10185
|
+
/** Re-applies `prevSelectedPromotionRef` after recompute populates group keys. */
|
|
10186
|
+
const retrySelectionPromotionRef = react.useRef(null);
|
|
10152
10187
|
// Stable ref to current tiling handles for the debounce callback.
|
|
10153
10188
|
const tilingHandlesRef = react.useRef(tilingHandles);
|
|
10154
10189
|
react.useEffect(() => {
|
|
@@ -10168,6 +10203,27 @@ const useShapeFillTiling = ({ api, handles, onFillGeometriesChange, onZIndexOver
|
|
|
10168
10203
|
react.useEffect(() => {
|
|
10169
10204
|
tilingContentKeyRef.current = tilingContentKey;
|
|
10170
10205
|
}, [tilingContentKey]);
|
|
10206
|
+
/**
|
|
10207
|
+
* Merge selection and hover promotion maps per group. Selection wins over hover.
|
|
10208
|
+
*/
|
|
10209
|
+
const resolveGroupWinners = (handleId) => {
|
|
10210
|
+
const winners = new Map();
|
|
10211
|
+
const selected = selectedPromotedByHandleRef.current.get(handleId);
|
|
10212
|
+
if (selected !== undefined) {
|
|
10213
|
+
for (const [groupKey, featureId] of selected) {
|
|
10214
|
+
winners.set(groupKey, { featureId, channel: "selection" });
|
|
10215
|
+
}
|
|
10216
|
+
}
|
|
10217
|
+
const hover = promotedByHandleRef.current.get(handleId);
|
|
10218
|
+
if (hover !== undefined) {
|
|
10219
|
+
for (const [groupKey, featureId] of hover) {
|
|
10220
|
+
if (!winners.has(groupKey)) {
|
|
10221
|
+
winners.set(groupKey, { featureId, channel: "hover" });
|
|
10222
|
+
}
|
|
10223
|
+
}
|
|
10224
|
+
}
|
|
10225
|
+
return winners;
|
|
10226
|
+
};
|
|
10171
10227
|
/**
|
|
10172
10228
|
* Apply the current promotion state on top of resting fills and z-indices and emit
|
|
10173
10229
|
* the result. Called both at the end of `recompute` and directly on hover-settle
|
|
@@ -10175,22 +10231,23 @@ const useShapeFillTiling = ({ api, handles, onFillGeometriesChange, onZIndexOver
|
|
|
10175
10231
|
*
|
|
10176
10232
|
* Active promotion: winner unclipped; peers in the same group are clipped against
|
|
10177
10233
|
* the winner's full geometry so exclusive rings stay painted without compounding.
|
|
10234
|
+
* Selection channel outranks hover per group.
|
|
10178
10235
|
*/
|
|
10179
10236
|
const emitWithPromotion = react.useCallback(() => {
|
|
10180
10237
|
const nextFills = new Map();
|
|
10181
10238
|
const nextZIndices = new Map();
|
|
10182
10239
|
const handleById = new Map(tilingHandlesRef.current.map(handle => [handle.id, handle]));
|
|
10183
10240
|
for (const [handleId, restingFills] of restingFillByHandleRef.current) {
|
|
10184
|
-
const
|
|
10241
|
+
const groupWinners = resolveGroupWinners(handleId);
|
|
10185
10242
|
const featureToGroupKey = featureToGroupKeyByHandleRef.current.get(handleId);
|
|
10186
|
-
if (
|
|
10243
|
+
if (groupWinners.size === 0) {
|
|
10187
10244
|
if (restingFills.size > 0)
|
|
10188
10245
|
nextFills.set(handleId, restingFills);
|
|
10189
10246
|
}
|
|
10190
10247
|
else if (featureToGroupKey === undefined) {
|
|
10191
10248
|
const modifiedFills = new Map(restingFills);
|
|
10192
|
-
for (const
|
|
10193
|
-
modifiedFills.delete(
|
|
10249
|
+
for (const { featureId } of groupWinners.values()) {
|
|
10250
|
+
modifiedFills.delete(featureId);
|
|
10194
10251
|
}
|
|
10195
10252
|
if (modifiedFills.size > 0)
|
|
10196
10253
|
nextFills.set(handleId, modifiedFills);
|
|
@@ -10205,7 +10262,7 @@ const useShapeFillTiling = ({ api, handles, onFillGeometriesChange, onZIndexOver
|
|
|
10205
10262
|
}
|
|
10206
10263
|
}
|
|
10207
10264
|
const modifiedFills = new Map(restingFills);
|
|
10208
|
-
for (const [groupKey, winnerId] of
|
|
10265
|
+
for (const [groupKey, { featureId: winnerId }] of groupWinners) {
|
|
10209
10266
|
const groupMembers = [];
|
|
10210
10267
|
for (const [featureId, memberGroupKey] of featureToGroupKey) {
|
|
10211
10268
|
if (memberGroupKey !== groupKey)
|
|
@@ -10233,16 +10290,16 @@ const useShapeFillTiling = ({ api, handles, onFillGeometriesChange, onZIndexOver
|
|
|
10233
10290
|
}
|
|
10234
10291
|
}
|
|
10235
10292
|
for (const [handleId, restingZIndices] of restingZIndexByHandleRef.current) {
|
|
10236
|
-
const
|
|
10237
|
-
if (
|
|
10293
|
+
const groupWinners = resolveGroupWinners(handleId);
|
|
10294
|
+
if (groupWinners.size === 0) {
|
|
10238
10295
|
if (restingZIndices.size > 0)
|
|
10239
10296
|
nextZIndices.set(handleId, restingZIndices);
|
|
10240
10297
|
}
|
|
10241
10298
|
else {
|
|
10242
10299
|
const featureToGroupKey = featureToGroupKeyByHandleRef.current.get(handleId);
|
|
10243
10300
|
const modifiedZIndices = new Map(restingZIndices);
|
|
10244
|
-
for (const [groupKey, winnerId] of
|
|
10245
|
-
modifiedZIndices.set(winnerId, HOVER_PROMOTION_Z_INDEX);
|
|
10301
|
+
for (const [groupKey, { featureId: winnerId, channel }] of groupWinners) {
|
|
10302
|
+
modifiedZIndices.set(winnerId, channel === "selection" ? SELECTED_PROMOTION_Z_INDEX : HOVER_PROMOTION_Z_INDEX);
|
|
10246
10303
|
if (featureToGroupKey !== undefined) {
|
|
10247
10304
|
for (const [featureId, memberGroupKey] of featureToGroupKey) {
|
|
10248
10305
|
if (memberGroupKey === groupKey && featureId !== winnerId) {
|
|
@@ -10296,7 +10353,7 @@ const useShapeFillTiling = ({ api, handles, onFillGeometriesChange, onZIndexOver
|
|
|
10296
10353
|
// Zoom is intentionally excluded too: fill-tiling stack order is zoom-invariant
|
|
10297
10354
|
// by contract, so zooming can reuse the same resting clips and avoid a full
|
|
10298
10355
|
// polygon-clipping pass for an identical result.
|
|
10299
|
-
const inputKey = `${tilingContentKeyRef.current}|${
|
|
10356
|
+
const inputKey = `${tilingContentKeyRef.current}|${suppressedKey}|${geodesicKey}`;
|
|
10300
10357
|
if (inputKey === lastComputeInputKeyRef.current) {
|
|
10301
10358
|
return;
|
|
10302
10359
|
}
|
|
@@ -10308,6 +10365,7 @@ const useShapeFillTiling = ({ api, handles, onFillGeometriesChange, onZIndexOver
|
|
|
10308
10365
|
prevFillOverridesRef.current = EMPTY_OVERRIDES;
|
|
10309
10366
|
prevZIndexOverridesRef.current = EMPTY_ZINDEX_OVERRIDES;
|
|
10310
10367
|
promotedByHandleRef.current = new Map();
|
|
10368
|
+
selectedPromotedByHandleRef.current = new Map();
|
|
10311
10369
|
featureToGroupKeyByHandleRef.current = new Map();
|
|
10312
10370
|
onFillChangeRef.current(EMPTY_OVERRIDES);
|
|
10313
10371
|
onZIndexChangeRef.current(EMPTY_ZINDEX_OVERRIDES);
|
|
@@ -10324,7 +10382,6 @@ const useShapeFillTiling = ({ api, handles, onFillGeometriesChange, onZIndexOver
|
|
|
10324
10382
|
features: handle.features.features,
|
|
10325
10383
|
viewportBounds: GLOBAL_BOUNDS,
|
|
10326
10384
|
resolveStackOrder: handle.overlap?.resolveStackOrder ?? defaultShapeStackOrder,
|
|
10327
|
-
selectedFeatureId: selectedFeatureIdRef.current,
|
|
10328
10385
|
suppressedFeatureIds: suppressedFillIdsByHandleRef.current?.get(handle.id),
|
|
10329
10386
|
layerGeodesic: handle.style.geodesic,
|
|
10330
10387
|
featureStyles: handle.featureStyles,
|
|
@@ -10339,6 +10396,7 @@ const useShapeFillTiling = ({ api, handles, onFillGeometriesChange, onZIndexOver
|
|
|
10339
10396
|
}
|
|
10340
10397
|
restingFillByHandleRef.current = nextRestingFills;
|
|
10341
10398
|
restingZIndexByHandleRef.current = nextRestingZIndices;
|
|
10399
|
+
retrySelectionPromotionRef.current?.();
|
|
10342
10400
|
emitWithPromotion();
|
|
10343
10401
|
}, [emitWithPromotion]);
|
|
10344
10402
|
const flushPendingRestingRecompute = react.useCallback(() => {
|
|
@@ -10359,6 +10417,40 @@ const useShapeFillTiling = ({ api, handles, onFillGeometriesChange, onZIndexOver
|
|
|
10359
10417
|
flushPendingRestingRecompute();
|
|
10360
10418
|
}, IDLE_RECOMPUTE_DEBOUNCE_MS);
|
|
10361
10419
|
}, [flushPendingRestingRecompute]);
|
|
10420
|
+
const clearSelectionPromotion = react.useCallback((handleId, featureId) => {
|
|
10421
|
+
const groupKey = featureToGroupKeyByHandleRef.current.get(handleId)?.get(featureId);
|
|
10422
|
+
if (groupKey === undefined)
|
|
10423
|
+
return;
|
|
10424
|
+
const handleMap = selectedPromotedByHandleRef.current.get(handleId);
|
|
10425
|
+
if (handleMap === undefined || handleMap.get(groupKey) !== featureId)
|
|
10426
|
+
return;
|
|
10427
|
+
handleMap.delete(groupKey);
|
|
10428
|
+
if (handleMap.size === 0)
|
|
10429
|
+
selectedPromotedByHandleRef.current.delete(handleId);
|
|
10430
|
+
emitWithPromotion();
|
|
10431
|
+
}, [emitWithPromotion]);
|
|
10432
|
+
const promoteSelection = react.useCallback((handleId, featureId) => {
|
|
10433
|
+
const groupKey = featureToGroupKeyByHandleRef.current.get(handleId)?.get(featureId);
|
|
10434
|
+
if (groupKey === undefined)
|
|
10435
|
+
return;
|
|
10436
|
+
let handleMap = selectedPromotedByHandleRef.current.get(handleId);
|
|
10437
|
+
if (handleMap === undefined) {
|
|
10438
|
+
handleMap = new Map();
|
|
10439
|
+
selectedPromotedByHandleRef.current.set(handleId, handleMap);
|
|
10440
|
+
}
|
|
10441
|
+
if (handleMap.get(groupKey) === featureId)
|
|
10442
|
+
return;
|
|
10443
|
+
handleMap.set(groupKey, featureId);
|
|
10444
|
+
emitWithPromotion();
|
|
10445
|
+
}, [emitWithPromotion]);
|
|
10446
|
+
react.useEffect(() => {
|
|
10447
|
+
retrySelectionPromotionRef.current = () => {
|
|
10448
|
+
const pending = prevSelectedPromotionRef.current;
|
|
10449
|
+
if (pending !== null) {
|
|
10450
|
+
promoteSelection(pending.handleId, pending.featureId);
|
|
10451
|
+
}
|
|
10452
|
+
};
|
|
10453
|
+
}, [promoteSelection]);
|
|
10362
10454
|
const promoteFeature = react.useCallback((handleId, featureId) => {
|
|
10363
10455
|
const groupKey = featureToGroupKeyByHandleRef.current.get(handleId)?.get(featureId);
|
|
10364
10456
|
if (groupKey === undefined)
|
|
@@ -10391,31 +10483,23 @@ const useShapeFillTiling = ({ api, handles, onFillGeometriesChange, onZIndexOver
|
|
|
10391
10483
|
return;
|
|
10392
10484
|
recompute();
|
|
10393
10485
|
}, [recompute, tilingContentKey]);
|
|
10394
|
-
// Selection forces
|
|
10395
|
-
// which requires a resting re-tile (computeFillTiling depends on
|
|
10396
|
-
// selectedFeatureId via resolveStackOrder). For large layers that re-tile is
|
|
10397
|
-
// expensive (~600ms for 200 polygons over global bounds) and must NOT block
|
|
10398
|
-
// the interaction's first paint — e.g. a panel opening on the clicked feature.
|
|
10399
|
-
// Defer it past the next paint with a double rAF so the click commits and
|
|
10400
|
-
// paints immediately, then the selected polygon restacks one frame later.
|
|
10401
|
-
// Rapid selections coalesce: each schedule cancels the previous pending one.
|
|
10486
|
+
// Selection forces front via Path B (cheap overlay) — not resting re-tile.
|
|
10402
10487
|
react.useEffect(() => {
|
|
10403
|
-
|
|
10404
|
-
|
|
10405
|
-
|
|
10406
|
-
|
|
10407
|
-
|
|
10408
|
-
|
|
10409
|
-
|
|
10410
|
-
|
|
10411
|
-
|
|
10412
|
-
|
|
10413
|
-
|
|
10414
|
-
|
|
10415
|
-
|
|
10416
|
-
|
|
10417
|
-
|
|
10418
|
-
}, [recompute, selectedFeatureId]);
|
|
10488
|
+
const prev = prevSelectedPromotionRef.current;
|
|
10489
|
+
const next = selectedFeatureId !== null &&
|
|
10490
|
+
selectedFeatureId !== undefined &&
|
|
10491
|
+
selectedHandleId !== null &&
|
|
10492
|
+
selectedHandleId !== undefined
|
|
10493
|
+
? { handleId: selectedHandleId, featureId: selectedFeatureId }
|
|
10494
|
+
: null;
|
|
10495
|
+
if (prev !== null && (next === null || prev.handleId !== next.handleId || prev.featureId !== next.featureId)) {
|
|
10496
|
+
clearSelectionPromotion(prev.handleId, prev.featureId);
|
|
10497
|
+
}
|
|
10498
|
+
if (next !== null) {
|
|
10499
|
+
promoteSelection(next.handleId, next.featureId);
|
|
10500
|
+
}
|
|
10501
|
+
prevSelectedPromotionRef.current = next;
|
|
10502
|
+
}, [selectedFeatureId, selectedHandleId, clearSelectionPromotion, promoteSelection]);
|
|
10419
10503
|
// Suppression changes must trigger a recompute so EMPTY_FILL entries are
|
|
10420
10504
|
// added / removed even when the map is idle (covers the decorations-vs-tiling
|
|
10421
10505
|
// idle race where viewport-style overrides arrive after the last idle event).
|
|
@@ -10707,6 +10791,7 @@ const Layers = ({ layers, api, onEntityClick, onEntityDblClick }) => {
|
|
|
10707
10791
|
onZIndexOverridesChange: setZIndexOverrides,
|
|
10708
10792
|
onSettledHover,
|
|
10709
10793
|
selectedFeatureId: layers.interaction.selectedEntity?.type === "shape" ? String(layers.interaction.selectedEntity.id) : null,
|
|
10794
|
+
selectedHandleId: layers.interaction.selectedEntity?.type === "shape" ? layers.interaction.selectedEntity.handleId : null,
|
|
10710
10795
|
suppressedFillIdsByHandle,
|
|
10711
10796
|
});
|
|
10712
10797
|
const promoteFeatureRef = react.useRef(promoteFeature);
|
package/index.esm.js
CHANGED
|
@@ -817,7 +817,7 @@ const PANEL_SCROLL_INTERNAL_CLASS_NAME = "flex flex-1 min-h-0 flex-col overflow-
|
|
|
817
817
|
*
|
|
818
818
|
* @internal
|
|
819
819
|
*/
|
|
820
|
-
const Panel = ({ anchor, placement = DEFAULT_PLACEMENT$1, onDismiss, closeLabel = "Close", onCommit, containerEl = null, "aria-label": ariaLabel, contentScroll = "shell", children, }) => {
|
|
820
|
+
const Panel = ({ anchor, placement = DEFAULT_PLACEMENT$1, onDismiss, closeLabel = "Close", onCommit, containerEl = null, mapMoveSource, "aria-label": ariaLabel, contentScroll = "shell", children, }) => {
|
|
821
821
|
const scrollSlotClassName = contentScroll === "internal" ? PANEL_SCROLL_INTERNAL_CLASS_NAME : PANEL_SCROLL_SHELL_CLASS_NAME;
|
|
822
822
|
const shellRef = useRef(null);
|
|
823
823
|
const scrollRef = useRef(null);
|
|
@@ -875,7 +875,7 @@ const Panel = ({ anchor, placement = DEFAULT_PLACEMENT$1, onDismiss, closeLabel
|
|
|
875
875
|
shift({ padding: 8, ...(containerEl !== null ? { boundary: containerEl } : {}) }),
|
|
876
876
|
hide(),
|
|
877
877
|
], [containerEl]);
|
|
878
|
-
const { context, floatingStyles, middlewareData, placement: resolvedPlacement, elements, refs: { setFloating }, } = useFloating({
|
|
878
|
+
const { context, floatingStyles, middlewareData, placement: resolvedPlacement, elements, refs: { setFloating }, update, } = useFloating({
|
|
879
879
|
open: effectiveAnchor !== null,
|
|
880
880
|
onOpenChange: nextOpen => {
|
|
881
881
|
if (!nextOpen)
|
|
@@ -885,8 +885,38 @@ const Panel = ({ anchor, placement = DEFAULT_PLACEMENT$1, onDismiss, closeLabel
|
|
|
885
885
|
strategy: "absolute",
|
|
886
886
|
middleware,
|
|
887
887
|
elements: { reference: effectiveAnchor },
|
|
888
|
-
whileElementsMounted: (reference, floating, autoUpdateFn) => autoUpdate(reference, floating, autoUpdateFn
|
|
888
|
+
whileElementsMounted: (reference, floating, autoUpdateFn) => autoUpdate(reference, floating, autoUpdateFn),
|
|
889
889
|
});
|
|
890
|
+
// Reposition while the map pans/zooms: anchors move without scroll/resize events.
|
|
891
|
+
useEffect(() => {
|
|
892
|
+
if (mapMoveSource === undefined || effectiveAnchor === null)
|
|
893
|
+
return;
|
|
894
|
+
let rafId = null;
|
|
895
|
+
const loop = () => {
|
|
896
|
+
update();
|
|
897
|
+
rafId = requestAnimationFrame(loop);
|
|
898
|
+
};
|
|
899
|
+
const stopLoop = () => {
|
|
900
|
+
if (rafId !== null) {
|
|
901
|
+
cancelAnimationFrame(rafId);
|
|
902
|
+
rafId = null;
|
|
903
|
+
}
|
|
904
|
+
};
|
|
905
|
+
const offMoveStart = mapMoveSource.on("movestart", () => {
|
|
906
|
+
if (rafId === null) {
|
|
907
|
+
rafId = requestAnimationFrame(loop);
|
|
908
|
+
}
|
|
909
|
+
});
|
|
910
|
+
const offMoveEnd = mapMoveSource.on("moveend", () => {
|
|
911
|
+
stopLoop();
|
|
912
|
+
update();
|
|
913
|
+
});
|
|
914
|
+
return () => {
|
|
915
|
+
offMoveStart();
|
|
916
|
+
offMoveEnd();
|
|
917
|
+
stopLoop();
|
|
918
|
+
};
|
|
919
|
+
}, [effectiveAnchor, mapMoveSource, update]);
|
|
890
920
|
// Hide until the first placement commit so there is no flash at (0,0).
|
|
891
921
|
const isCommitted = middlewareData.hide !== undefined;
|
|
892
922
|
// Do NOT re-enable outsidePress here (neither `true` nor `outsidePressEvent: "click"`).
|
|
@@ -949,13 +979,13 @@ const sortByPriority = (descriptors) => {
|
|
|
949
979
|
*
|
|
950
980
|
* @internal
|
|
951
981
|
*/
|
|
952
|
-
const PanelOrchestrator = ({ store, containerEl, anchoredPortalEl }) => {
|
|
982
|
+
const PanelOrchestrator = ({ store, containerEl, mapMoveSource, anchoredPortalEl }) => {
|
|
953
983
|
const descriptors = useSyncExternalStore(store.subscribe, store.getSnapshot);
|
|
954
984
|
const sortedDescriptors = useMemo(() => sortByPriority(descriptors), [descriptors]);
|
|
955
985
|
if (sortedDescriptors.length === 0) {
|
|
956
986
|
return null;
|
|
957
987
|
}
|
|
958
|
-
const anchoredContent = sortedDescriptors.map(descriptor => (jsx(Panel, { anchor: descriptor.anchor, "aria-label": descriptor["aria-label"], closeLabel: descriptor.closeLabel, containerEl: containerEl, contentScroll: descriptor.contentScroll, onCommit: descriptor.onCommit, onDismiss: descriptor.onDismiss, placement: descriptor.placement, children: descriptor.children }, descriptor.id)));
|
|
988
|
+
const anchoredContent = sortedDescriptors.map(descriptor => (jsx(Panel, { anchor: descriptor.anchor, "aria-label": descriptor["aria-label"], closeLabel: descriptor.closeLabel, containerEl: containerEl, contentScroll: descriptor.contentScroll, mapMoveSource: mapMoveSource, onCommit: descriptor.onCommit, onDismiss: descriptor.onDismiss, placement: descriptor.placement, children: descriptor.children }, descriptor.id)));
|
|
959
989
|
return anchoredPortalEl !== null && anchoredPortalEl !== undefined ? (createPortal(anchoredContent, anchoredPortalEl)) : (jsx(Fragment, { children: anchoredContent }));
|
|
960
990
|
};
|
|
961
991
|
PanelOrchestrator.displayName = "PanelOrchestrator";
|
|
@@ -1143,11 +1173,12 @@ const createMapComponent = ({ adapter, Renderer, adapterName, containerRefHolder
|
|
|
1143
1173
|
availableSpace,
|
|
1144
1174
|
responsiveMode,
|
|
1145
1175
|
}), [availableSpace, responsiveMode]);
|
|
1176
|
+
const mapMoveSource = useMemo(() => ({ on: adapter.on.bind(adapter) }), []);
|
|
1146
1177
|
const [safeAreaContentEl, setSafeAreaContentEl] = useState(null);
|
|
1147
1178
|
const [safeAreaPanelPortalEl, setSafeAreaPanelPortalEl] = useState(null);
|
|
1148
1179
|
const content = (jsxs("div", { "aria-describedby": ariaDescribedBy, "aria-label": ariaLabel ?? "Interactive map", className: twMerge(
|
|
1149
1180
|
// ZStack: full-size grid with a single cell so children overlay the map
|
|
1150
|
-
"isolate grid h-full w-full grid-cols-1 grid-rows-1", className), "data-testid": dataTestId, id: containerId, ref: mergedRef, role: "application", style: style, tabIndex: 0, children: [jsxs("div", { className: "relative col-start-1 row-start-1 grid h-full min-h-[200px] w-full", children: [jsx(Renderer, { adapterInstance: adapter, className: "h-full w-full", style: { height: "100%", width: "100%" } }), jsx(AdapterReadinessOverlay, { adapter: adapter })] }), jsx("div", { className: "pointer-events-none absolute inset-0 col-start-1 row-start-1 overflow-visible", "data-testid": "map-marker-overlay-portal" }), jsx("div", { className: "pointer-events-none relative isolate col-start-1 row-start-1 h-full min-h-0 w-full overflow-clip", "data-testid": "map-panel-overlay-portal", ref: setSafeAreaPanelPortalEl }), panelStore !== undefined ? (jsx(PanelOrchestrator, { anchoredPortalEl: safeAreaPanelPortalEl, containerEl: safeAreaContentEl ?? containerEl, store: panelStore })) : null, jsx(SafeAreaLayoutContext.Provider, { value: layout, children: jsxs(SafeAreaOverlay, { safeAreaContentRef: setSafeAreaContentEl, safeAreaInsets: safeAreaInsets ?? null, children: [children, annotationStore !== undefined ? jsx(FallbackAnnotationOverlay, { store: annotationStore }) : null, loadingIndicatorStore !== undefined ? (jsx(LoadingOverlay, { adapter: adapter, store: loadingIndicatorStore })) : null] }) })] }));
|
|
1181
|
+
"isolate grid h-full w-full grid-cols-1 grid-rows-1", className), "data-testid": dataTestId, id: containerId, ref: mergedRef, role: "application", style: style, tabIndex: 0, children: [jsxs("div", { className: "relative col-start-1 row-start-1 grid h-full min-h-[200px] w-full", children: [jsx(Renderer, { adapterInstance: adapter, className: "h-full w-full", style: { height: "100%", width: "100%" } }), jsx(AdapterReadinessOverlay, { adapter: adapter })] }), jsx("div", { className: "pointer-events-none absolute inset-0 col-start-1 row-start-1 overflow-visible", "data-testid": "map-marker-overlay-portal" }), jsx("div", { className: "pointer-events-none relative isolate col-start-1 row-start-1 h-full min-h-0 w-full overflow-clip", "data-testid": "map-panel-overlay-portal", ref: setSafeAreaPanelPortalEl }), panelStore !== undefined ? (jsx(PanelOrchestrator, { anchoredPortalEl: safeAreaPanelPortalEl, containerEl: safeAreaContentEl ?? containerEl, mapMoveSource: mapMoveSource, store: panelStore })) : null, jsx(SafeAreaLayoutContext.Provider, { value: layout, children: jsxs(SafeAreaOverlay, { safeAreaContentRef: setSafeAreaContentEl, safeAreaInsets: safeAreaInsets ?? null, children: [children, annotationStore !== undefined ? jsx(FallbackAnnotationOverlay, { store: annotationStore }) : null, loadingIndicatorStore !== undefined ? (jsx(LoadingOverlay, { adapter: adapter, store: loadingIndicatorStore })) : null] }) })] }));
|
|
1151
1182
|
// Provide the adapter's layer port to children (e.g. <Layers>)
|
|
1152
1183
|
// so they can communicate layer data to the map adapter.
|
|
1153
1184
|
const layerPort = adapter.layers;
|
|
@@ -9976,6 +10007,11 @@ const DEFAULT_STROKE_WIDTH = 2;
|
|
|
9976
10007
|
* (which equals groupSize, bounded by the number of features in a viewport).
|
|
9977
10008
|
*/
|
|
9978
10009
|
const HOVER_PROMOTION_Z_INDEX = 10000;
|
|
10010
|
+
/**
|
|
10011
|
+
* Z-index for selection-forces-front promotion. Must exceed
|
|
10012
|
+
* {@link HOVER_PROMOTION_Z_INDEX} so selection outranks sticky hover.
|
|
10013
|
+
*/
|
|
10014
|
+
const SELECTED_PROMOTION_Z_INDEX = 20000;
|
|
9979
10015
|
/**
|
|
9980
10016
|
* FNV-1a 32-bit hash over an array of coordinate numbers. Cheap and
|
|
9981
10017
|
* change-detecting: any moved vertex changes the output; identical polls keep
|
|
@@ -10107,7 +10143,7 @@ const pickFillPromotionCandidates = (fillHits, compareByArea) => {
|
|
|
10107
10143
|
*
|
|
10108
10144
|
* @internal
|
|
10109
10145
|
*/
|
|
10110
|
-
const useShapeFillTiling = ({ api, handles, onFillGeometriesChange, onZIndexOverridesChange, onSettledHover, selectedFeatureId, suppressedFillIdsByHandle, }) => {
|
|
10146
|
+
const useShapeFillTiling = ({ api, handles, onFillGeometriesChange, onZIndexOverridesChange, onSettledHover, selectedFeatureId, selectedHandleId, suppressedFillIdsByHandle, }) => {
|
|
10111
10147
|
const tilingContentKey = useMemo(() => buildTilingContentKey(handles), [handles]);
|
|
10112
10148
|
const tilingHandles = useMemo(() => handles.filter((handle) => handle.layerType === "shapes" && handle.overlap?.mode === "tile"), [handles]);
|
|
10113
10149
|
const hasTilingHandles = tilingHandles.length > 0;
|
|
@@ -10124,18 +10160,12 @@ const useShapeFillTiling = ({ api, handles, onFillGeometriesChange, onZIndexOver
|
|
|
10124
10160
|
useEffect(() => {
|
|
10125
10161
|
onSettledHoverRef.current = onSettledHover;
|
|
10126
10162
|
}, [onSettledHover]);
|
|
10127
|
-
const selectedFeatureIdRef = useRef(selectedFeatureId);
|
|
10128
|
-
useEffect(() => {
|
|
10129
|
-
selectedFeatureIdRef.current = selectedFeatureId;
|
|
10130
|
-
}, [selectedFeatureId]);
|
|
10131
10163
|
const suppressedFillIdsByHandleRef = useRef(suppressedFillIdsByHandle);
|
|
10132
10164
|
useEffect(() => {
|
|
10133
10165
|
suppressedFillIdsByHandleRef.current = suppressedFillIdsByHandle;
|
|
10134
10166
|
}, [suppressedFillIdsByHandle]);
|
|
10135
10167
|
const debounceTimerRef = useRef(null);
|
|
10136
10168
|
const idleRecomputeTimerRef = useRef(null);
|
|
10137
|
-
/** Pending deferred selection re-tile (double-rAF handle); see selection effect. */
|
|
10138
|
-
const selectionRecomputeRafRef = useRef(null);
|
|
10139
10169
|
// Currently emitted overrides — used for hit-testing visible fills in handleSettle.
|
|
10140
10170
|
const prevFillOverridesRef = useRef(EMPTY_OVERRIDES);
|
|
10141
10171
|
const prevZIndexOverridesRef = useRef(EMPTY_ZINDEX_OVERRIDES);
|
|
@@ -10144,10 +10174,15 @@ const useShapeFillTiling = ({ api, handles, onFillGeometriesChange, onZIndexOver
|
|
|
10144
10174
|
const restingZIndexByHandleRef = useRef(new Map());
|
|
10145
10175
|
// Per-handle: groupKey → promoted feature id (sticky hover winner).
|
|
10146
10176
|
const promotedByHandleRef = useRef(new Map());
|
|
10177
|
+
// Per-handle: groupKey → selected feature id (selection-forces-front).
|
|
10178
|
+
const selectedPromotedByHandleRef = useRef(new Map());
|
|
10179
|
+
const prevSelectedPromotionRef = useRef(null);
|
|
10147
10180
|
// Per-handle: featureId → groupKey (populated from last computeFillTiling result).
|
|
10148
10181
|
const featureToGroupKeyByHandleRef = useRef(new Map());
|
|
10149
10182
|
/** True while an interactive shape decoration (edge label, badge, …) is hovered. */
|
|
10150
10183
|
const decorationHoverActiveRef = useRef(false);
|
|
10184
|
+
/** Re-applies `prevSelectedPromotionRef` after recompute populates group keys. */
|
|
10185
|
+
const retrySelectionPromotionRef = useRef(null);
|
|
10151
10186
|
// Stable ref to current tiling handles for the debounce callback.
|
|
10152
10187
|
const tilingHandlesRef = useRef(tilingHandles);
|
|
10153
10188
|
useEffect(() => {
|
|
@@ -10167,6 +10202,27 @@ const useShapeFillTiling = ({ api, handles, onFillGeometriesChange, onZIndexOver
|
|
|
10167
10202
|
useEffect(() => {
|
|
10168
10203
|
tilingContentKeyRef.current = tilingContentKey;
|
|
10169
10204
|
}, [tilingContentKey]);
|
|
10205
|
+
/**
|
|
10206
|
+
* Merge selection and hover promotion maps per group. Selection wins over hover.
|
|
10207
|
+
*/
|
|
10208
|
+
const resolveGroupWinners = (handleId) => {
|
|
10209
|
+
const winners = new Map();
|
|
10210
|
+
const selected = selectedPromotedByHandleRef.current.get(handleId);
|
|
10211
|
+
if (selected !== undefined) {
|
|
10212
|
+
for (const [groupKey, featureId] of selected) {
|
|
10213
|
+
winners.set(groupKey, { featureId, channel: "selection" });
|
|
10214
|
+
}
|
|
10215
|
+
}
|
|
10216
|
+
const hover = promotedByHandleRef.current.get(handleId);
|
|
10217
|
+
if (hover !== undefined) {
|
|
10218
|
+
for (const [groupKey, featureId] of hover) {
|
|
10219
|
+
if (!winners.has(groupKey)) {
|
|
10220
|
+
winners.set(groupKey, { featureId, channel: "hover" });
|
|
10221
|
+
}
|
|
10222
|
+
}
|
|
10223
|
+
}
|
|
10224
|
+
return winners;
|
|
10225
|
+
};
|
|
10170
10226
|
/**
|
|
10171
10227
|
* Apply the current promotion state on top of resting fills and z-indices and emit
|
|
10172
10228
|
* the result. Called both at the end of `recompute` and directly on hover-settle
|
|
@@ -10174,22 +10230,23 @@ const useShapeFillTiling = ({ api, handles, onFillGeometriesChange, onZIndexOver
|
|
|
10174
10230
|
*
|
|
10175
10231
|
* Active promotion: winner unclipped; peers in the same group are clipped against
|
|
10176
10232
|
* the winner's full geometry so exclusive rings stay painted without compounding.
|
|
10233
|
+
* Selection channel outranks hover per group.
|
|
10177
10234
|
*/
|
|
10178
10235
|
const emitWithPromotion = useCallback(() => {
|
|
10179
10236
|
const nextFills = new Map();
|
|
10180
10237
|
const nextZIndices = new Map();
|
|
10181
10238
|
const handleById = new Map(tilingHandlesRef.current.map(handle => [handle.id, handle]));
|
|
10182
10239
|
for (const [handleId, restingFills] of restingFillByHandleRef.current) {
|
|
10183
|
-
const
|
|
10240
|
+
const groupWinners = resolveGroupWinners(handleId);
|
|
10184
10241
|
const featureToGroupKey = featureToGroupKeyByHandleRef.current.get(handleId);
|
|
10185
|
-
if (
|
|
10242
|
+
if (groupWinners.size === 0) {
|
|
10186
10243
|
if (restingFills.size > 0)
|
|
10187
10244
|
nextFills.set(handleId, restingFills);
|
|
10188
10245
|
}
|
|
10189
10246
|
else if (featureToGroupKey === undefined) {
|
|
10190
10247
|
const modifiedFills = new Map(restingFills);
|
|
10191
|
-
for (const
|
|
10192
|
-
modifiedFills.delete(
|
|
10248
|
+
for (const { featureId } of groupWinners.values()) {
|
|
10249
|
+
modifiedFills.delete(featureId);
|
|
10193
10250
|
}
|
|
10194
10251
|
if (modifiedFills.size > 0)
|
|
10195
10252
|
nextFills.set(handleId, modifiedFills);
|
|
@@ -10204,7 +10261,7 @@ const useShapeFillTiling = ({ api, handles, onFillGeometriesChange, onZIndexOver
|
|
|
10204
10261
|
}
|
|
10205
10262
|
}
|
|
10206
10263
|
const modifiedFills = new Map(restingFills);
|
|
10207
|
-
for (const [groupKey, winnerId] of
|
|
10264
|
+
for (const [groupKey, { featureId: winnerId }] of groupWinners) {
|
|
10208
10265
|
const groupMembers = [];
|
|
10209
10266
|
for (const [featureId, memberGroupKey] of featureToGroupKey) {
|
|
10210
10267
|
if (memberGroupKey !== groupKey)
|
|
@@ -10232,16 +10289,16 @@ const useShapeFillTiling = ({ api, handles, onFillGeometriesChange, onZIndexOver
|
|
|
10232
10289
|
}
|
|
10233
10290
|
}
|
|
10234
10291
|
for (const [handleId, restingZIndices] of restingZIndexByHandleRef.current) {
|
|
10235
|
-
const
|
|
10236
|
-
if (
|
|
10292
|
+
const groupWinners = resolveGroupWinners(handleId);
|
|
10293
|
+
if (groupWinners.size === 0) {
|
|
10237
10294
|
if (restingZIndices.size > 0)
|
|
10238
10295
|
nextZIndices.set(handleId, restingZIndices);
|
|
10239
10296
|
}
|
|
10240
10297
|
else {
|
|
10241
10298
|
const featureToGroupKey = featureToGroupKeyByHandleRef.current.get(handleId);
|
|
10242
10299
|
const modifiedZIndices = new Map(restingZIndices);
|
|
10243
|
-
for (const [groupKey, winnerId] of
|
|
10244
|
-
modifiedZIndices.set(winnerId, HOVER_PROMOTION_Z_INDEX);
|
|
10300
|
+
for (const [groupKey, { featureId: winnerId, channel }] of groupWinners) {
|
|
10301
|
+
modifiedZIndices.set(winnerId, channel === "selection" ? SELECTED_PROMOTION_Z_INDEX : HOVER_PROMOTION_Z_INDEX);
|
|
10245
10302
|
if (featureToGroupKey !== undefined) {
|
|
10246
10303
|
for (const [featureId, memberGroupKey] of featureToGroupKey) {
|
|
10247
10304
|
if (memberGroupKey === groupKey && featureId !== winnerId) {
|
|
@@ -10295,7 +10352,7 @@ const useShapeFillTiling = ({ api, handles, onFillGeometriesChange, onZIndexOver
|
|
|
10295
10352
|
// Zoom is intentionally excluded too: fill-tiling stack order is zoom-invariant
|
|
10296
10353
|
// by contract, so zooming can reuse the same resting clips and avoid a full
|
|
10297
10354
|
// polygon-clipping pass for an identical result.
|
|
10298
|
-
const inputKey = `${tilingContentKeyRef.current}|${
|
|
10355
|
+
const inputKey = `${tilingContentKeyRef.current}|${suppressedKey}|${geodesicKey}`;
|
|
10299
10356
|
if (inputKey === lastComputeInputKeyRef.current) {
|
|
10300
10357
|
return;
|
|
10301
10358
|
}
|
|
@@ -10307,6 +10364,7 @@ const useShapeFillTiling = ({ api, handles, onFillGeometriesChange, onZIndexOver
|
|
|
10307
10364
|
prevFillOverridesRef.current = EMPTY_OVERRIDES;
|
|
10308
10365
|
prevZIndexOverridesRef.current = EMPTY_ZINDEX_OVERRIDES;
|
|
10309
10366
|
promotedByHandleRef.current = new Map();
|
|
10367
|
+
selectedPromotedByHandleRef.current = new Map();
|
|
10310
10368
|
featureToGroupKeyByHandleRef.current = new Map();
|
|
10311
10369
|
onFillChangeRef.current(EMPTY_OVERRIDES);
|
|
10312
10370
|
onZIndexChangeRef.current(EMPTY_ZINDEX_OVERRIDES);
|
|
@@ -10323,7 +10381,6 @@ const useShapeFillTiling = ({ api, handles, onFillGeometriesChange, onZIndexOver
|
|
|
10323
10381
|
features: handle.features.features,
|
|
10324
10382
|
viewportBounds: GLOBAL_BOUNDS,
|
|
10325
10383
|
resolveStackOrder: handle.overlap?.resolveStackOrder ?? defaultShapeStackOrder,
|
|
10326
|
-
selectedFeatureId: selectedFeatureIdRef.current,
|
|
10327
10384
|
suppressedFeatureIds: suppressedFillIdsByHandleRef.current?.get(handle.id),
|
|
10328
10385
|
layerGeodesic: handle.style.geodesic,
|
|
10329
10386
|
featureStyles: handle.featureStyles,
|
|
@@ -10338,6 +10395,7 @@ const useShapeFillTiling = ({ api, handles, onFillGeometriesChange, onZIndexOver
|
|
|
10338
10395
|
}
|
|
10339
10396
|
restingFillByHandleRef.current = nextRestingFills;
|
|
10340
10397
|
restingZIndexByHandleRef.current = nextRestingZIndices;
|
|
10398
|
+
retrySelectionPromotionRef.current?.();
|
|
10341
10399
|
emitWithPromotion();
|
|
10342
10400
|
}, [emitWithPromotion]);
|
|
10343
10401
|
const flushPendingRestingRecompute = useCallback(() => {
|
|
@@ -10358,6 +10416,40 @@ const useShapeFillTiling = ({ api, handles, onFillGeometriesChange, onZIndexOver
|
|
|
10358
10416
|
flushPendingRestingRecompute();
|
|
10359
10417
|
}, IDLE_RECOMPUTE_DEBOUNCE_MS);
|
|
10360
10418
|
}, [flushPendingRestingRecompute]);
|
|
10419
|
+
const clearSelectionPromotion = useCallback((handleId, featureId) => {
|
|
10420
|
+
const groupKey = featureToGroupKeyByHandleRef.current.get(handleId)?.get(featureId);
|
|
10421
|
+
if (groupKey === undefined)
|
|
10422
|
+
return;
|
|
10423
|
+
const handleMap = selectedPromotedByHandleRef.current.get(handleId);
|
|
10424
|
+
if (handleMap === undefined || handleMap.get(groupKey) !== featureId)
|
|
10425
|
+
return;
|
|
10426
|
+
handleMap.delete(groupKey);
|
|
10427
|
+
if (handleMap.size === 0)
|
|
10428
|
+
selectedPromotedByHandleRef.current.delete(handleId);
|
|
10429
|
+
emitWithPromotion();
|
|
10430
|
+
}, [emitWithPromotion]);
|
|
10431
|
+
const promoteSelection = useCallback((handleId, featureId) => {
|
|
10432
|
+
const groupKey = featureToGroupKeyByHandleRef.current.get(handleId)?.get(featureId);
|
|
10433
|
+
if (groupKey === undefined)
|
|
10434
|
+
return;
|
|
10435
|
+
let handleMap = selectedPromotedByHandleRef.current.get(handleId);
|
|
10436
|
+
if (handleMap === undefined) {
|
|
10437
|
+
handleMap = new Map();
|
|
10438
|
+
selectedPromotedByHandleRef.current.set(handleId, handleMap);
|
|
10439
|
+
}
|
|
10440
|
+
if (handleMap.get(groupKey) === featureId)
|
|
10441
|
+
return;
|
|
10442
|
+
handleMap.set(groupKey, featureId);
|
|
10443
|
+
emitWithPromotion();
|
|
10444
|
+
}, [emitWithPromotion]);
|
|
10445
|
+
useEffect(() => {
|
|
10446
|
+
retrySelectionPromotionRef.current = () => {
|
|
10447
|
+
const pending = prevSelectedPromotionRef.current;
|
|
10448
|
+
if (pending !== null) {
|
|
10449
|
+
promoteSelection(pending.handleId, pending.featureId);
|
|
10450
|
+
}
|
|
10451
|
+
};
|
|
10452
|
+
}, [promoteSelection]);
|
|
10361
10453
|
const promoteFeature = useCallback((handleId, featureId) => {
|
|
10362
10454
|
const groupKey = featureToGroupKeyByHandleRef.current.get(handleId)?.get(featureId);
|
|
10363
10455
|
if (groupKey === undefined)
|
|
@@ -10390,31 +10482,23 @@ const useShapeFillTiling = ({ api, handles, onFillGeometriesChange, onZIndexOver
|
|
|
10390
10482
|
return;
|
|
10391
10483
|
recompute();
|
|
10392
10484
|
}, [recompute, tilingContentKey]);
|
|
10393
|
-
// Selection forces
|
|
10394
|
-
// which requires a resting re-tile (computeFillTiling depends on
|
|
10395
|
-
// selectedFeatureId via resolveStackOrder). For large layers that re-tile is
|
|
10396
|
-
// expensive (~600ms for 200 polygons over global bounds) and must NOT block
|
|
10397
|
-
// the interaction's first paint — e.g. a panel opening on the clicked feature.
|
|
10398
|
-
// Defer it past the next paint with a double rAF so the click commits and
|
|
10399
|
-
// paints immediately, then the selected polygon restacks one frame later.
|
|
10400
|
-
// Rapid selections coalesce: each schedule cancels the previous pending one.
|
|
10485
|
+
// Selection forces front via Path B (cheap overlay) — not resting re-tile.
|
|
10401
10486
|
useEffect(() => {
|
|
10402
|
-
|
|
10403
|
-
|
|
10404
|
-
|
|
10405
|
-
|
|
10406
|
-
|
|
10407
|
-
|
|
10408
|
-
|
|
10409
|
-
|
|
10410
|
-
|
|
10411
|
-
|
|
10412
|
-
|
|
10413
|
-
|
|
10414
|
-
|
|
10415
|
-
|
|
10416
|
-
|
|
10417
|
-
}, [recompute, selectedFeatureId]);
|
|
10487
|
+
const prev = prevSelectedPromotionRef.current;
|
|
10488
|
+
const next = selectedFeatureId !== null &&
|
|
10489
|
+
selectedFeatureId !== undefined &&
|
|
10490
|
+
selectedHandleId !== null &&
|
|
10491
|
+
selectedHandleId !== undefined
|
|
10492
|
+
? { handleId: selectedHandleId, featureId: selectedFeatureId }
|
|
10493
|
+
: null;
|
|
10494
|
+
if (prev !== null && (next === null || prev.handleId !== next.handleId || prev.featureId !== next.featureId)) {
|
|
10495
|
+
clearSelectionPromotion(prev.handleId, prev.featureId);
|
|
10496
|
+
}
|
|
10497
|
+
if (next !== null) {
|
|
10498
|
+
promoteSelection(next.handleId, next.featureId);
|
|
10499
|
+
}
|
|
10500
|
+
prevSelectedPromotionRef.current = next;
|
|
10501
|
+
}, [selectedFeatureId, selectedHandleId, clearSelectionPromotion, promoteSelection]);
|
|
10418
10502
|
// Suppression changes must trigger a recompute so EMPTY_FILL entries are
|
|
10419
10503
|
// added / removed even when the map is idle (covers the decorations-vs-tiling
|
|
10420
10504
|
// idle race where viewport-style overrides arrive after the last idle event).
|
|
@@ -10706,6 +10790,7 @@ const Layers = ({ layers, api, onEntityClick, onEntityDblClick }) => {
|
|
|
10706
10790
|
onZIndexOverridesChange: setZIndexOverrides,
|
|
10707
10791
|
onSettledHover,
|
|
10708
10792
|
selectedFeatureId: layers.interaction.selectedEntity?.type === "shape" ? String(layers.interaction.selectedEntity.id) : null,
|
|
10793
|
+
selectedHandleId: layers.interaction.selectedEntity?.type === "shape" ? layers.interaction.selectedEntity.handleId : null,
|
|
10709
10794
|
suppressedFillIdsByHandle,
|
|
10710
10795
|
});
|
|
10711
10796
|
const promoteFeatureRef = useRef(promoteFeature);
|
package/package.json
CHANGED
|
@@ -25,10 +25,12 @@ type UseShapeFillTilingConfig = Readonly<{
|
|
|
25
25
|
*/
|
|
26
26
|
onSettledHover?: (handleId: string, featureId: string) => void;
|
|
27
27
|
/**
|
|
28
|
-
* Currently selected feature id.
|
|
29
|
-
*
|
|
28
|
+
* Currently selected feature id. Routed through the selection promotion
|
|
29
|
+
* channel (Path B) — not the resting `computeFillTiling` baseline.
|
|
30
30
|
*/
|
|
31
31
|
selectedFeatureId?: string | null;
|
|
32
|
+
/** Handle id of the selected shape entity (required when `selectedFeatureId` is set). */
|
|
33
|
+
selectedHandleId?: string | null;
|
|
32
34
|
/**
|
|
33
35
|
* Per-handle set of feature ids whose fill is suppressed (`fillOpacity === 0`
|
|
34
36
|
* on the resolved context-aware style). Suppressed features are excluded from
|
|
@@ -85,5 +87,5 @@ export type UseShapeFillTilingReturn = Readonly<{
|
|
|
85
87
|
*
|
|
86
88
|
* @internal
|
|
87
89
|
*/
|
|
88
|
-
export declare const useShapeFillTiling: ({ api, handles, onFillGeometriesChange, onZIndexOverridesChange, onSettledHover, selectedFeatureId, suppressedFillIdsByHandle, }: UseShapeFillTilingConfig) => UseShapeFillTilingReturn;
|
|
90
|
+
export declare const useShapeFillTiling: ({ api, handles, onFillGeometriesChange, onZIndexOverridesChange, onSettledHover, selectedFeatureId, selectedHandleId, suppressedFillIdsByHandle, }: UseShapeFillTilingConfig) => UseShapeFillTilingReturn;
|
|
89
91
|
export {};
|
package/src/panel/Panel.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type Placement } from "@floating-ui/react";
|
|
2
2
|
import { ReactNode } from "react";
|
|
3
|
+
import type { MapApi } from "../core/types";
|
|
3
4
|
import "./Panel.css";
|
|
4
5
|
export type PanelContentScroll = "shell" | "internal";
|
|
5
6
|
export type PanelProps = Readonly<{
|
|
@@ -31,6 +32,12 @@ export type PanelProps = Readonly<{
|
|
|
31
32
|
* outside the map viewport. Falls back to clipping ancestors when null.
|
|
32
33
|
*/
|
|
33
34
|
containerEl?: HTMLElement | null;
|
|
35
|
+
/**
|
|
36
|
+
* Map event source for repositioning during pan/zoom. When provided, the panel
|
|
37
|
+
* updates on `moveend` and runs a lightweight rAF loop only while the map is
|
|
38
|
+
* moving — avoiding perpetual `autoUpdate({ animationFrame: true })`.
|
|
39
|
+
*/
|
|
40
|
+
mapMoveSource?: Pick<MapApi, "on">;
|
|
34
41
|
/** Accessible label for the panel dialog, announced by screen readers on open. */
|
|
35
42
|
"aria-label"?: string;
|
|
36
43
|
/**
|
|
@@ -55,6 +62,6 @@ export type PanelProps = Readonly<{
|
|
|
55
62
|
* @internal
|
|
56
63
|
*/
|
|
57
64
|
export declare const Panel: {
|
|
58
|
-
({ anchor, placement, onDismiss, closeLabel, onCommit, containerEl, "aria-label": ariaLabel, contentScroll, children, }: PanelProps): import("react/jsx-runtime").JSX.Element | null;
|
|
65
|
+
({ anchor, placement, onDismiss, closeLabel, onCommit, containerEl, mapMoveSource, "aria-label": ariaLabel, contentScroll, children, }: PanelProps): import("react/jsx-runtime").JSX.Element | null;
|
|
59
66
|
displayName: string;
|
|
60
67
|
};
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import type { MapApi } from "../core/types";
|
|
1
2
|
import type { PanelStore } from "./store/panels";
|
|
3
|
+
export type PanelOrchestratorMapMoveSource = Pick<MapApi, "on">;
|
|
2
4
|
export type PanelOrchestratorProps = Readonly<{
|
|
3
5
|
store: PanelStore;
|
|
4
6
|
/**
|
|
@@ -6,6 +8,8 @@ export type PanelOrchestratorProps = Readonly<{
|
|
|
6
8
|
* escape the map's overflow.
|
|
7
9
|
*/
|
|
8
10
|
containerEl: HTMLElement | null;
|
|
11
|
+
/** Subscribes panels to map pan/zoom for anchor tracking. */
|
|
12
|
+
mapMoveSource?: PanelOrchestratorMapMoveSource;
|
|
9
13
|
/**
|
|
10
14
|
* Overlay element that hosts anchored panels via portal. When omitted,
|
|
11
15
|
* anchored panels render inline next to the orchestrator (used in tests).
|
|
@@ -19,6 +23,6 @@ export type PanelOrchestratorProps = Readonly<{
|
|
|
19
23
|
* @internal
|
|
20
24
|
*/
|
|
21
25
|
export declare const PanelOrchestrator: {
|
|
22
|
-
({ store, containerEl, anchoredPortalEl }: PanelOrchestratorProps): import("react/jsx-runtime").JSX.Element | null;
|
|
26
|
+
({ store, containerEl, mapMoveSource, anchoredPortalEl }: PanelOrchestratorProps): import("react/jsx-runtime").JSX.Element | null;
|
|
23
27
|
displayName: string;
|
|
24
28
|
};
|