@trackunit/react-map 0.1.3 → 0.1.5

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.esm.js CHANGED
@@ -15,7 +15,7 @@ import { useModal, Modal as Modal$1, ModalHeader, ModalBody } from '@trackunit/r
15
15
  import { z } from 'zod';
16
16
  import { PieChart } from 'react-minimal-pie-chart';
17
17
  import { tailwindPalette } from '@trackunit/ui-design-tokens';
18
- import { validateBboxWithFallback, extractPositionsFromGeometry, geoJsonPositionSchema, validateFeatureCollection, EMPTY_FEATURE_COLLECTION, validateBbox, isBboxInsideFeatureCollection, extractEdges, computeGeometryCentroid, isPositionInsideRing, edgePixelLength } from '@trackunit/geo-json-utils';
18
+ import { validateBboxWithFallback, extractPositionsFromGeometry, geoJsonPositionSchema, validateFeatureCollection, EMPTY_FEATURE_COLLECTION, validateBbox, projectPolygonalToWebMercator, geoJsonPolygonDifference, unprojectPolygonalFromWebMercator, distanceToGeoJsonPolygonBoundary, getGeoJsonPolygonIntersection, isFullyContainedInGeoJsonGeometry, isGeoJsonPointInPolygon, isBboxInsideFeatureCollection, extractEdges, computeGeometryCentroid, isPositionInsideRing, edgePixelLength } from '@trackunit/geo-json-utils';
19
19
 
