@trackunit/react-map 0.1.3 → 0.1.4

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