@trackunit/react-map 0.2.191 → 0.2.192

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.cjs.js CHANGED
@@ -4462,8 +4462,10 @@ ClusterStickInner.displayName = "ClusterStick";
4462
4462
  *
4463
4463
  * Rules:
4464
4464
  * - `n < 1 000` → raw integer string
4465
- * - `1 000 ≤ n < 1 000 000` → `Xk` (truncated to whole thousands)
4466
- * - `n ≥ 1 000 000` → `X.Xm` (truncated to one decimal of millions)
4465
+ * - `1 000 ≤ n < 10 000` → `X.Xk` (truncated to one decimal of thousands)
4466
+ * - `10 000 ≤ n < 1 000 000` → `Xk` (truncated to whole thousands)
4467
+ * - `1 000 000 ≤ n < 10 000 000` → `X.Xm` (truncated to one decimal of millions)
4468
+ * - `n ≥ 10 000 000` → `Xm` (truncated to whole millions)
4467
4469
  *
4468
4470
  * Truncation (not rounding) is used so the displayed value never overstates the count.
4469
4471
  */
@@ -4471,16 +4473,93 @@ const formatClusterCount = (n) => {
4471
4473
  if (n < 1000) {
4472
4474
  return String(n);
4473
4475
  }
4476
+ if (n < 10000) {
4477
+ return `${(Math.floor(n / 100) / 10).toFixed(1)}k`;
4478
+ }
4474
4479
  if (n < 1000000) {
4475
4480
  return `${Math.floor(n / 1000)}k`;
4476
4481
  }
4477
- return `${(Math.floor(n / 100000) / 10).toFixed(1)}m`;
4482
+ if (n < 10000000) {
4483
+ return `${(Math.floor(n / 100000) / 10).toFixed(1)}m`;
4484
+ }
4485
+ return `${Math.floor(n / 1000000)}m`;
4486
+ };
4487
+
4488
+ /**
4489
+ * `memo` comparator for `ClusterMarker`. Shallow-compares every prop except `segments`,
4490
+ * which is compared by content since callers typically derive a fresh array per render.
4491
+ */
4492
+ const areClusterMarkerPropsEqual = (prev, next) => {
4493
+ const prevKeys = Object.keys(prev);
4494
+ const nextKeys = Object.keys(next);
4495
+ if (prevKeys.length !== nextKeys.length) {
4496
+ return false;
4497
+ }
4498
+ for (const key of nextKeys) {
4499
+ if (key === "segments") {
4500
+ continue;
4501
+ }
4502
+ if (!Object.is(Reflect.get(prev, key), Reflect.get(next, key))) {
4503
+ return false;
4504
+ }
4505
+ }
4506
+ return esToolkit.isEqual(prev.segments, next.segments);
4507
+ };
4508
+
4509
+ /**
4510
+ * Open donut diameter (px), including the 1px border.
4511
+ * Named like marker sizes: sm, md, lg, xl. Compact formatting keeps lg labels to at
4512
+ * most 3 digits (`999`, `9.9k`, `99k`) and xl labels to at most 4 (`999k`, `9.9m`).
4513
+ */
4514
+ const CLUSTER_BUBBLE_DIAMETER_PX = {
4515
+ sm: 24,
4516
+ md: 32,
4517
+ lg: 40,
4518
+ xl: 48,
4519
+ };
4520
+ const CLUSTER_PIE_BAND_STEPS = {
4521
+ sm: 1,
4522
+ md: 2,
4523
+ lg: 3,
4524
+ xl: 4,
4525
+ };
4526
+ /**
4527
+ * Resolves the cluster bubble diameter and form from the count, single-marker size, and state.
4528
+ * The resting pie is one marker step above `markerSize` for counts of 2–9, then one further step
4529
+ * per larger count band, clamped at xl.
4530
+ */
4531
+ const resolveClusterBubbleSize = ({ count, markerSize, state, }) => {
4532
+ const safeCount = Number.isFinite(count) ? count : 0;
4533
+ const band = safeCount <= 9 ? "sm" : safeCount <= 99 ? "md" : safeCount < 100000 ? "lg" : "xl";
4534
+ const donutDiameterPx = CLUSTER_BUBBLE_DIAMETER_PX[band];
4535
+ if (markerSize === undefined) {
4536
+ return { diameterPx: donutDiameterPx, isDonut: true };
4537
+ }
4538
+ /** Outer pie diameter (px), including the 1px border. Matches a marker circle tier. */
4539
+ let pieSize = markerSize;
4540
+ for (let step = 0; step < CLUSTER_PIE_BAND_STEPS[band]; step++) {
4541
+ pieSize = upgradeMarkerDomSize(pieSize);
4542
+ }
4543
+ const pieDiameterPx = MARKER_SIZE_MAP[pieSize].circle;
4544
+ if (state === "default") {
4545
+ return { diameterPx: pieDiameterPx, isDonut: false };
4546
+ }
4547
+ /**
4548
+ * Open diameter. The count picks sm/md/lg/xl, but a larger closed pie does not shrink.
4549
+ * A 2–9 cluster on an lg or xl marker is a 28px pie and a 24px donut, so it stays 28px.
4550
+ */
4551
+ return { diameterPx: Math.max(pieDiameterPx, donutDiameterPx), isDonut: true };
4478
4552
  };
4479
4553
 
4480
4554
  /** Radius (px) from cluster anchor at which sticks are placed during fan-out. */
