@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 +1255 -15
- package/index.esm.js +1256 -16
- package/package.json +8 -8
- package/src/index.d.ts +2 -1
- package/src/layers/types.d.ts +60 -1
- package/src/layers/useShapes/InteractiveDecoration.d.ts +3 -0
- package/src/layers/useShapes/shapeFillTiling.d.ts +114 -0
- package/src/layers/useShapes/shapeLabelResolution.d.ts +1 -1
- package/src/layers/useShapes/shapeStackOrder.d.ts +39 -0
- package/src/layers/useShapes/useManualDblClick.d.ts +13 -0
- package/src/layers/useShapes/useShapeFillTiling.d.ts +89 -0
- package/src/layers/useShapes/useShapes.d.ts +20 -2
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
|
-
|
|
7183
|
-
}, [entity, select,
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
|
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
|
|
8964
|
-
|
|
8965
|
-
|
|
8966
|
-
|
|
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({
|