20
20
  var defaultTranslations = {
21
21
  "controls.allControls": "Map controls",
@@ -6410,7 +6410,7 @@ const extractPositions = (features) => {
6410
6410
  * ```
6411
6411
  */
6412
6412
  const useShapes = (options) => {
6413
- const { id, name, features: rawFeatures, resolveStyle, interactive = "stroke", controls, getDecorations, skipAutoDecorations = false, loading, fitParticipation = "all", } = options;
6413
+ const { id, name, features: rawFeatures, resolveStyle, interactive = "stroke", overlap, controls, getDecorations, skipAutoDecorations = false, loading, fitParticipation = "all", onShapesUnderCursor, } = options;
6414
6414
  // ---- Data readiness tracking ----
6415
6415
  const layerReady = useLayerReady(loading);
6416
6416
  // ---- Validate and stabilize features ----
@@ -6474,6 +6474,14 @@ const useShapes = (options) => {
6474
6474
  const auto = buildMultiPartDecorations(feature, strokeColor, pointRadius, strokeWidth);
6475
6475
  return auto.length > 0 ? [...custom, ...auto] : custom;
6476
6476
  }, [skipAutoDecorations, featureStyles]);
6477
+ // Stabilize onShapesUnderCursor so an inline lambda does not recreate the handle.
6478
+ const onShapesUnderCursorRef = useRef(onShapesUnderCursor);
6479
+ useEffect(() => {
6480
+ onShapesUnderCursorRef.current = onShapesUnderCursor;
6481
+ }, [onShapesUnderCursor]);
6482
+ const stableOnShapesUnderCursor = useCallback((hits, ctx) => {
6483
+ onShapesUnderCursorRef.current?.(hits, ctx);
6484
+ }, []);
6477
6485
  // ---- Build the layer handle ----
6478
6486
  return useMemo(() => ({
6479
6487
  id,
@@ -6491,7 +6499,9 @@ const useShapes = (options) => {
6491
6499
  resolveStyle: stableResolveStyle,
6492
6500
  featureStyles,
6493
6501
  interactive,
6502
+ overlap,
6494
6503
  getDecorations: stableGetDecorations,
6504
+ onShapesUnderCursor: stableOnShapesUnderCursor,
6495
6505
  }), [
6496
6506
  id,
6497
6507
  name,
@@ -6504,7 +6514,9 @@ const useShapes = (options) => {
6504
6514
  stableResolveStyle,
6505
6515
  featureStyles,
6506
6516
  interactive,
6517
+ overlap,
6507
6518
  stableGetDecorations,
6519
+ stableOnShapesUnderCursor,
6508
6520
  ]);
6509
6521
  };
6510
6522
 
@@ -6960,6 +6972,8 @@ function toMapLayer(handle) {
6960
6972
  features: handle.features,
6961
6973
  style: handle.style,
6962
6974
  featureStyles: handle.featureStyles,
6975
+ featureFillGeometries: handle.featureFillGeometries,
6976
+ featureZIndexOverrides: handle.featureZIndexOverrides,
6963
6977
  interactive: handle.interactive,
6964
6978
  };
6965
6979
  case "route":
@@ -7164,6 +7178,42 @@ const useMarkerMountBridge = (features, currentMediums) => {
7164
7178
  return useMemo(() => ({ mountingIds }), [mountingIds]);
7165
7179
  };
7166
7180
 
7181
+ // Matches the browser's default double-click timing window. Pixel-proximity is
7182
+ // intentionally NOT checked — geo-anchored decorations can shift on screen
7183
+ // between the two clicks (e.g. when the first click opens a panel that
7184
+ // triggers a map auto-pan), so the browser's native dblclick event and the
7185
+ // e.detail === 2 shorthand both fail here. Time-only detection is the correct
7186
+ // solution.
7187
+ const DOUBLE_CLICK_THRESHOLD_MS = 500;
7188
+ /**
7189
+ * Time-based double-click detection for interactive DOM overlays.
7190
+ *
7191
+ * Call the returned `dispatch` on every `click` event. When two calls arrive
7192
+ * within `DOUBLE_CLICK_THRESHOLD_MS`, the second call is treated as a
7193
+ * double-click and `onDblClick` is invoked; otherwise `onClick` is invoked.
7194
+ *
7195
+ * A third rapid click resets the window (triple-click is not treated as a
7196
+ * second double-click).
7197
+ *
7198
+ * @internal
7199
+ */
7200
+ const useManualDblClick = (onClick, onDblClick) => {
7201
+ const lastClickTimeRef = useRef(0);
7202
+ return useCallback((arg) => {
7203
+ const now = Date.now();
7204
+ const timeSinceLast = now - lastClickTimeRef.current;
7205
+ const isManualDblClick = timeSinceLast <= DOUBLE_CLICK_THRESHOLD_MS && onDblClick !== undefined;
7206
+ // Reset so a third rapid click doesn't re-fire the double-click action.
7207
+ lastClickTimeRef.current = isManualDblClick ? 0 : now;
7208
+ if (isManualDblClick) {
7209
+ onDblClick(arg);
7210
+ }
7211
+ else {
7212
+ onClick?.(arg);
7213
+ }
7214
+ }, [onClick, onDblClick]);
7215
+ };
7216
+
7167
7217
  /**
7168
7218
  * Uniform interactive wrapper for shape decorations.
7169
7219
  *
@@ -7172,19 +7222,24 @@ const useMarkerMountBridge = (features, currentMediums) => {
7172
7222
  * selection, mouseenter/mouseleave dispatches entity hover — both followed
7173
7223
  * by optional supplementary callbacks.
7174
7224
  *
7225
+ * Double-click is detected via time-based logic (`useManualDblClick`) rather
7226
+ * than the native `dblclick` event. See that hook for the rationale.
7227
+ *
7175
7228
  * @internal
7176
7229
  */
7177
7230
  const InteractiveDecoration = ({ entity, select, hover, onClick, onDblClick, onHover, children, }) => {
7231
+ const dispatchClick = useManualDblClick(onClick, onDblClick);
7178
7232
  const handleClick = useCallback((e) => {
7179
7233
  e.stopPropagation();
7180
7234
  select(entity);
7181
- onClick?.(entity);
7182
- }, [entity, select, onClick]);
7235
+ dispatchClick(entity);
7236
+ }, [entity, select, dispatchClick]);
7237
+ // Native dblclick is suppressed — the action is already handled by the
7238
+ // time-based detection in handleClick above (second click fires before dblclick).
7239
+ // We still stopPropagation so the event doesn't reach the map canvas.
7183
7240
  const handleDblClick = useCallback((e) => {
7184
7241
  e.stopPropagation();
7185
- select(entity);
7186
- onDblClick?.(entity);
7187
- }, [entity, select, onDblClick]);
7242
+ }, []);
7188
7243
  const handleMouseEnter = useCallback(() => {
7189
7244
  hover(entity);
7190
7245
  onHover?.(entity);
@@ -7196,6 +7251,401 @@ const InteractiveDecoration = ({ entity, select, hover, onClick, onDblClick, onH
7196
7251
  return (jsx("button", { className: "m-0 cursor-pointer appearance-none border-0 bg-transparent p-0 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-1", onClick: handleClick, onDoubleClick: handleDblClick, onMouseEnter: handleMouseEnter, onMouseLeave: handleMouseLeave, type: "button", children: children }));
7197
7252
  };
7198
7253
 
7254
+ /**
7255
+ * Pure fill-tiling core for ADR-0021. Given a set of features, the current
7256
+ * viewport, a stack-order resolver, and an optional cursor, it computes the
7257
+ * clipped fill geometry for every feature whose fill overlaps a neighbour.
7258
+ *
7259
+ * Non-overlapping features and the top-ranked feature of each overlap group
7260
+ * keep their full geometry (no entry returned → adapter falls back to full).
7261
+ * Fully covered features map to an empty MultiPolygon (no fill painted).
7262
+ *
7263
+ * Kept free of React/rAF so it is unit-testable; the surrounding hook owns the
7264
+ * pointermove throttling and emits the result through the adapter.
7265
+ */
7266
+ /** Empty fill = painted by nobody (fully covered loser or suppressed feature). */
7267
+ const EMPTY_FILL = { type: "MultiPolygon", coordinates: [] };
7268
+ /**
7269
+ * A feature's fill is suppressed when `fillOpacity` is exactly `0` on the
7270
+ * resolved style. Suppressed features are excluded from tiling stack
7271
+ * computation (no overlap ownership, no clip neighbour) and from fill
7272
+ * hit-testing (stroke remains interactive).
7273
+ */
7274
+ const isFillSuppressed = (style) => style?.fillOpacity === 0;
7275
+ const polygonalFromGeometry = (geometry) => {
7276
+ if (geometry.type === "Polygon")
7277
+ return geometry;
7278
+ if (geometry.type === "MultiPolygon") {
7279
+ if (geometry.coordinates.length === 0)
7280
+ return null;
7281
+ return geometry;
7282
+ }
7283
+ return null;
7284
+ };
7285
+ /** Resolve the geometry used for fill hit-tests; stroke always uses the full shape. */
7286
+ const fillGeometryForHitTest = (featureId, fullGeometry, visibleFillFor) => {
7287
+ if (visibleFillFor === undefined)
7288
+ return fullGeometry;
7289
+ const override = visibleFillFor(featureId);
7290
+ if (override === undefined)
7291
+ return fullGeometry;
7292
+ if (override === null)
7293
+ return null;
7294
+ return polygonalFromGeometry(override);
7295
+ };
7296
+ const polygonalGeometry = (feature) => {
7297
+ const geom = feature.geometry;
7298
+ if (geom === null)
7299
+ return null;
7300
+ if (geom.type === "Polygon" || geom.type === "MultiPolygon")
7301
+ return geom;
7302
+ return null;
7303
+ };
7304
+ const computeBbox = (geometry) => {
7305
+ let minLng = Infinity;
7306
+ let minLat = Infinity;
7307
+ let maxLng = -Infinity;
7308
+ let maxLat = -Infinity;
7309
+ const rings = geometry.type === "Polygon" ? geometry.coordinates : geometry.coordinates.flatMap(part => part);
7310
+ for (const ring of rings) {
7311
+ for (const [lng, lat] of ring) {
7312
+ if (lng < minLng)
7313
+ minLng = lng;
7314
+ if (lng > maxLng)
7315
+ maxLng = lng;
7316
+ if (lat < minLat)
7317
+ minLat = lat;
7318
+ if (lat > maxLat)
7319
+ maxLat = lat;
7320
+ }
7321
+ }
7322
+ return [minLng, minLat, maxLng, maxLat];
7323
+ };
7324
+ /**
7325
+ * Longitude-interval intersection that honours RFC 7946 §5.2 antimeridian-crossing
7326
+ * bboxes (west > east). Latitude stays a standard closed-interval test.
7327
+ *
7328
+ * Note: `computeBbox` produces a conservative [-180..180] box for MultiPolygon
7329
+ * sites that span the antimeridian after RFC 7946 coordinate-splitting; this
7330
+ * over-includes (never drops) those shapes, which is intentional.
7331
+ *
7332
+ * `useShapeDecorations.tsx` has its own copy of bboxesIntersect — left separate
7333
+ * (it is not exported and that file is out of scope for this fix).
7334
+ */
7335
+ const lngIntervalsIntersect = (aW, aE, bW, bE) => {
7336
+ const aWraps = aW > aE;
7337
+ const bWraps = bW > bE;
7338
+ if (aWraps && bWraps)
7339
+ return true;
7340
+ if (aWraps)
7341
+ return bE >= aW || bW <= aE;
7342
+ if (bWraps)
7343
+ return aE >= bW || aW <= bE;
7344
+ return aW <= bE && bW <= aE;
7345
+ };
7346
+ const bboxesIntersect$1 = (a, b) => lngIntervalsIntersect(a[0], a[2], b[0], b[2]) && a[3] >= b[1] && a[1] <= b[3];
7347
+ const ringArea = (ring) => {
7348
+ let sum = 0;
7349
+ for (let i = 0; i < ring.length - 1; i++) {
7350
+ const current = ring[i];
7351
+ const next = ring[i + 1];
7352
+ if (current === undefined || next === undefined)
7353
+ continue;
7354
+ sum += current[0] * next[1] - next[0] * current[1];
7355
+ }
7356
+ return Math.abs(sum) / 2;
7357
+ };
7358
+ /**
7359
+ * Planar area of any GeoJSON geometry (outer rings minus holes). Returns 0 for
7360
+ * non-polygonal types (Point, LineString, …). Relative magnitude only — not
7361
+ * geodesically accurate, but consistent enough for stack-order and tiebreak use.
7362
+ */
7363
+ const geometryArea = (geometry) => {
7364
+ if (geometry.type !== "Polygon" && geometry.type !== "MultiPolygon")
7365
+ return 0;
7366
+ const polygons = geometry.type === "Polygon" ? [geometry.coordinates] : geometry.coordinates;
7367
+ let total = 0;
7368
+ for (const rings of polygons) {
7369
+ rings.forEach((ring, index) => {
7370
+ const area = ringArea(ring);
7371
+ total += index === 0 ? area : -area;
7372
+ });
7373
+ }
7374
+ return Math.abs(total);
7375
+ };
7376
+ /** Inside any polygon part (holes respected). */
7377
+ const pointInGeometry = (position, geometry) => {
7378
+ const point = { type: "Point", coordinates: position };
7379
+ const polygons = geometry.type === "Polygon"
7380
+ ? [geometry]
7381
+ : geometry.coordinates.map(coordinates => ({ type: "Polygon", coordinates }));
7382
+ for (const polygon of polygons) {
7383
+ if (isGeoJsonPointInPolygon({ point, polygon }) === true)
7384
+ return true;
7385
+ }
7386
+ return false;
7387
+ };
7388
+ // ============================================================================
7389
+ // Shapes-under-cursor query (ADR-0021 §shapesUnderCursor)
7390
+ // ============================================================================
7391
+ /** Geographic degrees spanned by one screen pixel at a given zoom and tile size. */
7392
+ const degreesPerPixel = (zoom, tileSize) => 360 / (tileSize * Math.pow(2, zoom));
7393
+ /**
7394
+ * Adapter-agnostic hit test: given a cursor `position` and a list of GeoJSON
7395
+ * features, returns all features whose fill or stroke region contains the
7396
+ * position, along with raw distance-to-boundary for caller tiebreaks.
7397
+ *
7398
+ * - **fill**: point-in-polygon on the feature's **visible** fill geometry when
7399
+ * `visibleFillFor` is supplied (clipped tiling output); otherwise the full
7400
+ * feature geometry. Stroke distance always uses the full outline.
7401
+ * - **stroke**: `distanceToBoundary < max(strokeWidth, 3) / 2` pixels, converted
7402
+ * to geographic degrees via `degreesPerPixel(zoom, tileSize)`.
7403
+ * - Bbox-prefiltered for performance; results are in deterministic (feature-id) order.
7404
+ */
7405
+ const shapesUnderCursor = (position, features, options) => {
7406
+ const { zoom, tileSize, strokeWidthFor, visibleFillFor } = options;
7407
+ const degPerPx = degreesPerPixel(zoom, tileSize);
7408
+ const hits = [];
7409
+ for (const feature of features) {
7410
+ if (feature.id === undefined)
7411
+ continue;
7412
+ const geometry = polygonalGeometry(feature);
7413
+ if (geometry === null)
7414
+ continue;
7415
+ const featureId = String(feature.id);
7416
+ const bbox = computeBbox(geometry);
7417
+ // Loose bbox pre-filter: expand by a small margin for stroke proximity.
7418
+ const strokeWidth = strokeWidthFor(featureId);
7419
+ const strokeHalfDeg = (degPerPx * Math.max(strokeWidth, 3)) / 2;
7420
+ const expandedBbox = [
7421
+ bbox[0] - strokeHalfDeg,
7422
+ bbox[1] - strokeHalfDeg,
7423
+ bbox[2] + strokeHalfDeg,
7424
+ bbox[3] + strokeHalfDeg,
7425
+ ];
7426
+ const pointBbox = [position[0], position[1], position[0], position[1]];
7427
+ if (!bboxesIntersect$1(expandedBbox, pointBbox))
7428
+ continue;
7429
+ const fillGeom = fillGeometryForHitTest(featureId, geometry, visibleFillFor);
7430
+ const fill = fillGeom !== null && pointInGeometry(position, fillGeom);
7431
+ const distanceToBoundary = distanceToGeoJsonPolygonBoundary(position, geometry);
7432
+ const stroke = distanceToBoundary !== null && distanceToBoundary < strokeHalfDeg;
7433
+ if (fill || stroke) {
7434
+ hits.push({ featureId, fill, stroke, distanceToBoundary });
7435
+ }
7436
+ }
7437
+ // Deterministic order: sort by featureId string so callers get stable results.
7438
+ hits.sort((a, b) => (a.featureId < b.featureId ? -1 : a.featureId > b.featureId ? 1 : 0));
7439
+ return hits;
7440
+ };
7441
+ /**
7442
+ * Connected components of features that share fill area. Bbox prefilter then
7443
+ * exact polygon intersection, mirroring `countOverlappingShapes`.
7444
+ */
7445
+ const buildOverlapGroups = (records) => {
7446
+ const parent = records.map((_, index) => index);
7447
+ const find = (index) => {
7448
+ let root = index;
7449
+ while (parent[root] !== root)
7450
+ root = parent[root] ?? root;
7451
+ let node = index;
7452
+ while (parent[node] !== root) {
7453
+ const nextNode = parent[node] ?? root;
7454
+ parent[node] = root;
7455
+ node = nextNode;
7456
+ }
7457
+ return root;
7458
+ };
7459
+ const union = (a, b) => {
7460
+ parent[find(a)] = find(b);
7461
+ };
7462
+ for (let i = 0; i < records.length; i++) {
7463
+ for (let j = i + 1; j < records.length; j++) {
7464
+ const a = records[i];
7465
+ const b = records[j];
7466
+ if (a === undefined || b === undefined)
7467
+ continue;
7468
+ if (!bboxesIntersect$1(a.bbox, b.bbox))
7469
+ continue;
7470
+ if (getGeoJsonPolygonIntersection(a.geometry, b.geometry) !== null) {
7471
+ union(i, j);
7472
+ }
7473
+ }
7474
+ }
7475
+ const groups = new Map();
7476
+ records.forEach((record, index) => {
7477
+ const root = find(index);
7478
+ const group = groups.get(root);
7479
+ if (group === undefined) {
7480
+ groups.set(root, [record]);
7481
+ }
7482
+ else {
7483
+ group.push(record);
7484
+ }
7485
+ });
7486
+ return [...groups.values()].filter(group => group.length >= 2);
7487
+ };
7488
+ const buildContendedShapes = (group) => group.map(record => {
7489
+ const containedIn = [];
7490
+ for (const other of group) {
7491
+ if (other.id === record.id)
7492
+ continue;
7493
+ if (isFullyContainedInGeoJsonGeometry(record.geometry, other.geometry) === true) {
7494
+ containedIn.push(other.id);
7495
+ }
7496
+ }
7497
+ return {
7498
+ feature: record.feature,
7499
+ area: geometryArea(record.geometry),
7500
+ containedIn,
7501
+ };
7502
+ });
7503
+ /** Stable key for a group: sorted member ids. */
7504
+ const groupKeyOf = (group) => group
7505
+ .map(record => record.id)
7506
+ .sort()
7507
+ .join("|");
7508
+ /** Promote `id` to the front of `order` (no-op if not present). */
7509
+ const promoteToFront = (order, id) => order.includes(id) ? [id, ...order.filter(item => item !== id)] : order;
7510
+ /**
7511
+ * Hover-promotion overlay for one overlap group: the winner renders at full
7512
+ * geometry (caller removes its fill override). Each peer is clipped against the
7513
+ * winner's full outline starting from its **resting** fill (when present), not
7514
+ * its full geometry — so peer-to-peer overlap ownership from resting tiling is
7515
+ * preserved and translucent fills do not compound under the winner.
7516
+ */
7517
+ const computePromotionGroupFills = (members, winnerId, restingPeerFills = new Map()) => {
7518
+ const geometries = new Map();
7519
+ for (const feature of members) {
7520
+ if (feature.id === undefined)
7521
+ continue;
7522
+ const geometry = polygonalGeometry(feature);
7523
+ if (geometry !== null)
7524
+ geometries.set(String(feature.id), geometry);
7525
+ }
7526
+ const winnerGeometry = geometries.get(winnerId);
7527
+ if (winnerGeometry === undefined)
7528
+ return new Map();
7529
+ const result = new Map();
7530
+ const winnerMercator = projectPolygonalToWebMercator(winnerGeometry);
7531
+ for (const [memberId, fullGeometry] of geometries) {
7532
+ if (memberId === winnerId)
7533
+ continue;
7534
+ const restingClip = restingPeerFills.get(memberId);
7535
+ let subject;
7536
+ if (restingClip === undefined) {
7537
+ subject = fullGeometry;
7538
+ }
7539
+ else {
7540
+ const restingPolygonal = polygonalFromGeometry(restingClip);
7541
+ if (restingPolygonal === null) {
7542
+ result.set(memberId, EMPTY_FILL);
7543
+ continue;
7544
+ }
7545
+ subject = restingPolygonal;
7546
+ }
7547
+ const subjectMercator = projectPolygonalToWebMercator(subject);
7548
+ const clippedMercator = geoJsonPolygonDifference(subjectMercator, [winnerMercator]);
7549
+ const clipped = clippedMercator === null ? null : unprojectPolygonalFromWebMercator(clippedMercator);
7550
+ result.set(memberId, clipped === null ? EMPTY_FILL : clipped);
7551
+ }
7552
+ return result;
7553
+ };
7554
+ const computeFillTiling = (input) => {
7555
+ const { features, viewportBounds, resolveStackOrder, zoom, selectedFeatureId, suppressedFeatureIds } = input;
7556
+ const records = [];
7557
+ for (const feature of features) {
7558
+ if (feature.id === undefined)
7559
+ continue;
7560
+ const id = String(feature.id);
7561
+ const geometry = polygonalGeometry(feature);
7562
+ if (geometry === null)
7563
+ continue;
7564
+ const bbox = computeBbox(geometry);
7565
+ if (!bboxesIntersect$1(bbox, viewportBounds))
7566
+ continue;
7567
+ // Suppressed features are excluded from the overlap stack so they neither
7568
+ // claim fill ownership nor clip their neighbours.
7569
+ if (suppressedFeatureIds?.has(id) === true)
7570
+ continue;
7571
+ records.push({ feature, id, geometry, bbox });
7572
+ }
7573
+ const fillGeometries = new Map();
7574
+ const featureToGroupKey = new Map();
7575
+ const featureZIndex = new Map();
7576
+ const overlapGroups = buildOverlapGroups(records);
7577
+ for (const group of overlapGroups) {
7578
+ const contended = buildContendedShapes(group);
7579
+ const recordById = new Map(group.map(record => [record.id, record]));
7580
+ const key = groupKeyOf(group);
7581
+ for (const record of group) {
7582
+ featureToGroupKey.set(record.id, key);
7583
+ }
7584
+ // 1. Resting order: containment then smaller-area.
7585
+ let order = resolveStackOrder(contended, { zoom });
7586
+ // 2. Selection override: selected feature always takes front (highest priority).
7587
+ if (selectedFeatureId !== null && selectedFeatureId !== undefined) {
7588
+ order = promoteToFront(order, selectedFeatureId);
7589
+ }
7590
+ // Assign resting z-indices: rank 0 (front) → highest value, rank N-1 → 1.
7591
+ // These are stable across hover events; the hook applies a lightweight
7592
+ // promotion overlay on top without re-running computeFillTiling (ADR-0021 Path B).
7593
+ for (let rank = 0; rank < order.length; rank++) {
7594
+ const id = order[rank];
7595
+ if (id !== undefined) {
7596
+ featureZIndex.set(id, order.length - rank);
7597
+ }
7598
+ }
7599
+ // Top feature (index 0) keeps its full fill. Each lower-ranked feature is
7600
+ // clipped against the union of all features ranked above it.
7601
+ for (let rank = 1; rank < order.length; rank++) {
7602
+ const id = order[rank];
7603
+ if (id === undefined)
7604
+ continue;
7605
+ const record = recordById.get(id);
7606
+ if (record === undefined)
7607
+ continue;
7608
+ const higherRanked = [];
7609
+ for (let above = 0; above < rank; above++) {
7610
+ const aboveId = order[above];
7611
+ const aboveRecord = aboveId !== undefined ? recordById.get(aboveId) : undefined;
7612
+ if (aboveRecord !== undefined)
7613
+ higherRanked.push(aboveRecord.geometry);
7614
+ }
7615
+ // Clip in Web Mercator, not planar lng/lat. Google Maps and Mapbox both render
7616
+ // polygon edges as straight lines in Web Mercator, so a clip computed in lng/lat
7617
+ // produces boundary vertices that drift off the rendered edge of the covering
7618
+ // shape — visibly so on long, diagonal, high-latitude-span edges. Projecting
7619
+ // before the difference lands every cut exactly on the rendered edge (ADR-0021).
7620
+ const subjectMercator = projectPolygonalToWebMercator(record.geometry);
7621
+ const clipsMercator = higherRanked.map(projectPolygonalToWebMercator);
7622
+ const clippedMercator = geoJsonPolygonDifference(subjectMercator, clipsMercator);
7623
+ const clipped = clippedMercator === null ? null : unprojectPolygonalFromWebMercator(clippedMercator);
7624
+ fillGeometries.set(id, clipped === null ? EMPTY_FILL : clipped);
7625
+ }
7626
+ }
7627
+ // Suppressed features emit EMPTY_FILL when in the viewport so the adapter
7628
+ // paints nothing for their fill layer. They get no featureToGroupKey entry
7629
+ // so promoteFeature is a natural no-op for them.
7630
+ if (suppressedFeatureIds !== undefined && suppressedFeatureIds.size > 0) {
7631
+ for (const feature of features) {
7632
+ if (feature.id === undefined)
7633
+ continue;
7634
+ const id = String(feature.id);
7635
+ if (!suppressedFeatureIds.has(id))
7636
+ continue;
7637
+ const geometry = polygonalGeometry(feature);
7638
+ if (geometry === null)
7639
+ continue;
7640
+ const bbox = computeBbox(geometry);
7641
+ if (!bboxesIntersect$1(bbox, viewportBounds))
7642
+ continue;
7643
+ fillGeometries.set(id, EMPTY_FILL);
7644
+ }
7645
+ }
7646
+ return { fillGeometries, featureToGroupKey, featureZIndex };
7647
+ };
7648
+
7199
7649
  const DEFAULT_ZOOM = 0;
7200
7650
  const DEFAULT_TILE_SIZE = 256;
7201
7651
  /**
@@ -8511,11 +8961,31 @@ const countOverlappingShapes = (feature, featureBboxes, viewportBounds) => {
8511
8961
  const bbox = featureBboxes.get(feature);
8512
8962
  if (bbox === undefined || bbox === null)
8513
8963
  return 0;
8964
+ const geom = feature.geometry;
8965
+ const isPolygonal = geom !== null && (geom.type === "Polygon" || geom.type === "MultiPolygon");
8514
8966
  let count = 0;
8515
8967
  for (const [otherFeature, otherBbox] of featureBboxes) {
8516
8968
  if (otherFeature === feature || otherBbox === null)
8517
8969
  continue;
8518
- if (bboxesIntersect(otherBbox, viewportBounds) && bboxesIntersect(bbox, otherBbox)) {
8970
+ if (!bboxesIntersect(otherBbox, viewportBounds))
8971
+ continue;
8972
+ if (!bboxesIntersect(bbox, otherBbox))
8973
+ continue;
8974
+ const otherGeom = otherFeature.geometry;
8975
+ const otherIsPolygonal = otherGeom !== null && (otherGeom.type === "Polygon" || otherGeom.type === "MultiPolygon");
8976
+ if (isPolygonal && otherIsPolygonal) {
8977
+ // Precise polygon intersection — bbox was a prefilter only
8978
+ if (getGeoJsonPolygonIntersection(geom, otherGeom) !== null) {
8979
+ // If this feature is fully contained inside the other, its fill does not
8980
+ // obscure the base map layer below — only the outer's count increments.
8981
+ if (isFullyContainedInGeoJsonGeometry(geom, otherGeom) === true) {
8982
+ continue;
8983
+ }
8984
+ count++;
8985
+ }
8986
+ }
8987
+ else {
8988
+ // Points and lines keep bbox-only overlap (geometries have no fill area)
8519
8989
  count++;
8520
8990
  }
8521
8991
  }
@@ -8604,6 +9074,7 @@ const useShapeDecorations = ({ onDecorationsChange, onStyleOverridesChange, api,
8604
9074
  const previousAnchorGeos = useRef(new Map());
8605
9075
  const previousLayoutSides = useRef(new Map());
8606
9076
  const prevStyleOverridesRef = useRef(new Map());
9077
+ const prevDecorationLayersRef = useRef([]);
8607
9078
  /**
8608
9079
  * Labels that just transitioned from absent (hidden) to placed. While in
8609
9080
  * this set, edge identity is NOT stored — the algorithm gets one extra
@@ -8626,6 +9097,8 @@ const useShapeDecorations = ({ onDecorationsChange, onStyleOverridesChange, api,
8626
9097
  const viewport = useViewportContext(hasShapeHandles ? api : undefined);
8627
9098
  const isReady = api?.state.isReady ?? false;
8628
9099
  const theme = api?.state.appearance.theme ?? "light";
9100
+ const isMapMovingRef = useRef(false);
9101
+ const pendingDecorationUpdateRef = useRef(false);
8629
9102
  const updateDecorations = useCallback(() => {
8630
9103
  const bounds = viewport.bounds;
8631
9104
  if (bounds === null || !isReady) {
@@ -8884,7 +9357,14 @@ const useShapeDecorations = ({ onDecorationsChange, onStyleOverridesChange, api,
8884
9357
  });
8885
9358
  styleOverrides.set(handle.id, handleStyleOverrides);
8886
9359
  }
8887
- onDecorationsChange(newLayers);
9360
+ // Only push decoration layer changes when the output actually differs.
9361
+ // During pan, updateDecorations is called on every camera frame; skipping
9362
+ // unchanged outputs avoids triggering unnecessary React re-renders while
9363
+ // the camera is moving but no edge label has changed position.
9364
+ if (!isEqual(prevDecorationLayersRef.current, newLayers)) {
9365
+ prevDecorationLayersRef.current = newLayers;
9366
+ onDecorationsChange(newLayers);
9367
+ }
8888
9368
  if (!styleOverridesEqual(prevStyleOverridesRef.current, styleOverrides)) {
8889
9369
  prevStyleOverridesRef.current = styleOverrides;
8890
9370
  onStyleOverridesChange(styleOverrides);
@@ -8903,11 +9383,53 @@ const useShapeDecorations = ({ onDecorationsChange, onStyleOverridesChange, api,
8903
9383
  renderedSourceIds,
8904
9384
  isReady,
8905
9385
  ]);
9386
+ const updateDecorationsRef = useRef(updateDecorations);
8906
9387
  useEffect(() => {
8907
- updateDecorations();
8908
- }, [updateDecorations, readyRevision]);
9388
+ updateDecorationsRef.current = updateDecorations;
9389
+ }, [updateDecorations]);
9390
+ useEffect(() => {
9391
+ if (api === undefined || !hasShapeHandles)
9392
+ return;
9393
+ const unsubscribeMoveStart = api.on("movestart", () => {
9394
+ isMapMovingRef.current = true;
9395
+ });
9396
+ // Update decorations immediately on idle — no extra debounce. The 300 ms
9397
+ // debounce used by fill tiling is not needed here because:
9398
+ // 1. updateDecorations is cheap (pure JS placement math, no polygon clipping).
9399
+ // 2. pendingDecorationUpdateRef prevents redundant calls: only the first idle
9400
+ // after a pan triggers a real recompute; subsequent tile-load idles are
9401
+ // no-ops because the flag was already cleared.
9402
+ const unsubscribeIdle = api.on("idle", () => {
9403
+ isMapMovingRef.current = false;
9404
+ if (pendingDecorationUpdateRef.current) {
9405
+ pendingDecorationUpdateRef.current = false;
9406
+ updateDecorationsRef.current();
9407
+ }
9408
+ });
9409
+ return () => {
9410
+ unsubscribeMoveStart();
9411
+ unsubscribeIdle();
9412
+ };
9413
+ }, [api, hasShapeHandles]);
9414
+ useEffect(() => {
9415
+ pendingDecorationUpdateRef.current = true;
9416
+ }, [viewport]);
9417
+ useEffect(() => {
9418
+ pendingDecorationUpdateRef.current = true;
9419
+ if (isMapMovingRef.current)
9420
+ return;
9421
+ updateDecorationsRef.current();
9422
+ pendingDecorationUpdateRef.current = false;
9423
+ }, [readyRevision]);
8909
9424
  useEffect(() => {
8910
9425
  interactionRef.current = interaction;
9426
+ // Always update decorations when the viewport changes (updateDecorations ref
9427
+ // is recreated on every camera frame because viewport is in its useCallback
9428
+ // deps). This makes edge labels track the viewport boundary in real-time
9429
+ // during pan instead of only snapping on idle.
9430
+ // The prevDecorationLayersRef equality guard inside updateDecorations
9431
+ // prevents onDecorationsChange from firing when the positions haven't
9432
+ // actually changed, keeping React re-renders cheap.
8911
9433
  updateDecorations();
8912
9434
  }, [interaction, updateDecorations]);
8913
9435
  useEffect(() => {
@@ -8926,6 +9448,674 @@ const useShapeDecorations = ({ onDecorationsChange, onStyleOverridesChange, api,
8926
9448
  return useMemo(() => ({ subscribe, getSnapshot }), [subscribe, getSnapshot]);
8927
9449
  };
8928
9450
 
9451
+ // ============================================================================
9452
+ // Default resolver
9453
+ // ============================================================================
9454
+ const featureId = (shape) => String(shape.feature.id);
9455
+ /**
9456
+ * Default stack-order policy (ADR-0021 "golden path"). In priority order:
9457
+ *
9458
+ * 1. **Containment** — a shape contained within another ranks above its
9459
+ * container, so the specific inner site beats the broad outer one.
9460
+ * 2. **Smaller area on top** — stable tiebreak; inner shapes are typically
9461
+ * smaller, so this reinforces containment and keeps nested sites visible.
9462
+ */
9463
+ const defaultShapeStackOrder = (contended, _ctx) => {
9464
+ const idOf = new Map();
9465
+ for (const shape of contended) {
9466
+ idOf.set(shape, featureId(shape));
9467
+ }
9468
+ const compare = (a, b) => {
9469
+ const aId = idOf.get(a) ?? featureId(a);
9470
+ const bId = idOf.get(b) ?? featureId(b);
9471
+ // 1. Containment — a contained inside b ranks above (on top of) b.
9472
+ const aInsideB = a.containedIn.includes(bId);
9473
+ const bInsideA = b.containedIn.includes(aId);
9474
+ if (aInsideB && !bInsideA)
9475
+ return -1;
9476
+ if (bInsideA && !aInsideB)
9477
+ return 1;
9478
+ // 2. Smaller area on top.
9479
+ if (a.area !== b.area)
9480
+ return a.area - b.area;
9481
+ // Final stable tiebreak by id so ordering is deterministic.
9482
+ return aId < bId ? -1 : aId > bId ? 1 : 0;
9483
+ };
9484
+ return [...contended].sort(compare).map(shape => idOf.get(shape) ?? featureId(shape));
9485
+ };
9486
+
9487
+ const EMPTY_OVERRIDES = new Map();
9488
+ const EMPTY_ZINDEX_OVERRIDES = new Map();
9489
+ /** How long to wait after the last pointermove before running the stroke hit-test (ms). */
9490
+ const SETTLE_DEBOUNCE_MS = 120;
9491
+ /**
9492
+ * Coalesce rapid `idle` events (wheel-zoom steps, tile-load idles during pan)
9493
+ * into one resting recompute. 300 ms is wide enough to absorb the tile-load
9494
+ * idle bursts that Google Maps fires every 100–300 ms during and just after
9495
+ * panning, so only the final true-idle triggers `computeFillTiling`.
9496
+ */
9497
+ const IDLE_RECOMPUTE_DEBOUNCE_MS = 300;
9498
+ /** Default stroke width when featureStyles does not specify one. */
9499
+ const DEFAULT_STROKE_WIDTH = 2;
9500
+ /**
9501
+ * Z-index override applied to a hover-promoted feature so it renders above all
9502
+ * resting-order peers in its group. Must exceed the maximum possible resting z-index
9503
+ * (which equals groupSize, bounded by the number of features in a viewport).
9504
+ */
9505
+ const HOVER_PROMOTION_Z_INDEX = 10000;
9506
+ /**
9507
+ * FNV-1a 32-bit hash over an array of coordinate numbers. Cheap and
9508
+ * change-detecting: any moved vertex changes the output; identical polls keep
9509
+ * it stable so the tiling useMemo does not recompute unnecessarily.
9510
+ */
9511
+ const fnv1a32Coords = (coords) => {
9512
+ let h = 0x811c9dc5;
9513
+ for (const n of coords) {
9514
+ // Pack each float as two 16-bit halves to avoid float-to-int truncation loss.
9515
+ const bits = Math.round(n * 1e7);
9516
+ h ^= (bits >>> 16) & 0xffff;
9517
+ h = (Math.imul(h, 0x01000193) | 0) >>> 0;
9518
+ h ^= bits & 0xffff;
9519
+ h = (Math.imul(h, 0x01000193) | 0) >>> 0;
9520
+ }
9521
+ return h;
9522
+ };
9523
+ const geometryCoordFingerprint = (feature) => {
9524
+ const geom = feature.geometry;
9525
+ if (geom === null)
9526
+ return "null";
9527
+ if (geom.type === "Polygon") {
9528
+ const coords = geom.coordinates.flat(2);
9529
+ return `${coords.length}:${fnv1a32Coords(coords)}`;
9530
+ }
9531
+ if (geom.type === "MultiPolygon") {
9532
+ const coords = geom.coordinates.flat(3);
9533
+ return `${coords.length}:${fnv1a32Coords(coords)}`;
9534
+ }
9535
+ return geom.type;
9536
+ };
9537
+ /** Content key for tiling handles — stable across new `handles` array refs with the same data.
9538
+ * Includes a geometry fingerprint per feature so polygon edits (same id, new vertices) trigger
9539
+ * a recompute rather than serving a stale clip. */
9540
+ const buildTilingContentKey = (handles) => {
9541
+ const segments = [];
9542
+ for (const handle of handles) {
9543
+ if (handle.layerType !== "shapes" || handle.overlap?.mode !== "tile")
9544
+ continue;
9545
+ const featureSegments = handle.features.features.map(feature => `${String(feature.id ?? "")}:${geometryCoordFingerprint(feature)}`);
9546
+ segments.push(`${handle.id}:${featureSegments.join(",")}`);
9547
+ }
9548
+ return segments.join("||");
9549
+ };
9550
+ const overridesEqual = (a, b) => {
9551
+ if (a === b)
9552
+ return true;
9553
+ if (a.size !== b.size)
9554
+ return false;
9555
+ for (const [handleId, aHandle] of a) {
9556
+ const bHandle = b.get(handleId);
9557
+ if (bHandle === undefined || aHandle.size !== bHandle.size)
9558
+ return false;
9559
+ for (const [featureId, aGeom] of aHandle) {
9560
+ if (!isEqual(aGeom, bHandle.get(featureId)))
9561
+ return false;
9562
+ }
9563
+ }
9564
+ return true;
9565
+ };
9566
+ const zIndexOverridesEqual = (a, b) => {
9567
+ if (a === b)
9568
+ return true;
9569
+ if (a.size !== b.size)
9570
+ return false;
9571
+ for (const [handleId, aHandle] of a) {
9572
+ const bHandle = b.get(handleId);
9573
+ if (bHandle === undefined || aHandle.size !== bHandle.size)
9574
+ return false;
9575
+ for (const [featureId, aZ] of aHandle) {
9576
+ if (aZ !== bHandle.get(featureId))
9577
+ return false;
9578
+ }
9579
+ }
9580
+ return true;
9581
+ };
9582
+ /**
9583
+ * Pre-build a `featureId → area` lookup and return a comparator that sorts
9584
+ * hits smallest-area-first with featureId as a stable tiebreak. Costs O(n)
9585
+ * upfront instead of O(n) per comparison in the sort.
9586
+ */
9587
+ const buildHitAreaComparator = (features) => {
9588
+ const areaById = new Map();
9589
+ for (const feature of features) {
9590
+ if (feature.id !== undefined) {
9591
+ areaById.set(String(feature.id), feature.geometry !== null ? geometryArea(feature.geometry) : 0);
9592
+ }
9593
+ }
9594
+ return (a, b) => {
9595
+ const aArea = areaById.get(a.featureId) ?? 0;
9596
+ const bArea = areaById.get(b.featureId) ?? 0;
9597
+ if (aArea !== bArea)
9598
+ return aArea - bArea;
9599
+ return a.featureId < b.featureId ? -1 : 1;
9600
+ };
9601
+ };
9602
+ /**
9603
+ * Pick one fill hit for promotion; smallest area wins when multiple remain.
9604
+ * Accepts a pre-built comparator so the area lookup map is shared with the
9605
+ * caller's per-group sort.
9606
+ */
9607
+ const pickFillPromotionCandidates = (fillHits, compareByArea) => {
9608
+ if (fillHits.length === 0)
9609
+ return [];
9610
+ if (fillHits.length === 1)
9611
+ return fillHits;
9612
+ const sorted = [...fillHits].sort(compareByArea);
9613
+ const top = sorted[0];
9614
+ return top === undefined ? [] : [top];
9615
+ };
9616
+ /**
9617
+ * `useShapeFillTiling` — clips overlapping polygon fills for shape handles that
9618
+ * opted in via `overlap: { mode: "tile" }`, and promotes shapes on stroke-hover
9619
+ * settle (sticky, ~120ms debounce), decoration hover (edge labels, etc.), or when
9620
+ * a shape is selected (selection always forces front). Emits the result through
9621
+ * `onFillGeometriesChange` and `onZIndexOverridesChange`; `<Layers>` merges them
9622
+ * into each shape handle's `featureFillGeometries` and `featureZIndexOverrides`.
9623
+ *
9624
+ * **Path B (ADR-0021):** `computeFillTiling` is promotion-unaware — it only runs on
9625
+ * viewport / feature-set / selection changes and produces stable resting fills and
9626
+ * resting z-indices. Hover promotion removes the resting clip for each group's
9627
+ * current sticky winner (so covered area can paint on top) and raises its z-index.
9628
+ * Non-winners in an active group are re-clipped against the winner's full outline
9629
+ * only (exclusive rings stay painted). Promotion clears when the pointer leaves
9630
+ * all sites on the handle.
9631
+ *
9632
+ * Resting tiling (viewport + feature set) is deferred while the map is moving
9633
+ * and coalesced on `idle` so pan/zoom does not run `computeFillTiling` every frame.
9634
+ *
9635
+ * @internal
9636
+ */
9637
+ const useShapeFillTiling = ({ api, handles, onFillGeometriesChange, onZIndexOverridesChange, onSettledHover, selectedFeatureId, suppressedFillIdsByHandle, }) => {
9638
+ const tilingContentKey = useMemo(() => buildTilingContentKey(handles), [handles]);
9639
+ const tilingHandles = useMemo(() => handles.filter((handle) => handle.layerType === "shapes" && handle.overlap?.mode === "tile"), [handles]);
9640
+ const hasTilingHandles = tilingHandles.length > 0;
9641
+ const viewport = useViewportContext(hasTilingHandles ? api : undefined);
9642
+ const onFillChangeRef = useRef(onFillGeometriesChange);
9643
+ useEffect(() => {
9644
+ onFillChangeRef.current = onFillGeometriesChange;
9645
+ }, [onFillGeometriesChange]);
9646
+ const onZIndexChangeRef = useRef(onZIndexOverridesChange);
9647
+ useEffect(() => {
9648
+ onZIndexChangeRef.current = onZIndexOverridesChange;
9649
+ }, [onZIndexOverridesChange]);
9650
+ const onSettledHoverRef = useRef(onSettledHover);
9651
+ useEffect(() => {
9652
+ onSettledHoverRef.current = onSettledHover;
9653
+ }, [onSettledHover]);
9654
+ const selectedFeatureIdRef = useRef(selectedFeatureId);
9655
+ useEffect(() => {
9656
+ selectedFeatureIdRef.current = selectedFeatureId;
9657
+ }, [selectedFeatureId]);
9658
+ const suppressedFillIdsByHandleRef = useRef(suppressedFillIdsByHandle);
9659
+ useEffect(() => {
9660
+ suppressedFillIdsByHandleRef.current = suppressedFillIdsByHandle;
9661
+ }, [suppressedFillIdsByHandle]);
9662
+ const debounceTimerRef = useRef(null);
9663
+ const idleRecomputeTimerRef = useRef(null);
9664
+ // Currently emitted overrides — used for hit-testing visible fills in handleSettle.
9665
+ const prevFillOverridesRef = useRef(EMPTY_OVERRIDES);
9666
+ const prevZIndexOverridesRef = useRef(EMPTY_ZINDEX_OVERRIDES);
9667
+ // Resting state from the last computeFillTiling run (stable across hover events).
9668
+ const restingFillByHandleRef = useRef(new Map());
9669
+ const restingZIndexByHandleRef = useRef(new Map());
9670
+ // Per-handle: groupKey → promoted feature id (sticky hover winner).
9671
+ const promotedByHandleRef = useRef(new Map());
9672
+ // Per-handle: featureId → groupKey (populated from last computeFillTiling result).
9673
+ const featureToGroupKeyByHandleRef = useRef(new Map());
9674
+ /** True while an interactive shape decoration (edge label, badge, …) is hovered. */
9675
+ const decorationHoverActiveRef = useRef(false);
9676
+ // Stable ref to current tiling handles for the debounce callback.
9677
+ const tilingHandlesRef = useRef(tilingHandles);
9678
+ useEffect(() => {
9679
+ tilingHandlesRef.current = tilingHandles;
9680
+ });
9681
+ // Stable ref to current viewport for the debounce callback.
9682
+ const viewportRef = useRef(viewport);
9683
+ useEffect(() => {
9684
+ viewportRef.current = viewport;
9685
+ });
9686
+ /** True between map `movestart` and debounced `idle` flush. */
9687
+ const isMapMovingRef = useRef(false);
9688
+ /** Viewport or tiling data changed while moving or before idle flush. */
9689
+ const pendingRestingRecomputeRef = useRef(false);
9690
+ const lastComputeInputKeyRef = useRef(null);
9691
+ const tilingContentKeyRef = useRef(tilingContentKey);
9692
+ useEffect(() => {
9693
+ tilingContentKeyRef.current = tilingContentKey;
9694
+ }, [tilingContentKey]);
9695
+ /**
9696
+ * Apply the current promotion state on top of resting fills and z-indices and emit
9697
+ * the result. Called both at the end of `recompute` and directly on hover-settle
9698
+ * without re-running `computeFillTiling` (Path B: promotion is a cheap overlay).
9699
+ *
9700
+ * Active promotion: winner unclipped; peers in the same group are clipped against
9701
+ * the winner's full geometry so exclusive rings stay painted without compounding.
9702
+ */
9703
+ const emitWithPromotion = useCallback(() => {
9704
+ const nextFills = new Map();
9705
+ const nextZIndices = new Map();
9706
+ const handleById = new Map(tilingHandlesRef.current.map(handle => [handle.id, handle]));
9707
+ for (const [handleId, restingFills] of restingFillByHandleRef.current) {
9708
+ const promoted = promotedByHandleRef.current.get(handleId);
9709
+ const featureToGroupKey = featureToGroupKeyByHandleRef.current.get(handleId);
9710
+ if (promoted === undefined || promoted.size === 0) {
9711
+ if (restingFills.size > 0)
9712
+ nextFills.set(handleId, restingFills);
9713
+ }
9714
+ else if (featureToGroupKey === undefined) {
9715
+ const modifiedFills = new Map(restingFills);
9716
+ for (const promotedId of promoted.values()) {
9717
+ modifiedFills.delete(promotedId);
9718
+ }
9719
+ if (modifiedFills.size > 0)
9720
+ nextFills.set(handleId, modifiedFills);
9721
+ }
9722
+ else {
9723
+ const handle = handleById.get(handleId);
9724
+ const featuresById = new Map();
9725
+ if (handle !== undefined) {
9726
+ for (const feature of handle.features.features) {
9727
+ if (feature.id !== undefined)
9728
+ featuresById.set(String(feature.id), feature);
9729
+ }
9730
+ }
9731
+ const modifiedFills = new Map(restingFills);
9732
+ for (const [groupKey, winnerId] of promoted) {
9733
+ const groupMembers = [];
9734
+ for (const [featureId, memberGroupKey] of featureToGroupKey) {
9735
+ if (memberGroupKey !== groupKey)
9736
+ continue;
9737
+ const feature = featuresById.get(featureId);
9738
+ if (feature !== undefined)
9739
+ groupMembers.push(feature);
9740
+ }
9741
+ const restingPeerFills = new Map();
9742
+ for (const [featureId, memberGroupKey] of featureToGroupKey) {
9743
+ if (memberGroupKey !== groupKey)
9744
+ continue;
9745
+ const restingClip = restingFills.get(featureId);
9746
+ if (restingClip !== undefined)
9747
+ restingPeerFills.set(featureId, restingClip);
9748
+ }
9749
+ const peerFills = computePromotionGroupFills(groupMembers, winnerId, restingPeerFills);
9750
+ modifiedFills.delete(winnerId);
9751
+ for (const [featureId, clip] of peerFills) {
9752
+ modifiedFills.set(featureId, clip);
9753
+ }
9754
+ }
9755
+ if (modifiedFills.size > 0)
9756
+ nextFills.set(handleId, modifiedFills);
9757
+ }
9758
+ }
9759
+ for (const [handleId, restingZIndices] of restingZIndexByHandleRef.current) {
9760
+ const promoted = promotedByHandleRef.current.get(handleId);
9761
+ if (promoted === undefined || promoted.size === 0) {
9762
+ if (restingZIndices.size > 0)
9763
+ nextZIndices.set(handleId, restingZIndices);
9764
+ }
9765
+ else {
9766
+ const featureToGroupKey = featureToGroupKeyByHandleRef.current.get(handleId);
9767
+ const modifiedZIndices = new Map(restingZIndices);
9768
+ for (const [groupKey, winnerId] of promoted) {
9769
+ modifiedZIndices.set(winnerId, HOVER_PROMOTION_Z_INDEX);
9770
+ if (featureToGroupKey !== undefined) {
9771
+ for (const [featureId, memberGroupKey] of featureToGroupKey) {
9772
+ if (memberGroupKey === groupKey && featureId !== winnerId) {
9773
+ modifiedZIndices.set(featureId, 0);
9774
+ }
9775
+ }
9776
+ }
9777
+ }
9778
+ nextZIndices.set(handleId, modifiedZIndices);
9779
+ }
9780
+ }
9781
+ if (!overridesEqual(prevFillOverridesRef.current, nextFills)) {
9782
+ prevFillOverridesRef.current = nextFills;
9783
+ onFillChangeRef.current(nextFills);
9784
+ }
9785
+ if (!zIndexOverridesEqual(prevZIndexOverridesRef.current, nextZIndices)) {
9786
+ prevZIndexOverridesRef.current = nextZIndices;
9787
+ onZIndexChangeRef.current(nextZIndices);
9788
+ }
9789
+ }, []);
9790
+ const recompute = useCallback(() => {
9791
+ const currentViewport = viewportRef.current;
9792
+ const currentHandles = tilingHandlesRef.current;
9793
+ // Stable suppressed-key: per-handle sorted ids so suppression changes are
9794
+ // not deduped away by the inputKey guard.
9795
+ const currentSuppressed = suppressedFillIdsByHandleRef.current;
9796
+ const suppressedKey = currentSuppressed === undefined || currentSuppressed.size === 0
9797
+ ? ""
9798
+ : [...currentSuppressed.entries()]
9799
+ .sort(([a], [b]) => (a < b ? -1 : 1))
9800
+ .map(([handleId, ids]) => `${handleId}:${[...ids].sort().join(",")}`)
9801
+ .join(";");
9802
+ // Bounds are intentionally excluded from the inputKey. Clip geometry depends on
9803
+ // zoom (which affects stack order via resolveStackOrder) and feature geometry —
9804
+ // not on the visible viewport region. Using global bounds means all overlapping
9805
+ // features always have pre-computed clips, so polygons entering the viewport
9806
+ // during zoom-out never flash their unclipped fill.
9807
+ const inputKey = `${tilingContentKeyRef.current}|${currentViewport.zoom}|${selectedFeatureIdRef.current ?? ""}|${suppressedKey}`;
9808
+ if (inputKey === lastComputeInputKeyRef.current) {
9809
+ return;
9810
+ }
9811
+ lastComputeInputKeyRef.current = inputKey;
9812
+ if (currentHandles.length === 0) {
9813
+ if (prevFillOverridesRef.current !== EMPTY_OVERRIDES && prevFillOverridesRef.current.size > 0) {
9814
+ restingFillByHandleRef.current = new Map();
9815
+ restingZIndexByHandleRef.current = new Map();
9816
+ prevFillOverridesRef.current = EMPTY_OVERRIDES;
9817
+ prevZIndexOverridesRef.current = EMPTY_ZINDEX_OVERRIDES;
9818
+ promotedByHandleRef.current = new Map();
9819
+ featureToGroupKeyByHandleRef.current = new Map();
9820
+ onFillChangeRef.current(EMPTY_OVERRIDES);
9821
+ onZIndexChangeRef.current(EMPTY_ZINDEX_OVERRIDES);
9822
+ }
9823
+ return;
9824
+ }
9825
+ // Use global bounds so all overlapping features always have pre-computed clips,
9826
+ // regardless of which part of the world is currently in view.
9827
+ const GLOBAL_BOUNDS = [-180, -90, 180, 90];
9828
+ const nextRestingFills = new Map();
9829
+ const nextRestingZIndices = new Map();
9830
+ for (const handle of currentHandles) {
9831
+ const { fillGeometries, featureToGroupKey, featureZIndex } = computeFillTiling({
9832
+ features: handle.features.features,
9833
+ viewportBounds: GLOBAL_BOUNDS,
9834
+ resolveStackOrder: handle.overlap?.resolveStackOrder ?? defaultShapeStackOrder,
9835
+ zoom: currentViewport.zoom,
9836
+ selectedFeatureId: selectedFeatureIdRef.current,
9837
+ suppressedFeatureIds: suppressedFillIdsByHandleRef.current?.get(handle.id),
9838
+ });
9839
+ featureToGroupKeyByHandleRef.current.set(handle.id, new Map(featureToGroupKey));
9840
+ if (fillGeometries.size > 0) {
9841
+ nextRestingFills.set(handle.id, fillGeometries);
9842
+ }
9843
+ if (featureZIndex.size > 0) {
9844
+ nextRestingZIndices.set(handle.id, featureZIndex);
9845
+ }
9846
+ }
9847
+ restingFillByHandleRef.current = nextRestingFills;
9848
+ restingZIndexByHandleRef.current = nextRestingZIndices;
9849
+ emitWithPromotion();
9850
+ }, [emitWithPromotion]);
9851
+ const flushPendingRestingRecompute = useCallback(() => {
9852
+ if (!pendingRestingRecomputeRef.current)
9853
+ return;
9854
+ pendingRestingRecomputeRef.current = false;
9855
+ // Sticky promotions survive pan/zoom — resting clips are always pre-computed
9856
+ // globally (global-bounds fix) so the promoted feature is never "full-fill".
9857
+ recompute();
9858
+ }, [recompute]);
9859
+ const scheduleIdleRestingRecompute = useCallback(() => {
9860
+ if (idleRecomputeTimerRef.current !== null) {
9861
+ clearTimeout(idleRecomputeTimerRef.current);
9862
+ }
9863
+ idleRecomputeTimerRef.current = setTimeout(() => {
9864
+ idleRecomputeTimerRef.current = null;
9865
+ isMapMovingRef.current = false;
9866
+ flushPendingRestingRecompute();
9867
+ }, IDLE_RECOMPUTE_DEBOUNCE_MS);
9868
+ }, [flushPendingRestingRecompute]);
9869
+ const promoteFeature = useCallback((handleId, featureId) => {
9870
+ const groupKey = featureToGroupKeyByHandleRef.current.get(handleId)?.get(featureId);
9871
+ if (groupKey === undefined)
9872
+ return;
9873
+ let handleMap = promotedByHandleRef.current.get(handleId);
9874
+ if (handleMap === undefined) {
9875
+ handleMap = new Map();
9876
+ promotedByHandleRef.current.set(handleId, handleMap);
9877
+ }
9878
+ if (handleMap.get(groupKey) === featureId)
9879
+ return;
9880
+ handleMap.set(groupKey, featureId);
9881
+ // Path B: emit lightweight promotion overlay — no recompute.
9882
+ emitWithPromotion();
9883
+ }, [emitWithPromotion]);
9884
+ const clearPromotion = useCallback((handleId, featureId) => {
9885
+ const groupKey = featureToGroupKeyByHandleRef.current.get(handleId)?.get(featureId);
9886
+ if (groupKey === undefined)
9887
+ return;
9888
+ const handleMap = promotedByHandleRef.current.get(handleId);
9889
+ if (handleMap === undefined || handleMap.get(groupKey) !== featureId)
9890
+ return;
9891
+ handleMap.delete(groupKey);
9892
+ emitWithPromotion();
9893
+ }, [emitWithPromotion]);
9894
+ // Tiling feature set changed (stable key — not the `tilingHandles` array ref).
9895
+ useEffect(() => {
9896
+ pendingRestingRecomputeRef.current = true;
9897
+ if (isMapMovingRef.current)
9898
+ return;
9899
+ recompute();
9900
+ }, [recompute, tilingContentKey]);
9901
+ useEffect(() => {
9902
+ recompute();
9903
+ }, [recompute, selectedFeatureId]);
9904
+ // Suppression changes must trigger a recompute so EMPTY_FILL entries are
9905
+ // added / removed even when the map is idle (covers the decorations-vs-tiling
9906
+ // idle race where viewport-style overrides arrive after the last idle event).
9907
+ useEffect(() => {
9908
+ pendingRestingRecomputeRef.current = true;
9909
+ if (!isMapMovingRef.current)
9910
+ recompute();
9911
+ }, [recompute, suppressedFillIdsByHandle]);
9912
+ // Viewport changes only mark pending; resting recompute runs on debounced map idle.
9913
+ useEffect(() => {
9914
+ pendingRestingRecomputeRef.current = true;
9915
+ }, [viewport]);
9916
+ // Pointer channel: settle-debounce → stroke hit-test → promote sticky winner.
9917
+ useEffect(() => {
9918
+ if (api === undefined || !hasTilingHandles)
9919
+ return;
9920
+ const unsubscribeMoveStart = api.on("movestart", () => {
9921
+ isMapMovingRef.current = true;
9922
+ if (idleRecomputeTimerRef.current !== null) {
9923
+ clearTimeout(idleRecomputeTimerRef.current);
9924
+ idleRecomputeTimerRef.current = null;
9925
+ }
9926
+ // Promotions intentionally NOT cleared here: resting clips are always
9927
+ // pre-computed globally so a promoted feature stays correctly clipped
9928
+ // throughout pan/zoom. Promotion only resets when the cursor settles on
9929
+ // a different group (handleSettle / clearPromotion path).
9930
+ });
9931
+ const unsubscribeIdle = api.on("idle", () => {
9932
+ scheduleIdleRestingRecompute();
9933
+ });
9934
+ const handleSettle = (position) => {
9935
+ const currentViewport = viewportRef.current;
9936
+ const currentHandles = tilingHandlesRef.current;
9937
+ const settleHitsByHandle = new Map();
9938
+ for (const handle of currentHandles) {
9939
+ const strokeWidthFor = (featureId) => handle.featureStyles?.get(featureId)?.strokeWidth ?? DEFAULT_STROKE_WIDTH;
9940
+ const handleFillOverrides = prevFillOverridesRef.current.get(handle.id);
9941
+ const suppressedIds = suppressedFillIdsByHandleRef.current?.get(handle.id);
9942
+ const visibleFillFor = handleFillOverrides === undefined && suppressedIds === undefined
9943
+ ? undefined
9944
+ : (featureId) => {
9945
+ if (suppressedIds?.has(featureId) === true)
9946
+ return null;
9947
+ return handleFillOverrides?.get(featureId);
9948
+ };
9949
+ const hits = shapesUnderCursor(position, handle.features.features, {
9950
+ zoom: currentViewport.zoom,
9951
+ tileSize: currentViewport.tileSize,
9952
+ strokeWidthFor,
9953
+ visibleFillFor,
9954
+ });
9955
+ handle.onShapesUnderCursor?.(hits, { position });
9956
+ settleHitsByHandle.set(handle.id, hits);
9957
+ }
9958
+ // Count hits that could trigger promotion: stroke hits plus unambiguous
9959
+ // single-fill hits (poked-out area). Used to guard the recompute-on-move.
9960
+ const totalCandidateHits = [...settleHitsByHandle.values()].reduce((count, hits) => {
9961
+ const strokeCount = hits.filter(h => h.stroke === true).length;
9962
+ if (strokeCount > 0)
9963
+ return count + strokeCount;
9964
+ const fillHits = hits.filter(h => h.fill === true);
9965
+ return count + (fillHits.length >= 1 ? 1 : 0);
9966
+ }, 0);
9967
+ // While panning, group keys are not updated every frame — refresh once per
9968
+ // settle when any candidate hit exists so promotion targets the right group.
9969
+ if (isMapMovingRef.current && totalCandidateHits > 0) {
9970
+ recompute();
9971
+ }
9972
+ // Sticky promotion: hovering nothing keeps all promotions — the cursor
9973
+ // drifted off, the last winner stays in front. Hovering something updates
9974
+ // groups under the cursor and clears groups no longer under the cursor
9975
+ // (user's attention has moved on from those groups).
9976
+ if (decorationHoverActiveRef.current) {
9977
+ return;
9978
+ }
9979
+ // Skip promotion updates while the map is moving. The map fires
9980
+ // pointermove during wheel zoom (world coords shift even with a stationary
9981
+ // cursor), causing settle to run mid-zoom. Promotions should not change
9982
+ // during movement — the sticky winner stays in front through pan/zoom.
9983
+ if (isMapMovingRef.current) {
9984
+ return;
9985
+ }
9986
+ let overallTop;
9987
+ for (const handle of currentHandles) {
9988
+ const hits = settleHitsByHandle.get(handle.id);
9989
+ if (hits === undefined)
9990
+ continue;
9991
+ const strokeHits = hits.filter(h => h.stroke === true);
9992
+ // Build the area lookup once per handle; shared by the fill-pick and
9993
+ // the per-group sort below so we never call features.find() inside a comparator.
9994
+ const compareByArea = buildHitAreaComparator(handle.features.features);
9995
+ // Fill promotion uses visible (clipped) fill geometry from the last
9996
+ // tiling pass. Stroke hits take priority; otherwise the visible fill
9997
+ // owner wins (smallest-area tiebreak if multiple remain).
9998
+ const allFillHits = hits.filter(h => h.fill === true);
9999
+ const candidateHits = strokeHits.length > 0 ? strokeHits : pickFillPromotionCandidates(allFillHits, compareByArea);
10000
+ const groupKeyMap = featureToGroupKeyByHandleRef.current.get(handle.id) ?? new Map();
10001
+ const hitsByGroup = new Map();
10002
+ for (const hit of candidateHits) {
10003
+ const groupKey = groupKeyMap.get(hit.featureId);
10004
+ if (groupKey === undefined)
10005
+ continue;
10006
+ const existing = hitsByGroup.get(groupKey);
10007
+ if (existing === undefined) {
10008
+ hitsByGroup.set(groupKey, [hit]);
10009
+ }
10010
+ else {
10011
+ existing.push(hit);
10012
+ }
10013
+ }
10014
+ // When the cursor is over something, clear promotions for groups that
10015
+ // have no hit in this settle — this resets groups the cursor has left.
10016
+ // When the cursor is over nothing (totalCandidateHits === 0) we skip
10017
+ // this so the last winner stays in front (sticky promotion).
10018
+ if (totalCandidateHits > 0) {
10019
+ const handlePromotion = promotedByHandleRef.current.get(handle.id);
10020
+ if (handlePromotion !== undefined) {
10021
+ for (const [groupKey, promotedFeatureId] of [...handlePromotion.entries()]) {
10022
+ if (!hitsByGroup.has(groupKey))
10023
+ clearPromotion(handle.id, promotedFeatureId);
10024
+ }
10025
+ }
10026
+ }
10027
+ const featuresById = new Map();
10028
+ for (const feature of handle.features.features) {
10029
+ if (feature.id !== undefined)
10030
+ featuresById.set(String(feature.id), feature);
10031
+ }
10032
+ for (const [, groupHits] of hitsByGroup) {
10033
+ const top = [...groupHits].sort(compareByArea)[0];
10034
+ if (top === undefined)
10035
+ continue;
10036
+ promoteFeature(handle.id, top.featureId);
10037
+ const topFeature = featuresById.get(top.featureId);
10038
+ const topArea = topFeature?.geometry !== null && topFeature?.geometry !== undefined ? geometryArea(topFeature.geometry) : 0;
10039
+ if (overallTop === undefined || topArea < overallTop.area) {
10040
+ overallTop = { handleId: handle.id, featureId: top.featureId, area: topArea };
10041
+ }
10042
+ }
10043
+ }
10044
+ if (overallTop !== undefined) {
10045
+ onSettledHoverRef.current?.(overallTop.handleId, overallTop.featureId);
10046
+ }
10047
+ };
10048
+ const unsubscribe = api.on("pointermove", event => {
10049
+ if (debounceTimerRef.current !== null) {
10050
+ clearTimeout(debounceTimerRef.current);
10051
+ }
10052
+ const pos = event.position;
10053
+ debounceTimerRef.current = setTimeout(() => {
10054
+ debounceTimerRef.current = null;
10055
+ handleSettle(pos);
10056
+ }, SETTLE_DEBOUNCE_MS);
10057
+ });
10058
+ return () => {
10059
+ unsubscribeMoveStart();
10060
+ unsubscribeIdle();
10061
+ unsubscribe();
10062
+ if (debounceTimerRef.current !== null) {
10063
+ clearTimeout(debounceTimerRef.current);
10064
+ debounceTimerRef.current = null;
10065
+ }
10066
+ if (idleRecomputeTimerRef.current !== null) {
10067
+ clearTimeout(idleRecomputeTimerRef.current);
10068
+ idleRecomputeTimerRef.current = null;
10069
+ }
10070
+ };
10071
+ }, [
10072
+ api,
10073
+ clearPromotion,
10074
+ emitWithPromotion,
10075
+ hasTilingHandles,
10076
+ promoteFeature,
10077
+ recompute,
10078
+ scheduleIdleRestingRecompute,
10079
+ ]);
10080
+ // Clear on unmount.
10081
+ useEffect(() => {
10082
+ return () => {
10083
+ onFillChangeRef.current(EMPTY_OVERRIDES);
10084
+ onZIndexChangeRef.current(EMPTY_ZINDEX_OVERRIDES);
10085
+ };
10086
+ }, []);
10087
+ const allShapeHandlesRef = useRef([]);
10088
+ useEffect(() => {
10089
+ allShapeHandlesRef.current = handles.filter((h) => h.layerType === "shapes");
10090
+ });
10091
+ const queryAt = useCallback((handleId, position) => {
10092
+ const handle = allShapeHandlesRef.current.find(h => h.id === handleId);
10093
+ if (handle === undefined)
10094
+ return [];
10095
+ const { zoom, tileSize } = viewportRef.current;
10096
+ const strokeWidthFor = (featureId) => handle.featureStyles?.get(featureId)?.strokeWidth ?? DEFAULT_STROKE_WIDTH;
10097
+ const handleFillOverrides = prevFillOverridesRef.current.get(handleId);
10098
+ const suppressedIds = suppressedFillIdsByHandleRef.current?.get(handleId);
10099
+ const visibleFillFor = handleFillOverrides === undefined && suppressedIds === undefined
10100
+ ? undefined
10101
+ : (featureId) => {
10102
+ if (suppressedIds?.has(featureId) === true)
10103
+ return null;
10104
+ return handleFillOverrides?.get(featureId);
10105
+ };
10106
+ return shapesUnderCursor(position, handle.features.features, {
10107
+ zoom,
10108
+ tileSize,
10109
+ strokeWidthFor,
10110
+ visibleFillFor,
10111
+ });
10112
+ }, []);
10113
+ const setDecorationHoverActive = useCallback((active) => {
10114
+ decorationHoverActiveRef.current = active;
10115
+ }, []);
10116
+ return useMemo(() => ({ queryAt, promoteFeature, clearPromotion, setDecorationHoverActive }), [queryAt, promoteFeature, clearPromotion, setDecorationHoverActive]);
10117
+ };
10118
+
8929
10119
  const applyPortalZIndex = (container, zIndex) => {
8930
10120
  container.style.zIndex = String(zIndex);
8931
10121
  };
@@ -8955,14 +10145,60 @@ const Layers = ({ layers, api, onEntityClick, onEntityDblClick }) => {
8955
10145
  const layerPort = useLayerPort();
8956
10146
  const [decorationLayers, setDecorationLayers] = useState([]);
8957
10147
  const [viewportStyleOverrides, setViewportStyleOverrides] = useState(new Map());
10148
+ const [fillGeometryOverrides, setFillGeometryOverrides] = useState(new Map());
10149
+ const [zIndexOverrides, setZIndexOverrides] = useState(new Map());
10150
+ const onSettledHover = useCallback((handleId, featureId) => {
10151
+ // Fill tiling only fires on polygonal features — shapeType is always "polygon".
10152
+ layers.hover({ type: "shape", id: featureId, handleId, shapeType: "polygon" });
10153
+ }, [layers]);
10154
+ const suppressedFillIdsByHandle = useMemo(() => {
10155
+ const result = new Map();
10156
+ for (const [handleId, featureStyles] of viewportStyleOverrides) {
10157
+ const suppressed = new Set();
10158
+ for (const [featureId, style] of featureStyles) {
10159
+ if (isFillSuppressed(style))
10160
+ suppressed.add(featureId);
10161
+ }
10162
+ if (suppressed.size > 0)
10163
+ result.set(handleId, suppressed);
10164
+ }
10165
+ return result;
10166
+ }, [viewportStyleOverrides]);
10167
+ const { queryAt, promoteFeature, setDecorationHoverActive } = useShapeFillTiling({
10168
+ api,
10169
+ handles: layers.handles,
10170
+ onFillGeometriesChange: setFillGeometryOverrides,
10171
+ onZIndexOverridesChange: setZIndexOverrides,
10172
+ onSettledHover,
10173
+ selectedFeatureId: layers.interaction.selectedEntity?.type === "shape" ? String(layers.interaction.selectedEntity.id) : null,
10174
+ suppressedFillIdsByHandle,
10175
+ });
10176
+ const promoteFeatureRef = useRef(promoteFeature);
10177
+ useEffect(() => {
10178
+ promoteFeatureRef.current = promoteFeature;
10179
+ }, [promoteFeature]);
10180
+ const setDecorationHoverActiveRef = useRef(setDecorationHoverActive);
10181
+ useEffect(() => {
10182
+ setDecorationHoverActiveRef.current = setDecorationHoverActive;
10183
+ }, [setDecorationHoverActive]);
8958
10184
  const adaptiveByLayer = useAdaptiveMarkerResolution(layers.handles, layers.interaction, api);
8959
10185
  const enrichedHandles = useMemo(() => {
8960
10186
  return layers.handles.map(handle => {
8961
10187
  if (handle.layerType === "shapes") {
8962
- const overrides = viewportStyleOverrides.get(handle.id);
8963
- if (overrides === undefined)
8964
- return handle;
8965
- return { ...handle, featureStyles: overrides };
10188
+ const styleOverrides = viewportStyleOverrides.get(handle.id);
10189
+ const fillOverrides = fillGeometryOverrides.get(handle.id);
10190
+ const zIndexOverridesForHandle = zIndexOverrides.get(handle.id);
10191
+ const queryShapesAt = (position) => queryAt(handle.id, position);
10192
+ if (styleOverrides === undefined && fillOverrides === undefined && zIndexOverridesForHandle === undefined) {
10193
+ return { ...handle, queryShapesAt };
10194
+ }
10195
+ return {
10196
+ ...handle,
10197
+ queryShapesAt,
10198
+ ...(styleOverrides !== undefined ? { featureStyles: styleOverrides } : {}),
10199
+ ...(fillOverrides !== undefined ? { featureFillGeometries: fillOverrides } : {}),
10200
+ ...(zIndexOverridesForHandle !== undefined ? { featureZIndexOverrides: zIndexOverridesForHandle } : {}),
10201
+ };
8966
10202
  }
8967
10203
  if (handle.layerType !== "markers" || handle.markerRender.mode !== "adaptive")
8968
10204
  return handle;
@@ -8971,7 +10207,7 @@ const Layers = ({ layers, api, onEntityClick, onEntityDblClick }) => {
8971
10207
  return handle;
8972
10208
  return { ...handle, adaptiveResolution };
8973
10209
  });
8974
- }, [layers.handles, adaptiveByLayer, viewportStyleOverrides]);
10210
+ }, [layers.handles, adaptiveByLayer, viewportStyleOverrides, fillGeometryOverrides, zIndexOverrides, queryAt]);
8975
10211
  const { renderedSourceIds, hasSourceReadiness, readyRevision } = useLayerHandleSync(layerPort, enrichedHandles, layers.interaction, decorationLayers);
8976
10212
  const hoveredEntityRef = useRef(layers.interaction.hoveredEntity);
8977
10213
  useEffect(() => {
@@ -8999,6 +10235,10 @@ const Layers = ({ layers, api, onEntityClick, onEntityDblClick }) => {
8999
10235
  });
9000
10236
  const coordinatedHover = useCallback((entity) => {
9001
10237
  setDomHoverActive(entity !== null);
10238
+ setDecorationHoverActiveRef.current(entity !== null);
10239
+ if (entity !== null && entity.type === "shape" && entity.handleId !== undefined) {
10240
+ promoteFeatureRef.current(entity.handleId, entity.id);
10241
+ }
9002
10242
  layers.hover(entity);
9003
10243
  }, [layers, setDomHoverActive]);
9004
10244
  const interactionMapStore = useShapeDecorations({