4481
4555
  const STICK_FAN_RADIUS_PX = 56;
4482
- /** Doughnut ring thickness as a percentage of the chart radius (matches the legacy cluster ring). */
4483
- const PIE_LINE_WIDTH_PERCENT = 25;
4556
+ /** Closed pie fills the disc. Open donut leaves a hole for the count (legacy ring thickness). */
4557
+ const PIE_LINE_WIDTH_PERCENT = 100;
4558
+ const DONUT_LINE_WIDTH_PERCENT = 25;
4559
+ /** The ring is `DONUT_LINE_WIDTH_PERCENT` of the radius, so the hole is inset by half that on each side. */
4560
+ const HOLE_INSET = `${DONUT_LINE_WIDTH_PERCENT / 2}%`;
4561
+ /** easeOutCubic. */
4562
+ const HOLE_EASING = "cubic-bezier(0.33, 1, 0.68, 1)";
4484
4563
  const PIE_START_ANGLE = -90;
4485
4564
  /** Stable empty sticks array — used as the default for the `sticks` prop so the useEffect
4486
4565
  * dependency does not change on every render when no sticks are passed. */
@@ -4507,13 +4586,23 @@ const resolveBubbleBackground = (state, config) => {
4507
4586
  return config.expandedBackground;
4508
4587
  };
4509
4588
  const ClusterMarkerInner = react.forwardRef(function ClusterMarkerComponent(props, ref) {
4510
- 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;
4589
+ const { count, segments, sticks = EMPTY_STICKS, onStickClick, onClick, onKeyDown, onMouseEnter, onMouseLeave, onFocus, onBlur, state = "default", markerSize, theme, colorConfig, className, style, "data-testid": dataTestId, "aria-label": ariaLabel, } = props;
4511
4590
  const formatted = formatClusterCount(count);
4591
+ const pieData = react.useMemo(() => segments
4592
+ .map((segment, index) => ({
4593
+ color: segment.color,
4594
+ value: segment.weight,
4595
+ title: `segment-${index}`,
4596
+ }))
4597
+ .filter(entry => entry.value > 0), [segments]);
4598
+ const { diameterPx: bubbleDiameterPx, isDonut } = resolveClusterBubbleSize({ count, markerSize, state });
4599
+ const showCount = isDonut || pieData.length === 0;
4512
4600
  const effectiveColorConfig = { ...defaultMarkerColorConfig(theme), ...colorConfig };
4513
4601
  const colors = resolveMarkerColors("transparent", state, theme, colorConfig);
4514
4602
  const bubbleBackground = resolveBubbleBackground(state, effectiveColorConfig);
4515
4603
  const morphDurationMs = MARKER_TUNING.animation.morphDurationMs;
4516
4604
  const easing = MARKER_TUNING.animation.morphEasing;
4605
+ const holeDurationMs = Math.round(morphDurationMs / 2);
4517
4606
  // Single source of truth for all sticks — both active (open=true) and departing (open=false).
4518
4607
  // Active sticks carry updated fan angles; departing sticks keep their last known positioning
4519
4608
  // so the spring can animate them from their current position back to the cluster center.
@@ -4564,25 +4653,22 @@ const ClusterMarkerInner = react.forwardRef(function ClusterMarkerComponent(prop
4564
4653
  event.currentTarget.click();
4565
4654
  }
4566
4655
  }, [onKeyDown]);
4567
- const pieData = react.useMemo(() => segments.map((segment, index) => ({
4568
- color: segment.color,
4569
- value: segment.weight,
4570
- title: `segment-${index}`,
4571
- })), [segments]);
4572
4656
  const bubbleTestId = dataTestId !== undefined ? `${dataTestId}-bubble` : undefined;
4657
+ const holeTestId = dataTestId !== undefined ? `${dataTestId}-hole` : undefined;
4573
4658
  return (jsxRuntime.jsxs("div", { className: className, "data-testid": dataTestId, onMouseEnter: onMouseEnter, onMouseLeave: onMouseLeave, ref: ref, style: {
4574
4659
  position: "relative",
4575
4660
  display: "inline-block",
4576
4661
  isolation: "isolate",
4577
4662
  ...colors.cssVars,
4578
4663
  ...style,
4579
- }, children: [managedSticks.map(({ input: stick, open, positioning }) => (jsxRuntime.jsx("div", { style: { position: "absolute", zIndex: 0, left: "50%", top: "50%", width: 0, height: 0 }, children: jsxRuntime.jsx(ClusterStick, { ...stick, onStickClick: open ? onStickClick : undefined, open: open, positioning: positioning }) }, stick.id))), jsxRuntime.jsxs("div", { "aria-label": accessibleName, "aria-pressed": state === "selected", className: "focus-visible:ring-info-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2", "data-map-marker-hit-surface": "", "data-testid": bubbleTestId, onBlur: onBlur, onClick: onClick, onFocus: onFocus, onKeyDown: handleKeyDown, role: "button", style: {
4664
+ }, children: [managedSticks.map(({ input: stick, open, positioning }) => (jsxRuntime.jsx("div", { style: { position: "absolute", zIndex: 0, left: "50%", top: "50%", width: 0, height: 0 }, children: jsxRuntime.jsx(ClusterStick, { ...stick, onStickClick: open ? onStickClick : undefined, open: open, positioning: positioning }) }, stick.id))), jsxRuntime.jsxs("div", { "aria-label": accessibleName, "aria-pressed": state === "selected", className: "focus-visible:ring-info-500 motion-reduce:transition-none! focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2", "data-map-marker-hit-surface": "", "data-testid": bubbleTestId, onBlur: onBlur, onClick: onClick, onFocus: onFocus, onKeyDown: handleKeyDown, role: "button", style: {
4580
4665
  position: "relative",
4581
4666
  display: "inline-flex",
4582
4667
  alignItems: "center",
4583
4668
  justifyContent: "center",
4584
- aspectRatio: "1 / 1",
4585
- width: "fit-content",
4669
+ boxSizing: "border-box",
4670
+ width: bubbleDiameterPx,
4671
+ height: bubbleDiameterPx,
4586
4672
  borderRadius: 9999,
4587
4673
  overflow: "hidden",
4588
4674
  backgroundColor: bubbleBackground,
@@ -4592,13 +4678,34 @@ const ClusterMarkerInner = react.forwardRef(function ClusterMarkerComponent(prop
4592
4678
  borderStyle: "solid",
4593
4679
  cursor: "pointer",
4594
4680
  userSelect: "none",
4595
- transitionProperty: "background-color, border-color, color",
4681
+ transitionProperty: "width, height, background-color, border-color, color",
4596
4682
  transitionDuration: `${morphDurationMs}ms`,
4597
4683
  transitionTimingFunction: easing,
4598
4684
  zIndex: 1,
4599
- }, tabIndex: 0, children: [pieData.length > 0 ? (jsxRuntime.jsx(reactMinimalPieChart.PieChart, { data: pieData, lineWidth: PIE_LINE_WIDTH_PERCENT, startAngle: PIE_START_ANGLE, style: { position: "absolute", inset: 0, width: "100%", height: "100%" } })) : null, jsxRuntime.jsx("span", { className: "relative whitespace-nowrap px-2 py-1 text-xs font-semibold leading-none [text-box-trim:trim-both]", "data-testid": "ClusterMarker-count", children: formatted })] })] }));
4685
+ }, tabIndex: 0, children: [pieData.length > 0 ? (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx(reactMinimalPieChart.PieChart, { data: pieData, lineWidth: PIE_LINE_WIDTH_PERCENT, startAngle: PIE_START_ANGLE, style: { position: "absolute", inset: 0, width: "100%", height: "100%" } }), jsxRuntime.jsx("div", { "aria-hidden": true, className: "motion-reduce:transition-none!", "data-testid": holeTestId, style: {
4686
+ position: "absolute",
4687
+ inset: HOLE_INSET,
4688
+ borderRadius: 9999,
4689
+ backgroundColor: bubbleBackground,
4690
+ pointerEvents: "none",
4691
+ transform: isDonut ? "scale(1)" : "scale(0)",
4692
+ transitionProperty: "transform, background-color",
4693
+ transitionDuration: `${holeDurationMs}ms, ${morphDurationMs}ms`,
4694
+ transitionTimingFunction: `${HOLE_EASING}, ${easing}`,
4695
+ } })] })) : null, jsxRuntime.jsx("span", { className: "motion-reduce:transition-none! relative whitespace-nowrap text-xs font-semibold leading-none [text-box-trim:trim-both]", "data-testid": "ClusterMarker-count", style: {
4696
+ opacity: showCount ? 1 : 0,
4697
+ transitionProperty: "opacity",
4698
+ transitionDuration: `${morphDurationMs}ms`,
4699
+ transitionTimingFunction: easing,
4700
+ }, children: formatted })] })] }));
4600
4701
  });
