@trackunit/react-map 0.1.22 → 0.1.24
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 +197 -113
- package/index.esm.js +197 -113
- package/package.json +11 -11
- package/src/index.d.ts +1 -1
- package/src/layers/shared/measureLabelWidth.d.ts +8 -6
- package/src/layers/useShapes/buildShapeLabelResolutionContext.d.ts +6 -7
- package/src/layers/useShapes/shapeFillTiling.d.ts +0 -1
- package/src/layers/useShapes/shapeStackOrder.d.ts +3 -10
- package/src/markers/shared/mapMarkerVariants.d.ts +3 -3
package/index.esm.js
CHANGED
|
@@ -3854,9 +3854,9 @@ const resolveMarkerDomSize = (size, direction, form) => {
|
|
|
3854
3854
|
* both states. The inner indicator disc (`cvaMarkerIndicator` below) carries
|
|
3855
3855
|
* its own per-size `h-* w-*`; the outer surface sizes intrinsically around it.
|
|
3856
3856
|
*
|
|
3857
|
-
* `
|
|
3858
|
-
*
|
|
3859
|
-
*
|
|
3857
|
+
* `transitionProperty` is set inline in `MapMarker.tsx` to list only the
|
|
3858
|
+
* properties that need to animate, intentionally excluding `width` to prevent
|
|
3859
|
+
* non-composited layout thrashing. See `MarkerAnimatedSurface.tsx` for details.
|
|
3860
3860
|
*/
|
|
3861
3861
|
const cvaMapMarker = cvaMerge([
|
|
3862
3862
|
"inline-flex",
|
|
@@ -3865,7 +3865,6 @@ const cvaMapMarker = cvaMerge([
|
|
|
3865
3865
|
"overflow-hidden",
|
|
3866
3866
|
"select-none",
|
|
3867
3867
|
"cursor-pointer",
|
|
3868
|
-
"transition-all",
|
|
3869
3868
|
"rounded-full",
|
|
3870
3869
|
"outline-none",
|
|
3871
3870
|
"focus-visible:ring-2",
|
|
@@ -3936,69 +3935,91 @@ const MarkerDiscContent = ({ icon, iconPx, isPill, dotPx, directionDisplay, }) =
|
|
|
3936
3935
|
return null;
|
|
3937
3936
|
};
|
|
3938
3937
|
|
|
3939
|
-
|
|
3940
|
-
|
|
3941
|
-
|
|
3942
|
-
|
|
3943
|
-
|
|
3944
|
-
|
|
3945
|
-
|
|
3946
|
-
|
|
3938
|
+
/**
|
|
3939
|
+
* Compositable per-slot transition — `transform` and `opacity` are GPU-composited
|
|
3940
|
+
* and do not trigger layout (reflow).
|
|
3941
|
+
*
|
|
3942
|
+
* `width` was intentionally omitted: animating `width` is a non-composited operation
|
|
3943
|
+
* that forces the browser to re-run layout on every animation frame. When many markers
|
|
3944
|
+
* transition simultaneously (e.g. after a zoom-driven refetch) this caused ~29 concurrent
|
|
3945
|
+
* non-composited width animations and a CLS score of 0.58. Space-collapse is now handled
|
|
3946
|
+
* via `grid-template-columns: 0fr ↔ 1fr` on the slot wrapper divs, which produces the
|
|
3947
|
+
* same visual result without blocking the compositor.
|
|
3948
|
+
*/
|
|
3949
|
+
const slotContentTransition = (duration, easing) => `transform ${duration}ms ${easing}, opacity ${duration}ms ${easing}`;
|
|
3947
3950
|
const MarkerAnimatedSurface = ({ colors, directionDisplay, duration, effectiveSize, icon, iconPx, isPill, labelText, showDisc, textPx, theme, }) => {
|
|
3948
|
-
const circleD = MARKER_SIZE_MAP[effectiveSize].circle;
|
|
3949
3951
|
const dotPx = MARKER_SIZE_MAP[effectiveSize].dot;
|
|
3950
3952
|
const easing = MARKER_TUNING.animation.morphEasing;
|
|
3951
|
-
const
|
|
3953
|
+
const contentTx = slotContentTransition(duration, easing);
|
|
3952
3954
|
const pillDirectionVisible = isPill && directionDisplay.show;
|
|
3953
|
-
|
|
3954
|
-
|
|
3955
|
-
|
|
3955
|
+
// grid-template-columns 0fr ↔ 1fr is the compositable-friendly space-collapse
|
|
3956
|
+
// technique: the column animates its available-space fraction while overflow:clip
|
|
3957
|
+
// hides the partially-visible content. This avoids animating `width` directly.
|
|
3958
|
+
const gridTx = `grid-template-columns ${duration}ms ${easing}`;
|
|
3959
|
+
return (jsxs(Fragment, { children: [jsx("div", { "data-testid": "MapMarker-disc", style: {
|
|
3960
|
+
display: "grid",
|
|
3956
3961
|
flexShrink: 0,
|
|
3957
|
-
|
|
3958
|
-
|
|
3959
|
-
|
|
3960
|
-
}, children: jsx("span", {
|
|
3961
|
-
|
|
3962
|
-
|
|
3963
|
-
|
|
3964
|
-
|
|
3965
|
-
|
|
3962
|
+
gridTemplateColumns: showDisc ? "1fr" : "0fr",
|
|
3963
|
+
overflow: "clip",
|
|
3964
|
+
transition: gridTx,
|
|
3965
|
+
}, children: jsx("span", { style: {
|
|
3966
|
+
alignItems: "center",
|
|
3967
|
+
display: "inline-flex",
|
|
3968
|
+
justifyContent: "center",
|
|
3969
|
+
// Override the default `min-width: auto` on grid items so the `0fr`
|
|
3970
|
+
// track can collapse to zero width instead of the child's min-content.
|
|
3971
|
+
minWidth: 0,
|
|
3972
|
+
}, children: jsx("span", { className: cvaMarkerIndicator({ size: effectiveSize, theme }), style: {
|
|
3973
|
+
flexShrink: 0,
|
|
3974
|
+
opacity: showDisc ? 1 : 0,
|
|
3975
|
+
transform: showDisc ? "scale(1)" : "scale(0)",
|
|
3976
|
+
transition: contentTx,
|
|
3977
|
+
}, children: jsx(MarkerDiscContent, { directionDisplay: directionDisplay, dotPx: dotPx, icon: icon, iconPx: iconPx, isPill: isPill }) }) }) }), jsx("div", { style: {
|
|
3966
3978
|
display: "grid",
|
|
3967
3979
|
gridTemplateColumns: isPill ? "1fr" : "0fr",
|
|
3968
3980
|
overflowX: "clip",
|
|
3969
|
-
transition:
|
|
3981
|
+
transition: gridTx,
|
|
3970
3982
|
}, children: jsx("span", { style: {
|
|
3971
3983
|
alignItems: "center",
|
|
3972
3984
|
display: "flex",
|
|
3985
|
+
// Override the default `min-width: auto` on grid items so the `0fr`
|
|
3986
|
+
// track can collapse to zero width instead of the child's min-content.
|
|
3987
|
+
minWidth: 0,
|
|
3973
3988
|
overflow: "hidden",
|
|
3974
3989
|
}, children: labelText !== undefined ? (jsx("span", { className: cvaPillLabel(), style: {
|
|
3975
3990
|
fontSize: textPx > 0 ? `${textPx}px` : undefined,
|
|
3976
3991
|
maxWidth: MARKER_TUNING.pill.maxLabelWidthPx,
|
|
3977
3992
|
opacity: isPill ? 1 : 0,
|
|
3978
3993
|
transition: `opacity ${duration}ms ${easing}`,
|
|
3979
|
-
}, children: labelText })) : null }) }), jsx("
|
|
3980
|
-
|
|
3981
|
-
display: "inline-flex",
|
|
3994
|
+
}, children: labelText })) : null }) }), jsx("div", { style: {
|
|
3995
|
+
display: "grid",
|
|
3982
3996
|
flexShrink: 0,
|
|
3983
|
-
|
|
3984
|
-
|
|
3985
|
-
|
|
3997
|
+
gridTemplateColumns: isPill ? "1fr" : "0fr",
|
|
3998
|
+
overflow: "clip",
|
|
3999
|
+
transition: gridTx,
|
|
3986
4000
|
}, children: jsx("span", { style: {
|
|
3987
4001
|
alignItems: "center",
|
|
3988
4002
|
display: "inline-flex",
|
|
3989
|
-
flexShrink: 0,
|
|
3990
4003
|
justifyContent: "center",
|
|
3991
|
-
|
|
3992
|
-
|
|
3993
|
-
|
|
3994
|
-
|
|
3995
|
-
|
|
3996
|
-
|
|
4004
|
+
// Override the default `min-width: auto` on grid items so the `0fr`
|
|
4005
|
+
// track can collapse to zero width instead of the child's min-content.
|
|
4006
|
+
minWidth: 0,
|
|
4007
|
+
}, children: jsx("span", { style: {
|
|
4008
|
+
alignItems: "center",
|
|
4009
|
+
display: "inline-flex",
|
|
3997
4010
|
flexShrink: 0,
|
|
3998
|
-
|
|
3999
|
-
|
|
4000
|
-
|
|
4001
|
-
|
|
4011
|
+
justifyContent: "center",
|
|
4012
|
+
opacity: pillDirectionVisible ? 1 : 0,
|
|
4013
|
+
transform: pillDirectionVisible ? "scale(1)" : "scale(0)",
|
|
4014
|
+
transformOrigin: "center center",
|
|
4015
|
+
transition: contentTx,
|
|
4016
|
+
}, children: directionDisplay.show ? (jsx(Icon, { ariaHidden: true, "data-testid": "MapMarker-direction-pill", name: "GpsArrowNorth", size: "small", style: {
|
|
4017
|
+
color: colors.textColor,
|
|
4018
|
+
flexShrink: 0,
|
|
4019
|
+
height: directionDisplay.pixelSize,
|
|
4020
|
+
rotate: `${directionDisplay.rotationDegrees}deg`,
|
|
4021
|
+
width: directionDisplay.pixelSize,
|
|
4022
|
+
} })) : null }) }) })] }));
|
|
4002
4023
|
};
|
|
4003
4024
|
|
|
4004
4025
|
const isActivatingKey$2 = (key) => key === "Enter" || key === " ";
|
|
@@ -4138,6 +4159,9 @@ const STICK_FAN_RADIUS_PX = 56;
|
|
|
4138
4159
|
/** Doughnut ring thickness as a percentage of the chart radius (matches the legacy cluster ring). */
|
|
4139
4160
|
const PIE_LINE_WIDTH_PERCENT = 25;
|
|
4140
4161
|
const PIE_START_ANGLE = -90;
|
|
4162
|
+
/** Stable empty sticks array — used as the default for the `sticks` prop so the useEffect
|
|
4163
|
+
* dependency does not change on every render when no sticks are passed. */
|
|
4164
|
+
const EMPTY_STICKS = [];
|
|
4141
4165
|
const isActivatingKey$1 = (key) => key === "Enter" || key === " ";
|
|
4142
4166
|
/** Fan angle (radians) for a given index, starting at top (-π/2) and spread around a full circle. */
|
|
4143
4167
|
const computeFanAngle = (index, total) => {
|
|
@@ -4160,7 +4184,7 @@ const resolveBubbleBackground = (state, config) => {
|
|
|
4160
4184
|
return config.expandedBackground;
|
|
4161
4185
|
};
|
|
4162
4186
|
const ClusterMarkerInner = forwardRef(function ClusterMarkerComponent(props, ref) {
|
|
4163
|
-
const { count, segments, sticks =
|
|
4187
|
+
const { count, segments, sticks = EMPTY_STICKS, onStickClick, onClick, onKeyDown, onMouseEnter, onMouseLeave, onFocus, onBlur, state = "default", theme, colorConfig, className, style, "data-testid": dataTestId, "aria-label": ariaLabel, } = props;
|
|
4164
4188
|
const formatted = formatClusterCount(count);
|
|
4165
4189
|
const effectiveColorConfig = { ...defaultMarkerColorConfig(theme), ...colorConfig };
|
|
4166
4190
|
const colors = resolveMarkerColors("transparent", state, theme, colorConfig);
|
|
@@ -4217,11 +4241,11 @@ const ClusterMarkerInner = forwardRef(function ClusterMarkerComponent(props, ref
|
|
|
4217
4241
|
event.currentTarget.click();
|
|
4218
4242
|
}
|
|
4219
4243
|
}, [onKeyDown]);
|
|
4220
|
-
const pieData = segments.map((segment, index) => ({
|
|
4244
|
+
const pieData = useMemo(() => segments.map((segment, index) => ({
|
|
4221
4245
|
color: segment.color,
|
|
4222
4246
|
value: segment.weight,
|
|
4223
4247
|
title: `segment-${index}`,
|
|
4224
|
-
}));
|
|
4248
|
+
})), [segments]);
|
|
4225
4249
|
const bubbleTestId = dataTestId !== undefined ? `${dataTestId}-bubble` : undefined;
|
|
4226
4250
|
return (jsxs("div", { className: className, "data-testid": dataTestId, onMouseEnter: onMouseEnter, onMouseLeave: onMouseLeave, ref: ref, style: {
|
|
4227
4251
|
position: "relative",
|
|
@@ -4386,6 +4410,9 @@ const MapMarkerInner = forwardRef(function MapMarkerComponent(props, ref) {
|
|
|
4386
4410
|
gap: isPill ? MARKER_TUNING.pill.gapPx : 0,
|
|
4387
4411
|
borderWidth: showPillBorder ? 1 : 0,
|
|
4388
4412
|
borderStyle: "solid",
|
|
4413
|
+
// Explicitly list only the properties that need to animate on the outer shell.
|
|
4414
|
+
// `width` is intentionally excluded — see MarkerAnimatedSurface for details.
|
|
4415
|
+
transitionProperty: "background-color, border-color, border-width, padding, gap, color",
|
|
4389
4416
|
transitionDuration: `${morphDurationMs}ms`,
|
|
4390
4417
|
transitionTimingFunction: MARKER_TUNING.animation.morphEasing,
|
|
4391
4418
|
...(isPill
|
|
@@ -7597,7 +7624,7 @@ const computePromotionGroupFills = (members, winnerId, restingPeerFills = new Ma
|
|
|
7597
7624
|
return result;
|
|
7598
7625
|
};
|
|
7599
7626
|
const computeFillTiling = (input) => {
|
|
7600
|
-
const { features, viewportBounds, resolveStackOrder,
|
|
7627
|
+
const { features, viewportBounds, resolveStackOrder, selectedFeatureId, suppressedFeatureIds, layerGeodesic, featureStyles, } = input;
|
|
7601
7628
|
// Densify before any polygon/boundary math so clip geometry follows great-circle
|
|
7602
7629
|
// arcs on large polygons (mirrors the ADR-0024 precedent for edge labels).
|
|
7603
7630
|
const featureCollection = {
|
|
@@ -7636,7 +7663,7 @@ const computeFillTiling = (input) => {
|
|
|
7636
7663
|
featureToGroupKey.set(record.id, key);
|
|
7637
7664
|
}
|
|
7638
7665
|
// 1. Resting order: containment then smaller-area.
|
|
7639
|
-
let order = resolveStackOrder(contended
|
|
7666
|
+
let order = resolveStackOrder(contended);
|
|
7640
7667
|
// 2. Selection override: selected feature always takes front (highest priority).
|
|
7641
7668
|
if (selectedFeatureId !== null && selectedFeatureId !== undefined) {
|
|
7642
7669
|
order = promoteToFront(order, selectedFeatureId);
|
|
@@ -7742,8 +7769,11 @@ const useViewportContext = (api) => {
|
|
|
7742
7769
|
|
|
7743
7770
|
const FALLBACK_CHAR_WIDTH = 6;
|
|
7744
7771
|
const FALLBACK_PADDING = 16;
|
|
7772
|
+
// px-2 from cvaShapeLabel: 8px left + 8px right = 16px total
|
|
7773
|
+
const LABEL_HORIZONTAL_PADDING_PX = 16;
|
|
7745
7774
|
const labelWidthCache = new Map();
|
|
7746
7775
|
let probeElement = null;
|
|
7776
|
+
let canvasCtx = null;
|
|
7747
7777
|
/**
|
|
7748
7778
|
* Character-count fallback for environments where DOM measurement is unavailable
|
|
7749
7779
|
* (SSR, jsdom, headless tests).
|
|
@@ -7770,30 +7800,67 @@ const getProbeElement = () => {
|
|
|
7770
7800
|
return el;
|
|
7771
7801
|
};
|
|
7772
7802
|
/**
|
|
7773
|
-
*
|
|
7774
|
-
*
|
|
7803
|
+
* Returns a CanvasRenderingContext2D configured with the same font as ShapeLabelPill.
|
|
7804
|
+
* The font is read once from the probe element's computed style so canvas measurements
|
|
7805
|
+
* match the actual rendered text width. Returns null when canvas is unavailable.
|
|
7806
|
+
*
|
|
7807
|
+
* Canvas.measureText does not trigger a forced layout (reflow), unlike getBoundingClientRect.
|
|
7808
|
+
*/
|
|
7809
|
+
const getCanvasCtx = (probe) => {
|
|
7810
|
+
if (canvasCtx !== null)
|
|
7811
|
+
return canvasCtx;
|
|
7812
|
+
if (typeof document === "undefined")
|
|
7813
|
+
return null;
|
|
7814
|
+
const canvas = document.createElement("canvas");
|
|
7815
|
+
const ctx = canvas.getContext("2d");
|
|
7816
|
+
if (ctx === null)
|
|
7817
|
+
return null;
|
|
7818
|
+
// Read the computed font from the probe element once. getComputedStyle forces
|
|
7819
|
+
// a style recalculation (cheap) but NOT a full layout pass (no reflow).
|
|
7820
|
+
const computed = window.getComputedStyle(probe);
|
|
7821
|
+
// `font` shorthand: "font-style font-variant font-weight font-size/line-height font-family"
|
|
7822
|
+
// Falls back to a safe approximation matching text-xs (12px) + font-medium (500).
|
|
7823
|
+
const font = computed.font || "500 12px ui-sans-serif, system-ui, sans-serif";
|
|
7824
|
+
ctx.font = font;
|
|
7825
|
+
canvasCtx = ctx;
|
|
7826
|
+
return ctx;
|
|
7827
|
+
};
|
|
7828
|
+
/**
|
|
7829
|
+
* Measure the rendered pixel width of label text using a Canvas context configured
|
|
7830
|
+
* with the same font as shape edge labels (`cvaShapeLabel` / `ShapeLabelPill`).
|
|
7775
7831
|
*
|
|
7776
|
-
*
|
|
7832
|
+
* Results are cached per label text. Using Canvas.measureText avoids the forced
|
|
7833
|
+
* synchronous layout (reflow) that getBoundingClientRect triggers when called inside
|
|
7834
|
+
* a Google Maps onDraw callback — the previous implementation caused ~67ms of blocked
|
|
7835
|
+
* main-thread time per draw cycle during zoom (observed in Chrome DevTools trace).
|
|
7777
7836
|
*
|
|
7778
|
-
*
|
|
7779
|
-
*
|
|
7780
|
-
* estimate (label.length * 6 + 16).
|
|
7837
|
+
* Falls back to DOM measurement if canvas is unavailable, and to a character-count
|
|
7838
|
+
* estimate in environments without DOM (SSR, jsdom).
|
|
7781
7839
|
*/
|
|
7782
7840
|
const measureLabelWidth = (label) => {
|
|
7783
7841
|
const cached = labelWidthCache.get(label);
|
|
7784
7842
|
if (cached !== undefined)
|
|
7785
7843
|
return cached;
|
|
7786
7844
|
const probe = getProbeElement();
|
|
7787
|
-
if (probe
|
|
7788
|
-
const
|
|
7789
|
-
|
|
7790
|
-
|
|
7791
|
-
|
|
7792
|
-
|
|
7793
|
-
|
|
7794
|
-
|
|
7795
|
-
|
|
7796
|
-
|
|
7845
|
+
if (probe !== null) {
|
|
7846
|
+
const ctx = getCanvasCtx(probe);
|
|
7847
|
+
if (ctx !== null) {
|
|
7848
|
+
const textWidth = ctx.measureText(label).width;
|
|
7849
|
+
const canvasWidth = textWidth + LABEL_HORIZONTAL_PADDING_PX;
|
|
7850
|
+
labelWidthCache.set(label, canvasWidth);
|
|
7851
|
+
return canvasWidth;
|
|
7852
|
+
}
|
|
7853
|
+
// Canvas unavailable — fall back to DOM measurement (triggers reflow, but only
|
|
7854
|
+
// for uncached labels and only when CanvasRenderingContext2D is missing).
|
|
7855
|
+
probe.textContent = label;
|
|
7856
|
+
const measured = probe.getBoundingClientRect().width;
|
|
7857
|
+
const domWidth = measured > 0 ? measured : fallbackLabelWidth(label);
|
|
7858
|
+
labelWidthCache.set(label, domWidth);
|
|
7859
|
+
return domWidth;
|
|
7860
|
+
}
|
|
7861
|
+
const fallback = fallbackLabelWidth(label);
|
|
7862
|
+
labelWidthCache.set(label, fallback);
|
|
7863
|
+
return fallback;
|
|
7797
7864
|
};
|
|
7798
7865
|
|
|
7799
7866
|
const DEG_TO_RAD$1 = Math.PI / 180;
|
|
@@ -7930,14 +7997,13 @@ const isShapeMatch = (entity, handleId, featureKey) => {
|
|
|
7930
7997
|
return shape.handleId === handleId && shape.id === featureKey;
|
|
7931
7998
|
};
|
|
7932
7999
|
/**
|
|
7933
|
-
* Build the per-feature, per-frame context passed to
|
|
7934
|
-
*
|
|
7935
|
-
*
|
|
7936
|
-
*
|
|
8000
|
+
* Build the per-feature, per-frame context passed to viewport-aware shape
|
|
8001
|
+
* callbacks. Pure: no React, no `api` reads — caller supplies the already-
|
|
8002
|
+
* projected viewport bits, the precomputed `shapesInViewport` count for the
|
|
8003
|
+
* layer, a lazy overlap count, and the current interaction state.
|
|
7937
8004
|
*
|
|
7938
8005
|
* Lives next to `shapeLabelResolution.ts` and is consumed by
|
|
7939
|
-
* `useShapeDecorations` once per feature
|
|
7940
|
-
* decoration, before any placement work runs.
|
|
8006
|
+
* `useShapeDecorations` once per feature, before any placement work runs.
|
|
7941
8007
|
*/
|
|
7942
8008
|
const buildShapeLabelResolutionContext = (feature, args) => {
|
|
7943
8009
|
const geometry = feature.geometry;
|
|
@@ -7950,7 +8016,9 @@ const buildShapeLabelResolutionContext = (feature, args) => {
|
|
|
7950
8016
|
return {
|
|
7951
8017
|
zoom: args.zoom,
|
|
7952
8018
|
shapesInViewport: args.shapesInViewport,
|
|
7953
|
-
overlappingShapesCount
|
|
8019
|
+
get overlappingShapesCount() {
|
|
8020
|
+
return args.getOverlappingShapesCount();
|
|
8021
|
+
},
|
|
7954
8022
|
geometryType: geometry.type,
|
|
7955
8023
|
bboxPixelWidth: dims.widthPx,
|
|
7956
8024
|
bboxPixelHeight: dims.heightPx,
|
|
@@ -8102,20 +8170,24 @@ const clipSegmentToRect = (x0, y0, x1, y1, xMin, yMin, xMax, yMax) => {
|
|
|
8102
8170
|
}
|
|
8103
8171
|
return [x0 + tMin * dx, y0 + tMin * dy, x0 + tMax * dx, y0 + tMax * dy];
|
|
8104
8172
|
};
|
|
8105
|
-
|
|
8106
|
-
|
|
8173
|
+
/**
|
|
8174
|
+
* Inner implementations that accept a pre-computed worldSize.
|
|
8175
|
+
* Used by findBestEdgePosition to avoid recomputing Math.pow(2, zoom) on
|
|
8176
|
+
* every vertex when projecting dozens of geodesic sub-segments per frame.
|
|
8177
|
+
*/
|
|
8178
|
+
const lngLatToMercatorPxWS = (lng, lat, worldSize) => {
|
|
8107
8179
|
const clampedLat = Math.max(-MAX_WEB_MERCATOR_LAT, Math.min(MAX_WEB_MERCATOR_LAT, lat));
|
|
8108
8180
|
const x = ((lng + 180) / 360) * worldSize;
|
|
8109
8181
|
const latRad = clampedLat * DEG_TO_RAD;
|
|
8110
8182
|
const y = (0.5 - Math.log(Math.tan(Math.PI / 4 + latRad / 2)) / (2 * Math.PI)) * worldSize;
|
|
8111
8183
|
return [x, y];
|
|
8112
8184
|
};
|
|
8113
|
-
const
|
|
8114
|
-
const worldSize = tileSize * Math.pow(2, zoom);
|
|
8185
|
+
const mercatorPxToLngLatWS = (px, py, worldSize) => {
|
|
8115
8186
|
const lng = (px / worldSize) * 360 - 180;
|
|
8116
8187
|
const lat = Math.atan(Math.sinh(Math.PI * (1 - (2 * py) / worldSize))) * RAD_TO_DEG$1;
|
|
8117
8188
|
return [lng, lat];
|
|
8118
8189
|
};
|
|
8190
|
+
const lngLatToWebMercatorPx = (lng, lat, zoom, tileSize = 256) => lngLatToMercatorPxWS(lng, lat, tileSize * Math.pow(2, zoom));
|
|
8119
8191
|
/**
|
|
8120
8192
|
* Determine which side of an edge the geometry interior lies on, in screen space.
|
|
8121
8193
|
* Returns "above" if the label should extend above the edge (interior is below),
|
|
@@ -8456,6 +8528,8 @@ const findBestEdgePosition = ({ features, viewportBounds, zoom, minPixelWidth, l
|
|
|
8456
8528
|
const [minLon, minLat, maxLon, maxLat] = viewportBounds;
|
|
8457
8529
|
const edges = extractEdges(features);
|
|
8458
8530
|
const centroid = computeGeometryCentroid(features);
|
|
8531
|
+
// Pre-compute once so the projection helpers below don't call Math.pow on every vertex.
|
|
8532
|
+
const worldSize = tileSize * Math.pow(2, zoom);
|
|
8459
8533
|
const candidates = [];
|
|
8460
8534
|
let nextCandidateId = 0;
|
|
8461
8535
|
// Pre-compute pixel viewport bounds so we can clip in pixel space.
|
|
@@ -8464,8 +8538,8 @@ const findBestEdgePosition = ({ features, viewportBounds, zoom, minPixelWidth, l
|
|
|
8464
8538
|
// edge — causing labels to float away from the line at mid-edge positions.
|
|
8465
8539
|
// Clipping in pixel space ensures clip endpoints lie exactly on the
|
|
8466
8540
|
// rendered edge, which fixes the floating at the midpoint of long edges.
|
|
8467
|
-
const [pxViewMin, pyViewMin] =
|
|
8468
|
-
const [pxViewMax, pyViewMax] =
|
|
8541
|
+
const [pxViewMin, pyViewMin] = lngLatToMercatorPxWS(minLon, maxLat, worldSize);
|
|
8542
|
+
const [pxViewMax, pyViewMax] = lngLatToMercatorPxWS(maxLon, minLat, worldSize);
|
|
8469
8543
|
const viewportWidth = pxViewMax - pxViewMin;
|
|
8470
8544
|
const viewportHeight = pyViewMax - pyViewMin;
|
|
8471
8545
|
for (let edgeIdx = 0; edgeIdx < edges.length; edgeIdx++) {
|
|
@@ -8483,8 +8557,8 @@ const findBestEdgePosition = ({ features, viewportBounds, zoom, minPixelWidth, l
|
|
|
8483
8557
|
const segmentClips = [];
|
|
8484
8558
|
for (const segment of getPlacementSegments(edge[0], edge[1], geodesic)) {
|
|
8485
8559
|
const { start, end } = segment;
|
|
8486
|
-
const [startPxX, startPxY] =
|
|
8487
|
-
const [endPxX, endPxY] =
|
|
8560
|
+
const [startPxX, startPxY] = lngLatToMercatorPxWS(start[0], start[1], worldSize);
|
|
8561
|
+
const [endPxX, endPxY] = lngLatToMercatorPxWS(end[0], end[1], worldSize);
|
|
8488
8562
|
const clippedPx = clipSegmentToRect(startPxX, startPxY, endPxX, endPxY, pxViewMin, pyViewMin, pxViewMax, pyViewMax);
|
|
8489
8563
|
if (!clippedPx)
|
|
8490
8564
|
continue;
|
|
@@ -8500,8 +8574,8 @@ const findBestEdgePosition = ({ features, viewportBounds, zoom, minPixelWidth, l
|
|
|
8500
8574
|
: [-rawDx / segPxLen, -rawDy / segPxLen];
|
|
8501
8575
|
// Inverse-project pixel clip endpoints to geo for outward-side and
|
|
8502
8576
|
// polygon-interior checks (qualitative, so approximately correct geo is fine).
|
|
8503
|
-
const [ex0, ey0] =
|
|
8504
|
-
const [ex1, ey1] =
|
|
8577
|
+
const [ex0, ey0] = mercatorPxToLngLatWS(px0, py0, worldSize);
|
|
8578
|
+
const [ex1, ey1] = mercatorPxToLngLatWS(px1, py1, worldSize);
|
|
8505
8579
|
const midLat = (ey0 + ey1) / 2;
|
|
8506
8580
|
const edgeOutwardSide = centroid !== null ? computeOutwardSide(ex0, ey0, ex1, ey1, centroid, midLat) : "above";
|
|
8507
8581
|
if (isLabelInsidePolygon(ex0, ey0, ex1, ey1, features, centroid))
|
|
@@ -8638,7 +8712,7 @@ const findBestEdgePosition = ({ features, viewportBounds, zoom, minPixelWidth, l
|
|
|
8638
8712
|
for (const matchingCandidate of candidates.filter(c => isSameEdge(previousEdgeIdentity, c.edge))) {
|
|
8639
8713
|
if (matchingCandidate.pxLen <= 0)
|
|
8640
8714
|
continue;
|
|
8641
|
-
const [prevAnchorAbsPx, prevAnchorAbsPy] =
|
|
8715
|
+
const [prevAnchorAbsPx, prevAnchorAbsPy] = lngLatToMercatorPxWS(previousAnchorGeo[0], previousAnchorGeo[1], worldSize);
|
|
8642
8716
|
const prevAnchorRelX = prevAnchorAbsPx - pxViewMin;
|
|
8643
8717
|
const prevAnchorRelY = prevAnchorAbsPy - pyViewMin;
|
|
8644
8718
|
const dx = prevAnchorRelX - matchingCandidate.readingStartPx[0];
|
|
@@ -8708,7 +8782,7 @@ const findBestEdgePosition = ({ features, viewportBounds, zoom, minPixelWidth, l
|
|
|
8708
8782
|
// great-circle arc by computing the arc fraction via angular distance
|
|
8709
8783
|
// from A. This places the label physically on the visible curved arc
|
|
8710
8784
|
// rather than on the straight Mercator chord.
|
|
8711
|
-
const [approxLng, approxLat] =
|
|
8785
|
+
const [approxLng, approxLat] = mercatorPxToLngLatWS(anchorRelX + pxViewMin, anchorRelY + pyViewMin, worldSize);
|
|
8712
8786
|
const tArc = Math.max(0, Math.min(1, angularDistance(A[0], A[1], approxLng, approxLat) / delta));
|
|
8713
8787
|
const geoAnchor = intermediatePoint(A, B, tArc, delta);
|
|
8714
8788
|
position = [geoAnchor[0], geoAnchor[1]];
|
|
@@ -8722,13 +8796,13 @@ const findBestEdgePosition = ({ features, viewportBounds, zoom, minPixelWidth, l
|
|
|
8722
8796
|
const centerOffsetFactor = resolvedAnchor === "left" ? 1 : resolvedAnchor === "right" ? -1 : 0;
|
|
8723
8797
|
const centerRelX = anchorRelX + centerOffsetFactor * halfLabelPx * best.directionPx[0];
|
|
8724
8798
|
const centerRelY = anchorRelY + centerOffsetFactor * halfLabelPx * best.directionPx[1];
|
|
8725
|
-
const [approxCenterLng, approxCenterLat] =
|
|
8799
|
+
const [approxCenterLng, approxCenterLat] = mercatorPxToLngLatWS(centerRelX + pxViewMin, centerRelY + pyViewMin, worldSize);
|
|
8726
8800
|
const tArcCenter = Math.max(0, Math.min(1, angularDistance(A[0], A[1], approxCenterLng, approxCenterLat) / delta));
|
|
8727
8801
|
const TANGENT_EPS = 0.001;
|
|
8728
8802
|
const p0 = intermediatePoint(A, B, Math.max(0, tArcCenter - TANGENT_EPS), delta);
|
|
8729
8803
|
const p1 = intermediatePoint(A, B, Math.min(1, tArcCenter + TANGENT_EPS), delta);
|
|
8730
|
-
const [bx, by] =
|
|
8731
|
-
const [ax, ay] =
|
|
8804
|
+
const [bx, by] = lngLatToMercatorPxWS(p0[0], p0[1], worldSize);
|
|
8805
|
+
const [ax, ay] = lngLatToMercatorPxWS(p1[0], p1[1], worldSize);
|
|
8732
8806
|
const ddx = ax - bx;
|
|
8733
8807
|
const ddy = ay - by;
|
|
8734
8808
|
const tangentLen = Math.sqrt(ddx * ddx + ddy * ddy);
|
|
@@ -8745,13 +8819,13 @@ const findBestEdgePosition = ({ features, viewportBounds, zoom, minPixelWidth, l
|
|
|
8745
8819
|
}
|
|
8746
8820
|
else {
|
|
8747
8821
|
// Co-located vertices — fall back to Mercator straight-line.
|
|
8748
|
-
const [anchorLng, anchorLat] =
|
|
8822
|
+
const [anchorLng, anchorLat] = mercatorPxToLngLatWS(anchorRelX + pxViewMin, anchorRelY + pyViewMin, worldSize);
|
|
8749
8823
|
position = [anchorLng, anchorLat];
|
|
8750
8824
|
resolvedDirectionPx = best.directionPx;
|
|
8751
8825
|
}
|
|
8752
8826
|
}
|
|
8753
8827
|
else {
|
|
8754
|
-
const [anchorLng, anchorLat] =
|
|
8828
|
+
const [anchorLng, anchorLat] = mercatorPxToLngLatWS(anchorRelX + pxViewMin, anchorRelY + pyViewMin, worldSize);
|
|
8755
8829
|
position = [anchorLng, anchorLat];
|
|
8756
8830
|
resolvedDirectionPx = best.directionPx;
|
|
8757
8831
|
}
|
|
@@ -9410,26 +9484,23 @@ const useShapeDecorations = ({ onDecorationsChange, onStyleOverridesChange, api,
|
|
|
9410
9484
|
}
|
|
9411
9485
|
return shapesInViewportCache;
|
|
9412
9486
|
};
|
|
9413
|
-
|
|
9487
|
+
// overlappingShapesCount is exposed to viewport-aware callbacks. Keep the
|
|
9488
|
+
// O(n²) overlap computation behind the context property getter so handles
|
|
9489
|
+
// that never read it pay nothing.
|
|
9490
|
+
let overlappingCountsCache = null;
|
|
9491
|
+
const getOverlappingCount = (feature) => {
|
|
9492
|
+
if (overlappingCountsCache === null) {
|
|
9493
|
+
overlappingCountsCache = buildOverlappingShapesCountMap(handle.features.features, featureBboxes, bounds);
|
|
9494
|
+
}
|
|
9495
|
+
return overlappingCountsCache.get(feature) ?? 0;
|
|
9496
|
+
};
|
|
9414
9497
|
handle.features.features.forEach((feature, index) => {
|
|
9415
9498
|
const geometry = feature.geometry;
|
|
9416
9499
|
if (geometry === null || geometry.type === "GeometryCollection")
|
|
9417
9500
|
return;
|
|
9418
9501
|
const featureKey = feature.id !== undefined ? String(feature.id) : String(index);
|
|
9419
|
-
|
|
9420
|
-
|
|
9421
|
-
featureKey,
|
|
9422
|
-
viewportBounds: bounds,
|
|
9423
|
-
zoom,
|
|
9424
|
-
tileSize,
|
|
9425
|
-
shapesInViewport: shapesInViewport(),
|
|
9426
|
-
overlappingShapesCount: overlappingCounts.get(feature) ?? 0,
|
|
9427
|
-
interaction: currentInteraction,
|
|
9428
|
-
});
|
|
9429
|
-
const featureStyle = resolutionContext === null ? handle.resolveStyle(feature) : handle.resolveStyle(feature, resolutionContext);
|
|
9430
|
-
handleStyleOverrides.set(featureKey, featureStyle);
|
|
9431
|
-
const shapeType = geometryTypeToShapeType(geometry.type);
|
|
9432
|
-
const strokeColors = resolveStrokeColors(featureStyle, shapeType, theme);
|
|
9502
|
+
// Partition decorations before building the resolution context so we
|
|
9503
|
+
// can gate the overlap computation on edge-auto presence.
|
|
9433
9504
|
const decorations = handle.getDecorations(feature);
|
|
9434
9505
|
const resolvedStatic = [];
|
|
9435
9506
|
const vertexAutoDecorations = [];
|
|
@@ -9451,6 +9522,20 @@ const useShapeDecorations = ({ onDecorationsChange, onStyleOverridesChange, api,
|
|
|
9451
9522
|
}
|
|
9452
9523
|
}
|
|
9453
9524
|
}
|
|
9525
|
+
const resolutionContext = buildShapeLabelResolutionContext(feature, {
|
|
9526
|
+
handleId: handle.id,
|
|
9527
|
+
featureKey,
|
|
9528
|
+
viewportBounds: bounds,
|
|
9529
|
+
zoom,
|
|
9530
|
+
tileSize,
|
|
9531
|
+
shapesInViewport: shapesInViewport(),
|
|
9532
|
+
getOverlappingShapesCount: () => getOverlappingCount(feature),
|
|
9533
|
+
interaction: currentInteraction,
|
|
9534
|
+
});
|
|
9535
|
+
const featureStyle = resolutionContext === null ? handle.resolveStyle(feature) : handle.resolveStyle(feature, resolutionContext);
|
|
9536
|
+
handleStyleOverrides.set(featureKey, featureStyle);
|
|
9537
|
+
const shapeType = geometryTypeToShapeType(geometry.type);
|
|
9538
|
+
const strokeColors = resolveStrokeColors(featureStyle, shapeType, theme);
|
|
9454
9539
|
const makeEntity = () => ({
|
|
9455
9540
|
type: "shape",
|
|
9456
9541
|
id: featureKey,
|
|
@@ -9744,7 +9829,7 @@ const featureId = (shape) => String(shape.feature.id);
|
|
|
9744
9829
|
* 2. **Smaller area on top** — stable tiebreak; inner shapes are typically
|
|
9745
9830
|
* smaller, so this reinforces containment and keeps nested sites visible.
|
|
9746
9831
|
*/
|
|
9747
|
-
const defaultShapeStackOrder =
|
|
9832
|
+
const defaultShapeStackOrder = contended => {
|
|
9748
9833
|
const idOf = new Map();
|
|
9749
9834
|
for (const shape of contended) {
|
|
9750
9835
|
idOf.set(shape, featureId(shape));
|
|
@@ -10072,7 +10157,6 @@ const useShapeFillTiling = ({ api, handles, onFillGeometriesChange, onZIndexOver
|
|
|
10072
10157
|
}
|
|
10073
10158
|
}, []);
|
|
10074
10159
|
const recompute = useCallback(() => {
|
|
10075
|
-
const currentViewport = viewportRef.current;
|
|
10076
10160
|
const currentHandles = tilingHandlesRef.current;
|
|
10077
10161
|
// Stable suppressed-key: per-handle sorted ids so suppression changes are
|
|
10078
10162
|
// not deduped away by the inputKey guard.
|
|
@@ -10099,12 +10183,13 @@ const useShapeFillTiling = ({ api, handles, onFillGeometriesChange, onZIndexOver
|
|
|
10099
10183
|
return `${handle.id}:${String(layerFlag)}:${featureFlags}`;
|
|
10100
10184
|
})
|
|
10101
10185
|
.join(";");
|
|
10102
|
-
// Bounds are intentionally excluded from the inputKey
|
|
10103
|
-
//
|
|
10104
|
-
//
|
|
10105
|
-
//
|
|
10106
|
-
//
|
|
10107
|
-
|
|
10186
|
+
// Bounds are intentionally excluded from the inputKey: clips use global bounds so
|
|
10187
|
+
// all overlapping features always have pre-computed clips, and polygons entering
|
|
10188
|
+
// the viewport during zoom-out never flash their unclipped fill.
|
|
10189
|
+
// Zoom is intentionally excluded too: fill-tiling stack order is zoom-invariant
|
|
10190
|
+
// by contract, so zooming can reuse the same resting clips and avoid a full
|
|
10191
|
+
// polygon-clipping pass for an identical result.
|
|
10192
|
+
const inputKey = `${tilingContentKeyRef.current}|${selectedFeatureIdRef.current ?? ""}|${suppressedKey}|${geodesicKey}`;
|
|
10108
10193
|
if (inputKey === lastComputeInputKeyRef.current) {
|
|
10109
10194
|
return;
|
|
10110
10195
|
}
|
|
@@ -10132,7 +10217,6 @@ const useShapeFillTiling = ({ api, handles, onFillGeometriesChange, onZIndexOver
|
|
|
10132
10217
|
features: handle.features.features,
|
|
10133
10218
|
viewportBounds: GLOBAL_BOUNDS,
|
|
10134
10219
|
resolveStackOrder: handle.overlap?.resolveStackOrder ?? defaultShapeStackOrder,
|
|
10135
|
-
zoom: currentViewport.zoom,
|
|
10136
10220
|
selectedFeatureId: selectedFeatureIdRef.current,
|
|
10137
10221
|
suppressedFeatureIds: suppressedFillIdsByHandleRef.current?.get(handle.id),
|
|
10138
10222
|
layerGeodesic: handle.style.geodesic,
|
package/package.json
CHANGED
|
@@ -1,23 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trackunit/react-map",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.24",
|
|
4
4
|
"repository": "https://github.com/Trackunit/manager",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE.txt",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=24.x"
|
|
8
8
|
},
|
|
9
9
|
"dependencies": {
|
|
10
|
-
"@trackunit/react-components": "2.1.
|
|
11
|
-
"@trackunit/css-class-variance-utilities": "1.13.
|
|
12
|
-
"@trackunit/react-form-components": "2.1.
|
|
13
|
-
"@trackunit/react-core-hooks": "1.17.
|
|
14
|
-
"@trackunit/geo-json-utils": "1.14.
|
|
15
|
-
"@trackunit/i18n-library-translation": "2.0.
|
|
16
|
-
"@trackunit/react-modal": "2.1.
|
|
10
|
+
"@trackunit/react-components": "2.1.35",
|
|
11
|
+
"@trackunit/css-class-variance-utilities": "1.13.39",
|
|
12
|
+
"@trackunit/react-form-components": "2.1.37",
|
|
13
|
+
"@trackunit/react-core-hooks": "1.17.48",
|
|
14
|
+
"@trackunit/geo-json-utils": "1.14.42",
|
|
15
|
+
"@trackunit/i18n-library-translation": "2.0.36",
|
|
16
|
+
"@trackunit/react-modal": "2.1.38",
|
|
17
17
|
"react-minimal-pie-chart": "^8.4.0",
|
|
18
|
-
"@trackunit/react-map-adapter-shared": "0.0.
|
|
19
|
-
"@trackunit/react-map-color-utils": "0.0.
|
|
20
|
-
"@trackunit/ui-design-tokens": "1.13.
|
|
18
|
+
"@trackunit/react-map-adapter-shared": "0.0.23",
|
|
19
|
+
"@trackunit/react-map-color-utils": "0.0.8",
|
|
20
|
+
"@trackunit/ui-design-tokens": "1.13.39",
|
|
21
21
|
"@floating-ui/react": "^0.26.25",
|
|
22
22
|
"es-toolkit": "^1.39.10",
|
|
23
23
|
"tailwind-merge": "^2.0.0",
|
package/src/index.d.ts
CHANGED
|
@@ -46,7 +46,7 @@ export { type AutoPanContext, type AutoPanResult } from "./panel/utils/autoPan";
|
|
|
46
46
|
export type { DecorationAnchor, EdgeSide, ShapeDecoration } from "./layers/useShapes/shapeDecorations";
|
|
47
47
|
export { type AnnotationContext, type ShapeLabelPolicy } from "./layers/useShapes/shapeLabelPolicy";
|
|
48
48
|
export type { ResolveShapeLabel, ShapeLabelResolution, ShapeLabelResolutionContext, } from "./layers/useShapes/shapeLabelResolution";
|
|
49
|
-
export { type ContendedShape, type ResolveShapeStackOrder
|
|
49
|
+
export { type ContendedShape, type ResolveShapeStackOrder } from "./layers/useShapes/shapeStackOrder";
|
|
50
50
|
export { useImageOverlay, type UseImageOverlayOptions, type UseImageOverlayReturn, } from "./layers/image-overlay/useImageOverlay";
|
|
51
51
|
export { buildExpandedIds, useExpandedIds, type MapFocus, type MapFocusTier, type MapFocusTierDisplay, } from "./layers/mapFocus";
|
|
52
52
|
export { useRoute, type UseRouteOptions, type UseRouteReturn } from "./layers/routes/useRoute";
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Measure the rendered pixel width of label text using
|
|
3
|
-
*
|
|
2
|
+
* Measure the rendered pixel width of label text using a Canvas context configured
|
|
3
|
+
* with the same font as shape edge labels (`cvaShapeLabel` / `ShapeLabelPill`).
|
|
4
4
|
*
|
|
5
|
-
*
|
|
5
|
+
* Results are cached per label text. Using Canvas.measureText avoids the forced
|
|
6
|
+
* synchronous layout (reflow) that getBoundingClientRect triggers when called inside
|
|
7
|
+
* a Google Maps onDraw callback — the previous implementation caused ~67ms of blocked
|
|
8
|
+
* main-thread time per draw cycle during zoom (observed in Chrome DevTools trace).
|
|
6
9
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* estimate (label.length * 6 + 16).
|
|
10
|
+
* Falls back to DOM measurement if canvas is unavailable, and to a character-count
|
|
11
|
+
* estimate in environments without DOM (SSR, jsdom).
|
|
10
12
|
*/
|
|
11
13
|
export declare const measureLabelWidth: (label: string) => number;
|