@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 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, { animationFrame: true }),
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 promoted = promotedByHandleRef.current.get(handleId);
10241
+ const groupWinners = resolveGroupWinners(handleId);
10185
10242
  const featureToGroupKey = featureToGroupKeyByHandleRef.current.get(handleId);
10186
- if (promoted === undefined || promoted.size === 0) {
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 promotedId of promoted.values()) {
10193
- modifiedFills.delete(promotedId);
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 promoted) {
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 promoted = promotedByHandleRef.current.get(handleId);
10237
- if (promoted === undefined || promoted.size === 0) {
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 promoted) {
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}|${selectedFeatureIdRef.current ?? ""}|${suppressedKey}|${geodesicKey}`;
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 the selected feature to the front of its overlap group,
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
- if (selectionRecomputeRafRef.current !== null) {
10404
- cancelAnimationFrame(selectionRecomputeRafRef.current);
10405
- }
10406
- selectionRecomputeRafRef.current = requestAnimationFrame(() => {
10407
- selectionRecomputeRafRef.current = requestAnimationFrame(() => {
10408
- selectionRecomputeRafRef.current = null;
10409
- recompute();
10410
- });
10411
- });
10412
- return () => {
10413
- if (selectionRecomputeRafRef.current !== null) {
10414
- cancelAnimationFrame(selectionRecomputeRafRef.current);
10415
- selectionRecomputeRafRef.current = null;
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, { animationFrame: true }),
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 promoted = promotedByHandleRef.current.get(handleId);
10240
+ const groupWinners = resolveGroupWinners(handleId);
10184
10241
  const featureToGroupKey = featureToGroupKeyByHandleRef.current.get(handleId);
10185
- if (promoted === undefined || promoted.size === 0) {
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 promotedId of promoted.values()) {
10192
- modifiedFills.delete(promotedId);
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 promoted) {
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 promoted = promotedByHandleRef.current.get(handleId);
10236
- if (promoted === undefined || promoted.size === 0) {
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 promoted) {
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}|${selectedFeatureIdRef.current ?? ""}|${suppressedKey}|${geodesicKey}`;
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 the selected feature to the front of its overlap group,
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
- if (selectionRecomputeRafRef.current !== null) {
10403
- cancelAnimationFrame(selectionRecomputeRafRef.current);
10404
- }
10405
- selectionRecomputeRafRef.current = requestAnimationFrame(() => {
10406
- selectionRecomputeRafRef.current = requestAnimationFrame(() => {
10407
- selectionRecomputeRafRef.current = null;
10408
- recompute();
10409
- });
10410
- });
10411
- return () => {
10412
- if (selectionRecomputeRafRef.current !== null) {
10413
- cancelAnimationFrame(selectionRecomputeRafRef.current);
10414
- selectionRecomputeRafRef.current = null;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trackunit/react-map",
3
- "version": "0.1.50",
3
+ "version": "0.1.51",
4
4
  "repository": "https://github.com/Trackunit/manager",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "engines": {
@@ -25,10 +25,12 @@ type UseShapeFillTilingConfig = Readonly<{
25
25
  */
26
26
  onSettledHover?: (handleId: string, featureId: string) => void;
27
27
  /**
28
- * Currently selected feature id. The matching feature is forced to the front
29
- * of its overlap group, overriding resting order.
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 {};
@@ -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
  };