4601
- const ClusterMarker = ClusterMarkerInner;
4702
+ /**
4703
+ * Memoized with {@link areClusterMarkerPropsEqual}: `segments` is compared by content, all other props by reference.
4704
+ * Re-renders of the layer then skip clusters whose props are unchanged, even when callers derive `segments` per render.
4705
+ * On the fleet map (about 127 clusters, panning), this cut cluster renders from 1,000–3,300/s to 0–2/s
4706
+ * and cluster React time from 40–114 ms/s to under 3 ms/s (on local build).
4707
+ */
4708
+ const ClusterMarker = react.memo(ClusterMarkerInner, areClusterMarkerPropsEqual);
4602
4709
  ClusterMarkerInner.displayName = "ClusterMarker";
4603
4710
 
4604
4711
  const getHasIcon$1 = (icon, iconPx) => {
@@ -12485,7 +12592,7 @@ const ShapeAnnotationLabel = ({ geometry, color, label, theme, isHovered = false
12485
12592
  * Build-your-own hook: stable reference to the cluster count formatter.
12486
12593
  *
12487
12594
  * Returns `formatClusterCount` for consumers building custom cluster markers
12488
- * that need consistent `"1.2k / 3.4m"` formatting without reimplementing
12595
+ * that need consistent `"1.0k"` / `"10k"` / `"9.9m"` / `"99m"` formatting without reimplementing
12489
12596
  * the truncation rules.
12490
12597
  *
12491
12598
  * @example
package/index.esm.js CHANGED
@@ -4461,8 +4461,10 @@ ClusterStickInner.displayName = "ClusterStick";
4461
4461
  *
4462
4462
  * Rules:
4463
4463
  * - `n < 1 000` → raw integer string
4464
- * - `1 000 ≤ n < 1 000 000` → `Xk` (truncated to whole thousands)
4465
- * - `n ≥ 1 000 000` → `X.Xm` (truncated to one decimal of millions)
4464
+ * - `1 000 ≤ n < 10 000` → `X.Xk` (truncated to one decimal of thousands)
4465
+ * - `10 000 ≤ n < 1 000 000` → `Xk` (truncated to whole thousands)
4466
+ * - `1 000 000 ≤ n < 10 000 000` → `X.Xm` (truncated to one decimal of millions)
4467
+ * - `n ≥ 10 000 000` → `Xm` (truncated to whole millions)
4466
4468
  *
4467
4469
  * Truncation (not rounding) is used so the displayed value never overstates the count.
4468
4470
  */
@@ -4470,16 +4472,93 @@ const formatClusterCount = (n) => {
4470
4472
  if (n < 1000) {
4471
4473
  return String(n);
4472
4474
  }
4475
+ if (n < 10000) {
4476
+ return `${(Math.floor(n / 100) / 10).toFixed(1)}k`;
4477
+ }
4473
4478
  if (n < 1000000) {
4474
4479
  return `${Math.floor(n / 1000)}k`;
4475
4480
  }
4476
- return `${(Math.floor(n / 100000) / 10).toFixed(1)}m`;
4481
+ if (n < 10000000) {
4482
+ return `${(Math.floor(n / 100000) / 10).toFixed(1)}m`;
4483
+ }
4484
+ return `${Math.floor(n / 1000000)}m`;
4485
+ };
4486
+
4487
+ /**
4488
+ * `memo` comparator for `ClusterMarker`. Shallow-compares every prop except `segments`,
4489
+ * which is compared by content since callers typically derive a fresh array per render.
4490
+ */
4491
+ const areClusterMarkerPropsEqual = (prev, next) => {
4492
+ const prevKeys = Object.keys(prev);
4493
+ const nextKeys = Object.keys(next);
4494
+ if (prevKeys.length !== nextKeys.length) {
4495
+ return false;
4496
+ }
4497
+ for (const key of nextKeys) {
4498
+ if (key === "segments") {
4499
+ continue;
4500
+ }
4501
+ if (!Object.is(Reflect.get(prev, key), Reflect.get(next, key))) {
4502
+ return false;
4503
+ }
4504
+ }
4505
+ return isEqual(prev.segments, next.segments);
4506
+ };
4507
+
4508
+ /**
4509
+ * Open donut diameter (px), including the 1px border.
4510
+ * Named like marker sizes: sm, md, lg, xl. Compact formatting keeps lg labels to at
4511
+ * most 3 digits (`999`, `9.9k`, `99k`) and xl labels to at most 4 (`999k`, `9.9m`).
4512
+ */
4513
+ const CLUSTER_BUBBLE_DIAMETER_PX = {
4514
+ sm: 24,
4515
+ md: 32,
4516
+ lg: 40,
4517
+ xl: 48,
4518
+ };
4519
+ const CLUSTER_PIE_BAND_STEPS = {
4520
+ sm: 1,
4521
+ md: 2,
4522
+ lg: 3,
4523
+ xl: 4,
4524
+ };
4525
+ /**
4526
+ * Resolves the cluster bubble diameter and form from the count, single-marker size, and state.
4527
+ * The resting pie is one marker step above `markerSize` for counts of 2–9, then one further step
4528
+ * per larger count band, clamped at xl.
4529
+ */
4530
+ const resolveClusterBubbleSize = ({ count, markerSize, state, }) => {
4531
+ const safeCount = Number.isFinite(count) ? count : 0;
4532
+ const band = safeCount <= 9 ? "sm" : safeCount <= 99 ? "md" : safeCount < 100000 ? "lg" : "xl";
4533
+ const donutDiameterPx = CLUSTER_BUBBLE_DIAMETER_PX[band];
4534
+ if (markerSize === undefined) {
4535
+ return { diameterPx: donutDiameterPx, isDonut: true };
4536
+ }
4537
+ /** Outer pie diameter (px), including the 1px border. Matches a marker circle tier. */
4538
+ let pieSize = markerSize;
4539
+ for (let step = 0; step < CLUSTER_PIE_BAND_STEPS[band]; step++) {
4540
+ pieSize = upgradeMarkerDomSize(pieSize);
4541
+ }
4542
+ const pieDiameterPx = MARKER_SIZE_MAP[pieSize].circle;
4543
+ if (state === "default") {
4544
+ return { diameterPx: pieDiameterPx, isDonut: false };
4545
+ }
4546
+ /**
4547
+ * Open diameter. The count picks sm/md/lg/xl, but a larger closed pie does not shrink.
4548
+ * A 2–9 cluster on an lg or xl marker is a 28px pie and a 24px donut, so it stays 28px.
4549
+ */
4550
+ return { diameterPx: Math.max(pieDiameterPx, donutDiameterPx), isDonut: true };
4477
4551
  };
4478
4552
 
4479
4553
  /** Radius (px) from cluster anchor at which sticks are placed during fan-out. */
4480
4554
  const STICK_FAN_RADIUS_PX = 56;
4481
- /** Doughnut ring thickness as a percentage of the chart radius (matches the legacy cluster ring). */
4482
- const PIE_LINE_WIDTH_PERCENT = 25;
4555
+ /** Closed pie fills the disc. Open donut leaves a hole for the count (legacy ring thickness). */
4556
+ const PIE_LINE_WIDTH_PERCENT = 100;
4557
+ const DONUT_LINE_WIDTH_PERCENT = 25;
4558
+ /** The ring is `DONUT_LINE_WIDTH_PERCENT` of the radius, so the hole is inset by half that on each side. */
4559
+ const HOLE_INSET = `${DONUT_LINE_WIDTH_PERCENT / 2}%`;
4560
+ /** easeOutCubic. */
4561
+ const HOLE_EASING = "cubic-bezier(0.33, 1, 0.68, 1)";
4483
4562
  const PIE_START_ANGLE = -90;
4484
4563
  /** Stable empty sticks array — used as the default for the `sticks` prop so the useEffect
4485
4564
  * dependency does not change on every render when no sticks are passed. */
@@ -4506,13 +4585,23 @@ const resolveBubbleBackground = (state, config) => {
4506
4585
  return config.expandedBackground;
4507
4586
  };
4508
4587
  const ClusterMarkerInner = forwardRef(function ClusterMarkerComponent(props, ref) {
4509
- 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;
4588
+ const { count, segments, sticks = EMPTY_STICKS, onStickClick, onClick, onKeyDown, onMouseEnter, onMouseLeave, onFocus, onBlur, state = "default", markerSize, theme, colorConfig, className, style, "data-testid": dataTestId, "aria-label": ariaLabel, } = props;
4510
4589
  const formatted = formatClusterCount(count);
4590
+ const pieData = useMemo(() => segments
4591
+ .map((segment, index) => ({
4592
+ color: segment.color,
4593
+ value: segment.weight,
4594
+ title: `segment-${index}`,
4595
+ }))
4596
+ .filter(entry => entry.value > 0), [segments]);
4597
+ const { diameterPx: bubbleDiameterPx, isDonut } = resolveClusterBubbleSize({ count, markerSize, state });
4598
+ const showCount = isDonut || pieData.length === 0;
4511
4599
  const effectiveColorConfig = { ...defaultMarkerColorConfig(theme), ...colorConfig };
4512
4600
  const colors = resolveMarkerColors("transparent", state, theme, colorConfig);
4513
4601
  const bubbleBackground = resolveBubbleBackground(state, effectiveColorConfig);
4514
4602
  const morphDurationMs = MARKER_TUNING.animation.morphDurationMs;
4515
4603
  const easing = MARKER_TUNING.animation.morphEasing;
4604
+ const holeDurationMs = Math.round(morphDurationMs / 2);
4516
4605
  // Single source of truth for all sticks — both active (open=true) and departing (open=false).
4517
4606
  // Active sticks carry updated fan angles; departing sticks keep their last known positioning
4518
4607
  // so the spring can animate them from their current position back to the cluster center.
@@ -4563,25 +4652,22 @@ const ClusterMarkerInner = forwardRef(function ClusterMarkerComponent(props, ref
4563
4652
  event.currentTarget.click();
4564
4653
  }
4565
4654
  }, [onKeyDown]);
4566
- const pieData = useMemo(() => segments.map((segment, index) => ({
4567
- color: segment.color,
4568
- value: segment.weight,
4569
- title: `segment-${index}`,
4570
- })), [segments]);
4571
4655
  const bubbleTestId = dataTestId !== undefined ? `${dataTestId}-bubble` : undefined;
4656
+ const holeTestId = dataTestId !== undefined ? `${dataTestId}-hole` : undefined;
4572
4657
  return (jsxs("div", { className: className, "data-testid": dataTestId, onMouseEnter: onMouseEnter, onMouseLeave: onMouseLeave, ref: ref, style: {
4573
4658
  position: "relative",
4574
4659
  display: "inline-block",
4575
4660
  isolation: "isolate",
4576
4661
  ...colors.cssVars,
4577
4662
  ...style,
4578
- }, children: [managedSticks.map(({ input: stick, open, positioning }) => (jsx("div", { style: { position: "absolute", zIndex: 0, left: "50%", top: "50%", width: 0, height: 0 }, children: jsx(ClusterStick, { ...stick, onStickClick: open ? onStickClick : undefined, open: open, positioning: positioning }) }, stick.id))), jsxs("div", { "aria-label": accessibleName, "aria-pressed": state === "selected", className: "focus-visible:ring-info-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2", "data-map-marker-hit-surface": "", "data-testid": bubbleTestId, onBlur: onBlur, onClick: onClick, onFocus: onFocus, onKeyDown: handleKeyDown, role: "button", style: {
4663
+ }, children: [managedSticks.map(({ input: stick, open, positioning }) => (jsx("div", { style: { position: "absolute", zIndex: 0, left: "50%", top: "50%", width: 0, height: 0 }, children: jsx(ClusterStick, { ...stick, onStickClick: open ? onStickClick : undefined, open: open, positioning: positioning }) }, stick.id))), jsxs("div", { "aria-label": accessibleName, "aria-pressed": state === "selected", className: "focus-visible:ring-info-500 motion-reduce:transition-none! focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2", "data-map-marker-hit-surface": "", "data-testid": bubbleTestId, onBlur: onBlur, onClick: onClick, onFocus: onFocus, onKeyDown: handleKeyDown, role: "button", style: {
4579
4664
  position: "relative",
4580
4665
  display: "inline-flex",
4581
4666
  alignItems: "center",
4582
4667
  justifyContent: "center",
4583
- aspectRatio: "1 / 1",
4584
- width: "fit-content",
4668
+ boxSizing: "border-box",
4669
+ width: bubbleDiameterPx,
4670
+ height: bubbleDiameterPx,
4585
4671
  borderRadius: 9999,
4586
4672
  overflow: "hidden",
4587
4673
  backgroundColor: bubbleBackground,
@@ -4591,13 +4677,34 @@ const ClusterMarkerInner = forwardRef(function ClusterMarkerComponent(props, ref
4591
4677
  borderStyle: "solid",
4592
4678
  cursor: "pointer",
4593
4679
  userSelect: "none",
4594
- transitionProperty: "background-color, border-color, color",
4680
+ transitionProperty: "width, height, background-color, border-color, color",
4595
4681
  transitionDuration: `${morphDurationMs}ms`,
4596
4682
  transitionTimingFunction: easing,
4597
4683
  zIndex: 1,
4598
- }, tabIndex: 0, children: [pieData.length > 0 ? (jsx(PieChart, { data: pieData, lineWidth: PIE_LINE_WIDTH_PERCENT, startAngle: PIE_START_ANGLE, style: { position: "absolute", inset: 0, width: "100%", height: "100%" } })) : null, jsx("span", { className: "relative whitespace-nowrap px-2 py-1 text-xs font-semibold leading-none [text-box-trim:trim-both]", "data-testid": "ClusterMarker-count", children: formatted })] })] }));
4684
+ }, tabIndex: 0, children: [pieData.length > 0 ? (jsxs(Fragment, { children: [jsx(PieChart, { data: pieData, lineWidth: PIE_LINE_WIDTH_PERCENT, startAngle: PIE_START_ANGLE, style: { position: "absolute", inset: 0, width: "100%", height: "100%" } }), jsx("div", { "aria-hidden": true, className: "motion-reduce:transition-none!", "data-testid": holeTestId, style: {
4685
+ position: "absolute",
4686
+ inset: HOLE_INSET,
4687
+ borderRadius: 9999,
4688
+ backgroundColor: bubbleBackground,
4689
+ pointerEvents: "none",
4690
+ transform: isDonut ? "scale(1)" : "scale(0)",
4691
+ transitionProperty: "transform, background-color",
4692
+ transitionDuration: `${holeDurationMs}ms, ${morphDurationMs}ms`,
4693
+ transitionTimingFunction: `${HOLE_EASING}, ${easing}`,
4694
+ } })] })) : null, jsx("span", { className: "motion-reduce:transition-none! relative whitespace-nowrap text-xs font-semibold leading-none [text-box-trim:trim-both]", "data-testid": "ClusterMarker-count", style: {
4695
+ opacity: showCount ? 1 : 0,
4696
+ transitionProperty: "opacity",
4697
+ transitionDuration: `${morphDurationMs}ms`,
4698
+ transitionTimingFunction: easing,
4699
+ }, children: formatted })] })] }));
4599
4700
  });
4600
- const ClusterMarker = ClusterMarkerInner;
4701
+ /**
4702
+ * Memoized with {@link areClusterMarkerPropsEqual}: `segments` is compared by content, all other props by reference.
4703
+ * Re-renders of the layer then skip clusters whose props are unchanged, even when callers derive `segments` per render.
4704
+ * On the fleet map (about 127 clusters, panning), this cut cluster renders from 1,000–3,300/s to 0–2/s
4705
+ * and cluster React time from 40–114 ms/s to under 3 ms/s (on local build).
4706
+ */
4707
+ const ClusterMarker = memo(ClusterMarkerInner, areClusterMarkerPropsEqual);
4601
4708
  ClusterMarkerInner.displayName = "ClusterMarker";
4602
4709
 
4603
4710
  const getHasIcon$1 = (icon, iconPx) => {
@@ -12484,7 +12591,7 @@ const ShapeAnnotationLabel = ({ geometry, color, label, theme, isHovered = false
12484
12591
  * Build-your-own hook: stable reference to the cluster count formatter.
12485
12592
  *
12486
12593
  * Returns `formatClusterCount` for consumers building custom cluster markers
12487
- * that need consistent `"1.2k / 3.4m"` formatting without reimplementing
12594
+ * that need consistent `"1.0k"` / `"10k"` / `"9.9m"` / `"99m"` formatting without reimplementing
12488
12595
  * the truncation rules.
12489
12596
  *
12490
12597
  * @example
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trackunit/react-map",
3
- "version": "0.2.191",
3
+ "version": "0.2.192",
4
4
  "repository": "https://github.com/Trackunit/manager",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "engines": {
@@ -1,7 +1,7 @@
1
1
  import { type CommonProps, type Refable, type Styleable } from "@trackunit/react-components";
2
2
  import { type HTMLAttributes } from "react";
3
3
  import type { MapTheme } from "../core/types";
4
- import type { MarkerState } from "../markers/model/markerDomTypes";
4
+ import type { MarkerDomSize, MarkerState } from "../markers/model/markerDomTypes";
5
5
  import { type MarkerColorConfig } from "../markers/shared/markerColors";
6
6
  import { type ClusterStickProps } from "./ClusterStick";
7
7
  /** Pre-computed doughnut segment input. `weight` maps directly to `value` in `BaseDataEntry`. */
@@ -21,21 +21,40 @@ type ClusterMarkerDivHandlers = Pick<HTMLAttributes<HTMLDivElement>, "onClick" |
21
21
  type ClusterMarkerBaseProps = CommonProps & Styleable & Refable<HTMLDivElement> & ClusterMarkerDivHandlers & {
22
22
  /** Total member count. Rendered via {@link formatClusterCount}. */
23
23
  count: number;
24
- /** Doughnut ring segments. Empty array suppresses the ring. */
24
+ /**
25
+ * Pie (resting) and donut ring (open) segments.
26
+ * Non-positive weights are ignored; without any positive weights the cluster shows only the count.
27
+ */
25
28
  segments: ReadonlyArray<ClusterSegment>;
26
29
  /**
27
- * Optional cluster member sticks fanned out around the bubble at evenly spread angles.
28
- * Intended for clusters with 6 or fewer members; above that, stick labels become crowded.
30
+ * Highlighted member sticks fanned out around the bubble at evenly spread angles.
31
+ * Any cluster can highlight assets this way. At most 6 sticks; more than that and the labels crowd.
29
32
  */
30
33
  sticks?: ReadonlyArray<ClusterMarkerStickInput>;
31
34
  /** Fired by each fanned-out stick with its `id`. */
32
35
  onStickClick?: (id: string) => void;
33
36
  state?: MarkerState;
37
+ /**
38
+ * Size of a single marker at the current zoom. When set, the resting form is a
39
+ * pie one step above this size for counts of 2–9, then one further step per
40
+ * larger band, clamped at xl. The open donut sizes from `count` (sm, md, lg, xl)
41
+ * and stays at the pie diameter when that pie is already larger.
42
+ * When omitted, the cluster stays the expanded count donut, which is what the
43
+ * fleet map renders today. Without segments with a positive weight, the cluster
44
+ * keeps this sizing but shows only the count, since there is no pie or ring to show.
45
+ */
46
+ markerSize?: MarkerDomSize;
34
47
  theme: MapTheme;
35
48
  colorConfig?: MarkerColorConfig;
36
49
  };
37
50
  export type ClusterMarkerProps = ClusterMarkerBaseProps & {
38
51
  "aria-label"?: string;
39
52
  };
40
- export declare const ClusterMarker: import("react").ForwardRefExoticComponent<Omit<ClusterMarkerProps, "ref"> & import("react").RefAttributes<HTMLDivElement>>;
53
+ /**
54
+ * Memoized with {@link areClusterMarkerPropsEqual}: `segments` is compared by content, all other props by reference.
55
+ * Re-renders of the layer then skip clusters whose props are unchanged, even when callers derive `segments` per render.
56
+ * On the fleet map (about 127 clusters, panning), this cut cluster renders from 1,000–3,300/s to 0–2/s
57
+ * and cluster React time from 40–114 ms/s to under 3 ms/s (on local build).
58
+ */
59
+ export declare const ClusterMarker: import("react").NamedExoticComponent<Omit<ClusterMarkerProps, "ref"> & import("react").RefAttributes<HTMLDivElement>>;
41
60
  export {};
@@ -3,8 +3,10 @@
3
3
  *
4
4
  * Rules:
5
5
  * - `n < 1 000` → raw integer string
6
- * - `1 000 ≤ n < 1 000 000` → `Xk` (truncated to whole thousands)
7
- * - `n ≥ 1 000 000` → `X.Xm` (truncated to one decimal of millions)
6
+ * - `1 000 ≤ n < 10 000` → `X.Xk` (truncated to one decimal of thousands)
7
+ * - `10 000 ≤ n < 1 000 000` → `Xk` (truncated to whole thousands)
8
+ * - `1 000 000 ≤ n < 10 000 000` → `X.Xm` (truncated to one decimal of millions)
9
+ * - `n ≥ 10 000 000` → `Xm` (truncated to whole millions)
8
10
  *
9
11
  * Truncation (not rounding) is used so the displayed value never overstates the count.
10
12
  */
@@ -1,12 +1,13 @@
1
- import type { ReactElement } from "react";
1
+ import { type ReactElement } from "react";
2
2
  import type { MapTheme } from "../../core/types";
3
- import type { MarkerState } from "../../markers/model/markerDomTypes";
3
+ import type { MarkerDomSize, MarkerState } from "../../markers/model/markerDomTypes";
4
4
  type ClusterMarkerDefaultDemoProps = {
5
5
  readonly count: number;
6
6
  readonly state: MarkerState;
7
+ readonly markerSize: MarkerDomSize;
7
8
  readonly theme: MapTheme;
8
9
  readonly showSegments: boolean;
9
10
  readonly showSticks: boolean;
10
11
  };
11
- export declare const ClusterMarkerDefaultDemo: ({ count, state, theme, showSegments, showSticks, }: ClusterMarkerDefaultDemoProps) => ReactElement;
12
+ export declare const ClusterMarkerDefaultDemo: ({ count, state, markerSize, theme, showSegments, showSticks, }: ClusterMarkerDefaultDemoProps) => ReactElement;
12
13
  export {};
@@ -0,0 +1,7 @@
1
+ import { type ReactElement } from "react";
2
+ import type { MapTheme } from "../../core/types";
3
+ type ClusterMarkerPieDonutDemoProps = {
4
+ readonly theme: MapTheme;
5
+ };
6
+ export declare const ClusterMarkerPieDonutDemo: ({ theme }: ClusterMarkerPieDonutDemoProps) => ReactElement;
7
+ export {};
@@ -1,10 +1,12 @@
1
1
  import { type ReactElement } from "react";
2
2
  import type { MapTheme } from "../../core/types";
3
+ import type { MarkerDomSize } from "../../markers/model/markerDomTypes";
3
4
  type ClusterMarkerPlaygroundDemoProps = {
4
5
  readonly theme: MapTheme;
6
+ readonly markerSize: MarkerDomSize;
5
7
  readonly stickCount: number;
6
8
  readonly showSegments: boolean;
7
9
  readonly showIndicators: boolean;
8
10
  };
9
- export declare const ClusterMarkerPlaygroundDemo: ({ theme, stickCount, showSegments, showIndicators, }: ClusterMarkerPlaygroundDemoProps) => ReactElement;
11
+ export declare const ClusterMarkerPlaygroundDemo: ({ theme, markerSize, stickCount, showSegments, showIndicators, }: ClusterMarkerPlaygroundDemoProps) => ReactElement;
10
12
  export {};
@@ -1,10 +1,18 @@
1
1
  import type { ReactElement } from "react";
2
2
  import type { MapTheme } from "../../core/types";
3
- type ClusterMarkerWithSticksDemoProps = {
4
- readonly theme: MapTheme;
5
- readonly showSegments: boolean;
3
+ type InteractiveSticksDemoProps = {
6
4
  readonly showIndicators: boolean;
5
+ readonly showSegments: boolean;
7
6
  readonly stickCount: number;
7
+ readonly theme: MapTheme;
8
+ };
9
+ type HighlightedSticksDemoProps = {
10
+ readonly theme: MapTheme;
8
11
  };
9
- export declare const ClusterMarkerWithSticksDemo: ({ theme, showSegments, showIndicators, stickCount, }: ClusterMarkerWithSticksDemoProps) => ReactElement;
12
+ type ClusterMarkerWithSticksDemoProps = ({
13
+ readonly example?: "interactive";
14
+ } & InteractiveSticksDemoProps) | ({
15
+ readonly example: "highlighted";
16
+ } & HighlightedSticksDemoProps);
17
+ export declare const ClusterMarkerWithSticksDemo: (props: ClusterMarkerWithSticksDemoProps) => ReactElement;
10
18
  export {};
@@ -6,7 +6,7 @@ export type ClusterCountFormatHelpers = Readonly<{
6
6
  * Build-your-own hook: stable reference to the cluster count formatter.
7
7
  *
8
8
  * Returns `formatClusterCount` for consumers building custom cluster markers
9
- * that need consistent `"1.2k / 3.4m"` formatting without reimplementing
9
+ * that need consistent `"1.0k"` / `"10k"` / `"9.9m"` / `"99m"` formatting without reimplementing
10
10
  * the truncation rules.
11
11
  *
12
12
  * @example
@@ -0,0 +1,6 @@
1
+ import type { ClusterMarkerProps } from "../ClusterMarker";
2
+ /**
3
+ * `memo` comparator for `ClusterMarker`. Shallow-compares every prop except `segments`,
4
+ * which is compared by content since callers typically derive a fresh array per render.
5
+ */
6
+ export declare const areClusterMarkerPropsEqual: (prev: ClusterMarkerProps, next: ClusterMarkerProps) => boolean;
@@ -0,0 +1,19 @@
1
+ import type { MarkerDomSize, MarkerState } from "../../markers/model/markerDomTypes";
2
+ export type ResolveClusterBubbleSizeOptions = Readonly<{
3
+ count: number;
4
+ /** Single-marker size at the current zoom. When omitted, the bubble is always the open donut. */
5
+ markerSize: MarkerDomSize | undefined;
6
+ state: MarkerState;
7
+ }>;
8
+ export type ClusterBubbleSize = Readonly<{
9
+ /** Outer diameter (px), including the 1px border. */
10
+ diameterPx: number;
11
+ /** True when the bubble is the open donut with the count; false when it rests as a pie. */
12
+ isDonut: boolean;
13
+ }>;
14
+ /**
15
+ * Resolves the cluster bubble diameter and form from the count, single-marker size, and state.
16
+ * The resting pie is one marker step above `markerSize` for counts of 2–9, then one further step
17
+ * per larger count band, clamped at xl.
18
+ */
19
+ export declare const resolveClusterBubbleSize: ({ count, markerSize, state, }: ResolveClusterBubbleSizeOptions) => ClusterBubbleSize;
@@ -0,0 +1,22 @@
1
+ export declare const cvaStoryCaption: import("cva").CVAComponent<Omit<{
2
+ base: string[];
3
+ variants: {
4
+ theme: {
5
+ light: string[];
6
+ dark: string[];
7
+ };
8
+ };
9
+ }, "defaultVariants"> & {
10
+ variants: {
11
+ theme: {
12
+ light: string[];
13
+ dark: string[];
14
+ };
15
+ };
16
+ defaultVariants: Omit<{}, never>;
17
+ }, {
18
+ theme: {
19
+ light: string[];
20
+ dark: string[];
21
+ };
22
+ }>;
@@ -0,0 +1,22 @@
1
+ export declare const cvaStoryTitle: import("cva").CVAComponent<Omit<{
2
+ base: string[];
3
+ variants: {
4
+ theme: {
5
+ light: string[];
6
+ dark: string[];
7
+ };
8
+ };
9
+ }, "defaultVariants"> & {
10
+ variants: {
11
+ theme: {
12
+ light: string[];
13
+ dark: string[];
14
+ };
15
+ };
16
+ defaultVariants: Omit<{}, never>;
17
+ }, {
18
+ theme: {
19
+ light: string[];
20
+ dark: string[];
21
+ };
22
+ }>;