@trackunit/react-map 0.2.222 → 0.2.225

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.esm.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { jsx, Fragment, jsxs } from 'react/jsx-runtime';
2
2
  import { useNamespaceTranslation, registerTranslations } from '@trackunit/i18n-library-translation';
3
- import { INITIAL_CAMERA_STATE, INITIAL_MAP_STATUS, KEYBOARD_PAN_AMOUNT, KEYBOARD_ZOOM_AMOUNT, mapAppearanceSchema, DEFAULT_MAP_APPEARANCE, EMPTY_DRAW_VALUE, WORLD_BBOX, INITIAL_INTERACTION_STATE, mapThemeSchema, mergeAntimeridianFeatures, extractSourceData, computeMarkerDomPortalZIndex, densifyGeodesicFeatures, angularDistance, intermediatePoint, GEODESIC_MAX_SEGMENT_KM, geometryTypeToShapeType, resolveStrokeColors } from '@trackunit/react-map-adapter-shared';
3
+ import { INITIAL_CAMERA_STATE, INITIAL_MAP_STATUS, KEYBOARD_PAN_AMOUNT, KEYBOARD_ZOOM_AMOUNT, mapAppearanceSchema, DEFAULT_MAP_APPEARANCE, EMPTY_DRAW_VALUE, NO_DRAW_ERRORS, WORLD_BBOX, INITIAL_INTERACTION_STATE, mapThemeSchema, mergeAntimeridianFeatures, extractSourceData, computeMarkerDomPortalZIndex, densifyGeodesicFeatures, angularDistance, intermediatePoint, GEODESIC_MAX_SEGMENT_KM, geometryTypeToShapeType, resolveStrokeColors } from '@trackunit/react-map-adapter-shared';
4
4
  export { ANCHOR_SELECTOR, DEFAULT_MAP_APPEARANCE, EMPTY_DRAW_VALUE, HIT_SURFACE_SELECTOR, INITIAL_CAMERA_STATE, INITIAL_INTERACTION_STATE, INITIAL_MAP_STATE, INITIAL_MAP_STATUS, computeMarkerDomPortalZIndex, defineAdapter, geometryTypeToShapeType, mapAppearanceSchema, mapThemeSchema, mapTypeSchema } from '@trackunit/react-map-adapter-shared';
5
5
  import { useSyncExternalStore, useMemo, useState, useRef, useCallback, useLayoutEffect, useEffect, useReducer, createContext, useContext, memo, Suspense, useId, Fragment as Fragment$1, forwardRef } from 'react';
6
6
  import { cva } from '@trackunit/css-class-variance-utilities';
@@ -32,6 +32,7 @@ var defaultTranslations = {
32
32
  "controls.stepper.increase": "Increase",
33
33
  "controls.zoom": "Zoom",
34
34
  "draw.clear": "Clear drawing",
35
+ "draw.limitReached": "Polygon limit reached. Clear the drawing to draw again",
35
36
  "draw.polygon": "Draw polygon",
36
37
  "draw.select": "Edit polygon",
37
38
  "draw.selectDisabled": "Draw a polygon first",
@@ -40,6 +41,7 @@ var defaultTranslations = {
40
41
  "map.loadingErrorMessage": "Unable to load the map. Please try again later.",
41
42
  "multiGeometry.badge.tooltip": "Part of a multi-part shape",
42
43
  "settings.mapStyle.dark": "Dark",
44
+ "settings.mapStyle.disabledWhileDrawing": "Finish drawing to change the map style",
43
45
  "settings.mapStyle.light": "Light",
44
46
  "settings.mapStyle.roadmap": "Road map",
45
47
  "settings.mapStyle.roads": "Roads",
@@ -1831,14 +1833,44 @@ const RIGHT_CONTROL_POPOVER_PLACEMENT = "right-start";
1831
1833
  const RIGHT_CONTROL_TOOLTIP_PLACEMENT = "right";
1832
1834
 
1833
1835
  /**
1834
- * Wraps standalone map controls with a right-aligned tooltip when `overlaySide` is `"right"`.
1835
- * Callers should omit `title` on {@link IconButton} in that mode so tooltips do not stack.
1836
+ * Whether `StandaloneControlTooltip` wraps its child: always on the right overlay
1837
+ * side, and on any side when the caller requires it (a disabled `IconButton` drops
1838
+ * its own tooltip, so a disabled reason needs the wrapper). Callers omit `title`
1839
+ * on the `IconButton` whenever this is true so tooltips do not stack.
1836
1840
  */
1837
- const StandaloneControlTooltip = ({ children, disabled = false, label, overlaySide = undefined, }) => {
1838
- if (overlaySide !== "right") {
1841
+ const isStandaloneTooltipWrapped = (overlaySide, required) => overlaySide === "right" || required;
1842
+
1843
+ /**
1844
+ * Wraps standalone map controls with a right-aligned tooltip when `overlaySide` is `"right"`,
1845
+ * or on any side when `required`. Callers should omit `title` on {@link IconButton} whenever
1846
+ * this wraps so tooltips do not stack; `isStandaloneTooltipWrapped` tells them.
1847
+ */
1848
+ const StandaloneControlTooltip = ({ children, disabled = false, label, overlaySide = undefined, required = false, }) => {
1849
+ if (!isStandaloneTooltipWrapped(overlaySide, required)) {
1839
1850
  return children;
1840
1851
  }
1841
- return (jsx(Tooltip, { delayed: true, disabled: disabled, interactable: false, label: label, placement: RIGHT_CONTROL_TOOLTIP_PLACEMENT, children: children }));
1852
+ return (jsx(Tooltip, { delayed: true, disabled: disabled, interactable: false, label: label, placement: overlaySide === "right" ? RIGHT_CONTROL_TOOLTIP_PLACEMENT : "auto", children: children }));
1853
+ };
1854
+
1855
+ /**
1856
+ * A disabled control is not focusable and its tooltip is hover-only, so the reason it is
1857
+ * disabled is also announced to assistive technology: a visually hidden copy of the reason,
1858
+ * linked from the control through `aria-describedby`. One hook for every control that shows a
1859
+ * `disabledReason`, so the pattern has one home (BooleanButton, Menu, AppearancePreview).
1860
+ */
1861
+ const useDisabledReason = ({ disabled = false, disabledReason, }) => {
1862
+ const id = useId();
1863
+ return useMemo(() => {
1864
+ if (!disabled || disabledReason === undefined) {
1865
+ return { showsReason: false, reason: undefined, describedBy: undefined, description: null };
1866
+ }
1867
+ return {
1868
+ showsReason: true,
1869
+ reason: disabledReason,
1870
+ describedBy: id,
1871
+ description: (jsx("span", { className: "sr-only", id: id, children: disabledReason })),
1872
+ };
1873
+ }, [disabled, disabledReason, id]);
1842
1874
  };
1843
1875
 
1844
1876
  /**
@@ -1853,15 +1885,15 @@ const BooleanButton = ({ config, overlaySide = undefined, resolved }) => {
1853
1885
  const { className, "data-testid": dataTestId, label, onChange: onClickChange, value } = config;
1854
1886
  const { icon, size } = resolved;
1855
1887
  const [t] = useTranslation();
1888
+ const { showsReason, reason, describedBy, description } = useDisabledReason(config);
1856
1889
  if (icon === undefined) {
1857
1890
  return null;
1858
1891
  }
1859
1892
  // A disabled control's tooltip explains what unblocks it rather than what clicking would do
1860
- const tooltipLabel = config.disabled === true && config.disabledReason !== undefined
1861
- ? config.disabledReason
1862
- : t("controls.boolean.tooltip", { label });
1863
- const useRightOverlay = overlaySide === "right";
1864
- return (jsx(StandaloneControlTooltip, { label: tooltipLabel, overlaySide: overlaySide, children: jsx(IconButton, { ...(useRightOverlay ? { ariaLabel: label } : { title: tooltipLabel }), "aria-pressed": value, className: className, "data-testid": dataTestId, disabled: config.disabled ?? false, icon: jsx(Icon, { name: icon, size: "small" }), onClick: () => onClickChange(!value), size: size, variant: value ? "primary" : "secondary" }) }));
1893
+ const tooltipLabel = showsReason ? reason : t("controls.boolean.tooltip", { label });
1894
+ // IconButton drops its own tooltip while disabled, so the reason needs the wrapper on every side.
1895
+ const wrapped = isStandaloneTooltipWrapped(overlaySide, showsReason);
1896
+ return (jsxs(Fragment, { children: [jsx(StandaloneControlTooltip, { label: tooltipLabel, overlaySide: overlaySide, required: showsReason, children: jsx(IconButton, { ...(wrapped ? { ariaLabel: label } : { title: tooltipLabel }), "aria-describedby": describedBy, "aria-pressed": value, className: className, "data-testid": dataTestId, disabled: config.disabled ?? false, icon: jsx(Icon, { name: icon, size: "small" }), onClick: () => onClickChange(!value), size: size, variant: value ? "primary" : "secondary" }) }), description] }));
1865
1897
  };
1866
1898
 
1867
1899
  /**
@@ -2376,6 +2408,9 @@ const SectionHeader = ({ title, className }) => (jsx("div", { className: twMerge
2376
2408
  */
2377
2409
  const Separator = ({ className }) => (jsx("div", { className: twMerge("my-1 h-px bg-neutral-200", className), role: "separator" }));
2378
2410
 
2411
+ /** A disabled menu's tooltip explains what unblocks it rather than what it opens. */
2412
+ const menuTooltipLabel = (config) => config.disabled === true && config.disabledReason !== undefined ? config.disabledReason : config.label;
2413
+
2379
2414
  /**
2380
2415
  * Renders a menu item based on its type.
2381
2416
  * Calls resolveControl with placement: "menu" for each item.
@@ -2450,8 +2485,11 @@ const MenuContent = ({ availableSpace, customPortalId, isFullScreen, items }) =>
2450
2485
  /** Popover opens right; tooltip is suppressed while the menu is open. */
2451
2486
  const MenuRightPopover = ({ availableSpace, config, customPortalId, resolved, }) => {
2452
2487
  const { className, "data-testid": dataTestId, items, label } = config;
2488
+ const disabled = config.disabled ?? false;
2489
+ const tooltipLabel = menuTooltipLabel(config);
2490
+ const { describedBy, description } = useDisabledReason(config);
2453
2491
  const isFullScreen = useIsFullscreen();
2454
- return (jsx("div", { className: className ?? "w-fit", children: jsx(Popover, { placement: RIGHT_CONTROL_POPOVER_PLACEMENT, children: ({ isOpen }) => (jsxs(Fragment$1, { children: [jsx(PopoverTrigger, { children: jsx(Tooltip, { delayed: true, disabled: isOpen, interactable: false, label: label, placement: RIGHT_CONTROL_TOOLTIP_PLACEMENT, children: jsx(IconButton, { ariaLabel: label, "data-testid": dataTestId, icon: jsx(Icon, { name: resolved.icon ?? "QuestionMarkCircle", size: "small" }), size: resolved.size, variant: "secondary" }) }) }), jsx(PopoverContent, { className: "overflow-clip", portalId: isFullScreen ? customPortalId : undefined, children: jsx(MenuContent, { availableSpace: availableSpace, customPortalId: customPortalId, isFullScreen: isFullScreen, items: items }) })] })) }) }));
2492
+ return (jsxs("div", { className: className ?? "w-fit", children: [jsx(Popover, { placement: RIGHT_CONTROL_POPOVER_PLACEMENT, children: ({ isOpen }) => (jsxs(Fragment$1, { children: [jsx(PopoverTrigger, { children: jsx(Tooltip, { delayed: true, disabled: isOpen, interactable: false, label: tooltipLabel, placement: RIGHT_CONTROL_TOOLTIP_PLACEMENT, children: jsx(IconButton, { "aria-describedby": describedBy, ariaLabel: label, "data-testid": dataTestId, disabled: disabled, icon: jsx(Icon, { name: resolved.icon ?? "QuestionMarkCircle", size: "small" }), size: resolved.size, variant: "secondary" }) }) }), jsx(PopoverContent, { className: "overflow-clip", portalId: isFullScreen ? customPortalId : undefined, children: jsx(MenuContent, { availableSpace: availableSpace, customPortalId: customPortalId, isFullScreen: isFullScreen, items: items }) })] })) }), description] }));
2455
2493
  };
2456
2494
  /** Popover presentation -- renders a position-aware popover anchored to the trigger button. */
2457
2495
  const MenuAutoPopover = ({ availableSpace, config, containerRef, customPortalId, resolved, }) => {
@@ -2459,7 +2497,13 @@ const MenuAutoPopover = ({ availableSpace, config, containerRef, customPortalId,
2459
2497
  const isFullScreen = useIsFullscreen();
2460
2498
  const triggerRef = useRef(null);
2461
2499
  const popoverPlacement = usePopoverPlacement(triggerRef, containerRef);
2462
- return (jsx("div", { className: className ?? "w-fit", ref: triggerRef, children: jsxs(Popover, { placement: popoverPlacement, children: [jsx(PopoverTrigger, { children: jsx(IconButton, { "data-testid": dataTestId, icon: jsx(Icon, { name: resolved.icon ?? "QuestionMarkCircle", size: "small" }), size: resolved.size, title: label, variant: "secondary" }) }), jsx(PopoverContent, { className: "overflow-clip", portalId: isFullScreen ? customPortalId : undefined, children: jsx(MenuContent, { availableSpace: availableSpace, customPortalId: customPortalId, isFullScreen: isFullScreen, items: items }) })] }) }));
2500
+ const disabled = config.disabled ?? false;
2501
+ const tooltipLabel = menuTooltipLabel(config);
2502
+ const { showsReason, describedBy, description } = useDisabledReason(config);
2503
+ // IconButton drops its own tooltip while disabled, so a disabled reason needs the wrapper;
2504
+ // the accessible name then moves from `title` to `ariaLabel` so the tooltips do not stack.
2505
+ const trigger = (jsx(IconButton, { ...(showsReason ? { ariaLabel: label, "aria-describedby": describedBy } : { title: tooltipLabel }), "data-testid": dataTestId, disabled: disabled, icon: jsx(Icon, { name: resolved.icon ?? "QuestionMarkCircle", size: "small" }), size: resolved.size, variant: "secondary" }));
2506
+ return (jsxs("div", { className: className ?? "w-fit", ref: triggerRef, children: [jsxs(Popover, { placement: popoverPlacement, children: [jsx(PopoverTrigger, { children: showsReason ? (jsx(StandaloneControlTooltip, { label: tooltipLabel, required: true, children: trigger })) : (trigger) }), jsx(PopoverContent, { className: "overflow-clip", portalId: isFullScreen ? customPortalId : undefined, children: jsx(MenuContent, { availableSpace: availableSpace, customPortalId: customPortalId, isFullScreen: isFullScreen, items: items }) })] }), description] }));
2463
2507
  };
2464
2508
  /**
2465
2509
  * Renders a menu control as a popover -- either anchored to the trigger (auto-placed) or
@@ -2778,11 +2822,17 @@ const cvaMapPreview = cva({
2778
2822
  *
2779
2823
  * @internal
2780
2824
  */
2781
- const AppearancePreview = ({ adapterConfig, mapType, label, selected, onSelect, "data-testid": dataTestId, }) => {
2825
+ const AppearancePreview = ({ adapterConfig, mapType, label, selected, onSelect, disabled = false, disabledReason, "data-testid": dataTestId, }) => {
2782
2826
  const { Map } = usePreviewMap(adapterConfig, { mapType });
2783
- return (jsxs("label", { className: "cursor-pointer", "data-testid": dataTestId, children: [jsxs("div", { className: cvaInteractableItem({
2827
+ const { showsReason, describedBy, description } = useDisabledReason({ disabled, disabledReason });
2828
+ const cell = (jsxs("label", { className: disabled ? undefined : "cursor-pointer", "data-testid": dataTestId, children: [jsxs("div", { className: cvaInteractableItem({
2829
+ disabled,
2784
2830
  className: cvaMapPreview({ selected }),
2785
- }), children: [jsx("input", { checked: selected, className: "sr-only", name: "map-style-preview", onChange: onSelect, type: "radio" }), jsx("div", { className: "absolute inset-px overflow-clip rounded-[calc(var(--radius)-1px)]", inert: true, children: jsx(Map, { "aria-label": label, style: { width: "72px", height: "96px", pointerEvents: "none" } }) })] }), jsx("span", { className: "line-clamp-2 w-[72px] text-center text-xs leading-tight text-neutral-700", children: label })] }));
2831
+ }), children: [jsx("input", { "aria-describedby": describedBy, "aria-disabled": disabled || undefined, checked: selected, className: "sr-only", name: "map-style-preview", onChange: disabled ? undefined : onSelect, readOnly: disabled, type: "radio" }), jsx("div", { className: "absolute inset-px overflow-clip rounded-[calc(var(--radius)-1px)]", inert: true, children: jsx(Map, { "aria-label": label, style: { width: "72px", height: "96px", pointerEvents: "none" } }) })] }), jsx("span", { className: "line-clamp-2 w-[72px] text-center text-xs leading-tight text-neutral-700", children: label }), description] }));
2832
+ if (showsReason) {
2833
+ return jsx(Tooltip, { label: disabledReason, children: cell });
2834
+ }
2835
+ return cell;
2786
2836
  };
2787
2837
 
2788
2838
  /**
@@ -2797,7 +2847,7 @@ const AppearancePreview = ({ adapterConfig, mapType, label, selected, onSelect,
2797
2847
  *
2798
2848
  * @internal
2799
2849
  */
2800
- const AppearancePreviewGrid = ({ adapterConfig, ariaLabel, options, currentTheme, currentMapType, onChange, }) => {
2850
+ const AppearancePreviewGrid = ({ adapterConfig, ariaLabel, options, currentTheme, currentMapType, onChange, disabled = false, disabledReason, }) => {
2801
2851
  const derivedOptions = useMemo(() => options.map(option => ({
2802
2852
  option,
2803
2853
  // Pin the full appearance per option: the main map's config may carry a
@@ -2811,7 +2861,7 @@ const AppearancePreviewGrid = ({ adapterConfig, ariaLabel, options, currentTheme
2811
2861
  showTraffic: false,
2812
2862
  }),
2813
2863
  })), [adapterConfig, options]);
2814
- return (jsx("div", { "aria-label": ariaLabel, className: "flex flex-wrap items-start gap-2 px-3 py-2", role: "radiogroup", children: derivedOptions.map(({ option, config }) => (jsx(AppearancePreview, { adapterConfig: config, "data-testid": `map-style-preview-${option.key}`, label: option.label, mapType: option.mapType, onSelect: () => onChange(option.theme, option.mapType), selected: option.theme === currentTheme && option.mapType === currentMapType }, option.key))) }));
2864
+ return (jsx("div", { "aria-disabled": disabled || undefined, "aria-label": ariaLabel, className: "flex flex-wrap items-start gap-2 px-3 py-2", role: "radiogroup", children: derivedOptions.map(({ option, config }) => (jsx(AppearancePreview, { adapterConfig: config, "data-testid": `map-style-preview-${option.key}`, disabled: disabled, disabledReason: disabledReason, label: option.label, mapType: option.mapType, onSelect: () => onChange(option.theme, option.mapType), selected: option.theme === currentTheme && option.mapType === currentMapType }, option.key))) }));
2815
2865
  };
2816
2866
 
2817
2867
  // ============================================================================
@@ -3077,7 +3127,12 @@ const useMapAppearanceControls = ({ api, options: appearanceOptions, persistence
3077
3127
  }, [dispatch]);
3078
3128
  const previewOptions = useMemo(() => buildAppearanceOptions(themes, mapTypes, t), [themes, mapTypes, t]);
3079
3129
  const adapterConfig = api.adapterConfig;
3130
+ // While a polygon is being drawn, a style change would recreate the map (Google) or
3131
+ // reload the style (Mapbox) and drop the in-progress polygon, so the whole control is
3132
+ // disabled with a reason. The persisted preferences keep displaying (ADR 0030).
3133
+ const drawing = api.state.drawing === true;
3080
3134
  return useMemo(() => {
3135
+ const drawingReason = drawing ? t("settings.mapStyle.disabledWhileDrawing") : undefined;
3081
3136
  const menuItems = [
3082
3137
  {
3083
3138
  type: "custom",
@@ -3089,7 +3144,9 @@ const useMapAppearanceControls = ({ api, options: appearanceOptions, persistence
3089
3144
  section: t("settings.mapStyle.title"),
3090
3145
  render: () => (jsx(AppearancePreviewGrid, { adapterConfig: adapterConfig,
3091
3146
  // `SectionHeader` is role="presentation", so the header above names nothing.
3092
- ariaLabel: t("settings.mapStyle.title"), currentMapType: localState.mapType, currentTheme: localState.theme, onChange: handleAppearanceChange, options: previewOptions })),
3147
+ ariaLabel: t("settings.mapStyle.title"), currentMapType: localState.mapType, currentTheme: localState.theme,
3148
+ // Items disable on their own too: a flattened responsive menu has no trigger.
3149
+ disabled: drawing, disabledReason: drawingReason, onChange: handleAppearanceChange, options: previewOptions })),
3093
3150
  },
3094
3151
  ];
3095
3152
  const isSatelliteOrHybrid = localState.mapType === "satellite" || localState.mapType === "hybrid";
@@ -3103,6 +3160,8 @@ const useMapAppearanceControls = ({ api, options: appearanceOptions, persistence
3103
3160
  id: "roads-overlay",
3104
3161
  label: t("settings.mapStyle.roads"),
3105
3162
  value: localState.showRoads,
3163
+ disabled: drawing,
3164
+ disabledReason: drawingReason,
3106
3165
  onChange: handleRoadsChange,
3107
3166
  "data-testid": "map-style-roads-toggle",
3108
3167
  });
@@ -3113,15 +3172,16 @@ const useMapAppearanceControls = ({ api, options: appearanceOptions, persistence
3113
3172
  // are off but keeps DISPLAYING the persisted preference — the setting is
3114
3173
  // still "on", it just cannot apply until roads return, and the tooltip says
3115
3174
  // so. Explicit "hybrid" always renders roads (and traffic), so the gate
3116
- // applies to "satellite" only.
3175
+ // applies to "satellite" only. Drawing mode disables it as well (a traffic
3176
+ // toggle recreates the Google map); the drawing reason wins while it lasts.
3117
3177
  const trafficBlockedByRoads = localState.mapType === "satellite" && !localState.showRoads;
3118
3178
  menuItems.push({
3119
3179
  type: "toggle",
3120
3180
  id: "traffic-overlay",
3121
3181
  label: t("settings.mapStyle.traffic"),
3122
3182
  value: localState.showTraffic,
3123
- disabled: trafficBlockedByRoads,
3124
- disabledReason: t("settings.mapStyle.trafficRequiresRoads"),
3183
+ disabled: drawing || trafficBlockedByRoads,
3184
+ disabledReason: drawingReason ?? t("settings.mapStyle.trafficRequiresRoads"),
3125
3185
  onChange: handleTrafficChange,
3126
3186
  "data-testid": "map-style-traffic-toggle",
3127
3187
  });
@@ -3133,12 +3193,15 @@ const useMapAppearanceControls = ({ api, options: appearanceOptions, persistence
3133
3193
  icon: "Map",
3134
3194
  label: t("settings.mapStyle.title"),
3135
3195
  items: menuItems,
3196
+ disabled: drawing,
3197
+ disabledReason: drawingReason,
3136
3198
  "data-testid": "map-style-menu",
3137
3199
  },
3138
3200
  ];
3139
3201
  }, [
3140
3202
  t,
3141
3203
  adapterConfig,
3204
+ drawing,
3142
3205
  localState.mapType,
3143
3206
  localState.theme,
3144
3207
  localState.showRoads,
@@ -3388,10 +3451,20 @@ const useControls = (base, areas) => {
3388
3451
  }, [base, areas]);
3389
3452
  };
3390
3453
 
3454
+ /**
3455
+ * Single dev-warning channel for react-map hooks (invalid data, invalid style, unhandled render errors).
3456
+ * Messages are prefixed with the emitting hook, e.g. `[useHeatmap]`.
3457
+ */
3458
+ const consoleWarnMap = (source, message, ...details) => {
3459
+ // eslint-disable-next-line no-console -- Intentional: the one react-map warning channel; invalid input is dropped or defaulted and render errors without onError must stay visible
3460
+ console.warn(`[${source}] ${message}`, ...details);
3461
+ };
3462
+
3391
3463
  const noopUnsubscribe$1 = () => undefined;
3392
3464
  const subscribeNoop = () => noopUnsubscribe$1;
3393
3465
  const getIdle = () => "idle";
3394
3466
  const getEmpty = () => EMPTY_DRAW_VALUE;
3467
+ const getNoErrors = () => NO_DRAW_ERRORS;
3395
3468
  /**
3396
3469
  * Feature-facing hook for the optional drawing capability (ADR 0030).
3397
3470
  *
@@ -3423,13 +3496,56 @@ const useDraw = (api, options) => {
3423
3496
  const subscribe = useMemo(() => (port === undefined ? subscribeNoop : (listener) => port.subscribe(listener)), [port]);
3424
3497
  const getMode = useMemo(() => (port === undefined ? getIdle : () => port.getMode()), [port]);
3425
3498
  const getValue = useMemo(() => (port === undefined ? getEmpty : () => port.getValue()), [port]);
3499
+ const getErrors = useMemo(() => (port === undefined ? getNoErrors : () => port.getErrors()), [port]);
3426
3500
  const mode = useSyncExternalStore(subscribe, getMode, getIdle);
3427
3501
  const value = useSyncExternalStore(subscribe, getValue, getEmpty);
3502
+ const errors = useSyncExternalStore(subscribe, getErrors, getNoErrors);
3503
+ const error = errors.engine ?? errors.value;
3428
3504
  // A disabled hook must not leave the map in a drawing mode it can no longer exit.
3429
3505
  useEffect(() => {
3430
3506
  if (!enabled)
3431
3507
  port?.setMode("idle");
3432
3508
  }, [enabled, port]);
3509
+ const onError = options?.onError;
3510
+ const onErrorRef = useRef(onError);
3511
+ useEffect(() => {
3512
+ onErrorRef.current = onError;
3513
+ }, [onError]);
3514
+ const errorHandler = useErrorHandlerOrNull();
3515
+ // The app's error handler at most once per code per hook mount, like useHeatmap: a
3516
+ // rejected stored boundary re-reports on every reconnect otherwise. A `value-rejected`
3517
+ // verdict is reported too, deliberately: it is the only count of legacy boundaries
3518
+ // the engine cannot render, kept until the Phase 2b pilot has run (ADR 0030).
3519
+ const reportedCodesRef = useRef(new Set());
3520
+ // Each slot reports once per new object: an engine error coming and going must not
3521
+ // re-report the value verdict that stayed in place the whole time.
3522
+ const reportedRef = useRef(NO_DRAW_ERRORS);
3523
+ useEffect(() => {
3524
+ const previous = reportedRef.current;
3525
+ reportedRef.current = errors;
3526
+ const fresh = [errors.engine, errors.value].flatMap(slot => slot !== null && slot !== previous.engine && slot !== previous.value ? [slot] : []);
3527
+ for (const next of fresh) {
3528
+ const detail = next.code === "value-rejected" ? next.reasons : next.cause;
3529
+ if (errorHandler !== null && !reportedCodesRef.current.has(next.code)) {
3530
+ reportedCodesRef.current.add(next.code);
3531
+ errorHandler.captureException(new Error(`Map drawing failed: ${next.code}`, { cause: detail }), {
3532
+ level: "warning",
3533
+ fingerprint: ["react-map", "draw", next.code],
3534
+ });
3535
+ }
3536
+ const handler = onErrorRef.current;
3537
+ if (handler === undefined) {
3538
+ consoleWarnMap("useDraw", `Drawing failed (${next.code}).`, detail);
3539
+ continue;
3540
+ }
3541
+ try {
3542
+ handler(next);
3543
+ }
3544
+ catch (handlerError) {
3545
+ consoleWarnMap("useDraw", "onError threw.", handlerError, detail);
3546
+ }
3547
+ }
3548
+ }, [errors, errorHandler]);
3433
3549
  const setMode = useCallback((nextMode) => {
3434
3550
  port?.setMode(nextMode);
3435
3551
  }, [port]);
@@ -3440,7 +3556,20 @@ const useDraw = (api, options) => {
3440
3556
  port?.setValue(EMPTY_DRAW_VALUE);
3441
3557
  port?.setMode("idle");
3442
3558
  }, [port]);
3443
- const hasPolygons = value.features.length > 0;
3559
+ // Rejected features are in the value but not on the map, so there is nothing to
3560
+ // select among them; the edit toggle follows what the map shows, whatever the
3561
+ // engine slot says.
3562
+ const rejectedCount = errors.value?.rejectedFeatureIndices.length ?? 0;
3563
+ const hasPolygons = value.features.length - rejectedCount > 0;
3564
+ const maxPolygons = options?.maxPolygons;
3565
+ // A limit below one would lock the toggle with "clear the drawing" and nothing to clear.
3566
+ const limitReached = maxPolygons !== undefined && maxPolygons >= 1 && value.features.length >= maxPolygons;
3567
+ // Closing the polygon that reaches the limit ends polygon mode, so the next click
3568
+ // on the map does not start another one.
3569
+ useEffect(() => {
3570
+ if (limitReached && mode === "polygon")
3571
+ port?.setMode("idle");
3572
+ }, [limitReached, mode, port]);
3444
3573
  const controls = useMemo(() => {
3445
3574
  if (!isSupported || !enabled)
3446
3575
  return [];
@@ -3452,6 +3581,10 @@ const useDraw = (api, options) => {
3452
3581
  section: t("controls.category.tools"),
3453
3582
  value: mode === "polygon",
3454
3583
  onChange: on => setMode(on ? "polygon" : "idle"),
3584
+ // The limit effect above ends polygon mode as soon as it is reached, so the
3585
+ // toggle is never both active and disabled for longer than one render.
3586
+ disabled: limitReached,
3587
+ disabledReason: limitReached ? t("draw.limitReached") : undefined,
3455
3588
  "data-testid": "map-draw-polygon-toggle",
3456
3589
  };
3457
3590
  const selectToggle = {
@@ -3477,17 +3610,19 @@ const useDraw = (api, options) => {
3477
3610
  "data-testid": "map-draw-clear-button",
3478
3611
  };
3479
3612
  return [polygonToggle, selectToggle, clearButton];
3480
- }, [clear, enabled, hasPolygons, isSupported, mode, setMode, t]);
3613
+ }, [clear, enabled, hasPolygons, isSupported, limitReached, mode, setMode, t]);
3481
3614
  return useMemo(() => ({
3482
3615
  isSupported,
3483
3616
  isDrawing: mode !== "idle",
3484
3617
  mode,
3485
3618
  value,
3619
+ error,
3620
+ errors,
3486
3621
  setMode,
3487
3622
  setValue,
3488
3623
  clear,
3489
3624
  controls,
3490
- }), [clear, controls, isSupported, mode, setMode, setValue, value]);
3625
+ }), [clear, controls, error, errors, isSupported, mode, setMode, setValue, value]);
3491
3626
  };
3492
3627
 
3493
3628
  /**
@@ -5994,15 +6129,6 @@ const useFitToContent = (api, handles, options) => {
5994
6129
  return useMemo(() => ({ hasFitted, isWaiting, fitNow }), [hasFitted, isWaiting, fitNow]);
5995
6130
  };
5996
6131
 
5997
- /**
5998
- * Single dev-warning channel for react-map hooks (invalid data, invalid style, unhandled render errors).
5999
- * Messages are prefixed with the emitting hook, e.g. `[useHeatmap]`.
6000
- */
6001
- const consoleWarnMap = (source, message, ...details) => {
6002
- // eslint-disable-next-line no-console -- Intentional: the one react-map warning channel; invalid input is dropped or defaulted and render errors without onError must stay visible
6003
- console.warn(`[${source}] ${message}`, ...details);
6004
- };
6005
-
6006
6132
  /**
6007
6133
  * Converts consumer items into weighted GeoJSON `Point` features.
6008
6134
  *
@@ -12331,6 +12457,26 @@ const useShapeFillTiling = ({ api, handles, onFillGeometriesChange, onZIndexOver
12331
12457
  return useMemo(() => ({ queryAt, promoteFeature, clearPromotion, setDecorationHoverActive }), [queryAt, promoteFeature, clearPromotion, setDecorationHoverActive]);
12332
12458
  };
12333
12459
 
12460
+ const noop$1 = () => undefined;
12461
+ /** The handler set every consumer gets while drawing: nothing reaches the layers. */
12462
+ const INERT_LAYER_INTERACTION = {
12463
+ hover: noop$1,
12464
+ select: noop$1,
12465
+ dblClick: noop$1,
12466
+ click: undefined,
12467
+ settleHover: noop$1,
12468
+ decorationHover: noop$1,
12469
+ decorationSelect: noop$1,
12470
+ };
12471
+ /**
12472
+ * The one choke point for "drawing mode suppresses layer interaction" (ADR 0030).
12473
+ * `<Layers>` builds its live handlers once, passes them through here, and hands the
12474
+ * result to every consumer, so a new interaction path is gated by default instead
12475
+ * of needing its own `if (drawing) return`. Programmatic `layers.select` and
12476
+ * `layers.hover` calls from outside `<Layers>` are not affected.
12477
+ */
12478
+ const useGatedLayerInteraction = (live, drawing) => useMemo(() => (drawing ? INERT_LAYER_INTERACTION : live), [drawing, live]);
12479
+
12334
12480
  const applyPortalZIndex = (container, zIndex) => {
12335
12481
  container.style.zIndex = String(zIndex);
12336
12482
  };
@@ -12358,6 +12504,13 @@ const applyPortalZIndex = (container, zIndex) => {
12358
12504
  */
12359
12505
  const Layers = ({ layers, api, onEntityClick, onEntityDblClick }) => {
12360
12506
  const layerPort = useLayerPort();
12507
+ // Drawing mode suppresses layer interaction (ADR 0030): shapes render non-interactive
12508
+ // so Terra Draw gets every click without competing with existing polygons, and
12509
+ // marker, cluster and decoration clicks and hovers are dropped so no panel opens and
12510
+ // no hover state lingers under the draw tool. Every interaction consumer below gets
12511
+ // its handlers from one gated set (`interaction`), so the rule lives in one place.
12512
+ // Programmatic `layers.select` still works.
12513
+ const drawing = api?.state.drawing === true;
12361
12514
  const [decorationLayers, setDecorationLayers] = useState([]);
12362
12515
  const [viewportStyleOverrides, setViewportStyleOverrides] = useState(new Map());
12363
12516
  const [fillGeometryOverrides, setFillGeometryOverrides] = useState(new Map());
@@ -12383,6 +12536,59 @@ const Layers = ({ layers, api, onEntityClick, onEntityDblClick }) => {
12383
12536
  // Fill tiling only fires on polygonal features — shapeType is always "polygon".
12384
12537
  layers.hover({ type: "shape", id: featureId, handleId, shapeType: "polygon" });
12385
12538
  }, [layers]);
12539
+ // The remaining live handlers are defined here, before the hooks that consume them,
12540
+ // so one gated set can feed every consumer. `setDomHoverActive` comes from
12541
+ // `useEntityInteraction` further down, hence the ref.
12542
+ const setDomHoverActiveRef = useRef(() => undefined);
12543
+ const promoteFeatureRef = useRef(() => undefined);
12544
+ const setDecorationHoverActiveRef = useRef(() => undefined);
12545
+ const coordinatedSelect = useCallback((entity) => {
12546
+ const hovered = hoveredEntityRef.current;
12547
+ if (entity === null || hovered === null) {
12548
+ layers.select(entity);
12549
+ return;
12550
+ }
12551
+ // Default to the entity the click actually carried — it is the freshest,
12552
+ // most direct signal of what the user hit, and it hit-tests per element
12553
+ // for markers/clusters/routes. The single exception is a shape-on-shape
12554
+ // conflict: the adapter resolves polygon clicks with its own z-order and
12555
+ // full (unclipped) hit areas, so it cannot honor the fill-tiling ownership
12556
+ // model that decides which overlapping site is visually on top (ADR-0021).
12557
+ // There the settle-resolved hovered shape is the precise winner, so we
12558
+ // trust it instead. This is also why an asset pill overlapping a site
12559
+ // selects the asset: a marker click is never a shape, so it bypasses the
12560
+ // exception even when a stale site shape is still the hovered entity.
12561
+ if (entity.type === "shape" && hovered.type === "shape") {
12562
+ layers.select(hovered);
12563
+ return;
12564
+ }
12565
+ layers.select(entity);
12566
+ }, [layers]);
12567
+ const fitOnDblClick = useDblClickFit(api, layers.handles);
12568
+ const handleDblClick = useCallback((entity) => {
12569
+ onEntityDblClick?.(entity);
12570
+ fitOnDblClick?.(entity);
12571
+ }, [onEntityDblClick, fitOnDblClick]);
12572
+ const coordinatedHover = useCallback((entity) => {
12573
+ setDomHoverActiveRef.current(entity !== null);
12574
+ setDecorationHoverActiveRef.current(entity !== null);
12575
+ if (entity !== null && entity.type === "shape" && entity.handleId !== undefined) {
12576
+ promoteFeatureRef.current(entity.handleId, entity.id);
12577
+ }
12578
+ layers.hover(entity);
12579
+ }, [layers]);
12580
+ const liveInteraction = useMemo(() => ({
12581
+ hover: layers.hover,
12582
+ select: coordinatedSelect,
12583
+ dblClick: handleDblClick,
12584
+ click: onEntityClick,
12585
+ settleHover: onSettledHover,
12586
+ decorationHover: coordinatedHover,
12587
+ decorationSelect: layers.select,
12588
+ }), [layers.hover, layers.select, coordinatedSelect, handleDblClick, onEntityClick, onSettledHover, coordinatedHover]);
12589
+ // The one choke point for ADR 0030's interaction rule: while drawing, every consumer
12590
+ // below receives handlers that do nothing (see useGatedLayerInteraction).
12591
+ const interaction = useGatedLayerInteraction(liveInteraction, drawing);
12386
12592
  const suppressedFillIdsByHandle = useMemo(() => {
12387
12593
  const result = new Map();
12388
12594
  for (const [handleId, featureStyles] of viewportStyleOverrides) {
@@ -12401,16 +12607,14 @@ const Layers = ({ layers, api, onEntityClick, onEntityDblClick }) => {
12401
12607
  handles: layers.handles,
12402
12608
  onFillGeometriesChange: setFillGeometryOverrides,
12403
12609
  onZIndexOverridesChange: setZIndexOverrides,
12404
- onSettledHover,
12610
+ onSettledHover: interaction.settleHover,
12405
12611
  selectedFeatureId: layers.interaction.selectedEntity?.type === "shape" ? String(layers.interaction.selectedEntity.id) : null,
12406
12612
  selectedHandleId: layers.interaction.selectedEntity?.type === "shape" ? layers.interaction.selectedEntity.handleId : null,
12407
12613
  suppressedFillIdsByHandle,
12408
12614
  });
12409
- const promoteFeatureRef = useRef(promoteFeature);
12410
12615
  useEffect(() => {
12411
12616
  promoteFeatureRef.current = promoteFeature;
12412
12617
  }, [promoteFeature]);
12413
- const setDecorationHoverActiveRef = useRef(setDecorationHoverActive);
12414
12618
  useEffect(() => {
12415
12619
  setDecorationHoverActiveRef.current = setDecorationHoverActive;
12416
12620
  }, [setDecorationHoverActive]);
@@ -12422,11 +12626,14 @@ const Layers = ({ layers, api, onEntityClick, onEntityDblClick }) => {
12422
12626
  const fillOverrides = fillGeometryOverrides.get(handle.id);
12423
12627
  const zIndexOverridesForHandle = zIndexOverrides.get(handle.id);
12424
12628
  const queryShapesAt = (position) => queryAt(handle.id, position);
12629
+ // The existing "none" mode removes the adapter's polygon hit-testing entirely.
12630
+ const interactiveWhileDrawing = drawing ? { interactive: "none" } : {};
12425
12631
  if (styleOverrides === undefined && fillOverrides === undefined && zIndexOverridesForHandle === undefined) {
12426
- return { ...handle, queryShapesAt };
12632
+ return { ...handle, ...interactiveWhileDrawing, queryShapesAt };
12427
12633
  }
12428
12634
  return {
12429
12635
  ...handle,
12636
+ ...interactiveWhileDrawing,
12430
12637
  queryShapesAt,
12431
12638
  ...(styleOverrides !== undefined ? { featureStyles: styleOverrides } : {}),
12432
12639
  ...(fillOverrides !== undefined ? { featureFillGeometries: fillOverrides } : {}),
@@ -12440,50 +12647,35 @@ const Layers = ({ layers, api, onEntityClick, onEntityDblClick }) => {
12440
12647
  return handle;
12441
12648
  return { ...handle, adaptiveResolution };
12442
12649
  });
12443
- }, [layers.handles, adaptiveByLayer, viewportStyleOverrides, fillGeometryOverrides, zIndexOverrides, queryAt]);
12650
+ }, [
12651
+ layers.handles,
12652
+ adaptiveByLayer,
12653
+ viewportStyleOverrides,
12654
+ fillGeometryOverrides,
12655
+ zIndexOverrides,
12656
+ queryAt,
12657
+ drawing,
12658
+ ]);
12444
12659
  const { renderedSourceIds, hasSourceReadiness, readyRevision } = useLayerHandleSync(layerPort, enrichedHandles, layers.interaction, decorationLayers);
12445
- const coordinatedSelect = useCallback((entity) => {
12446
- const hovered = hoveredEntityRef.current;
12447
- if (entity === null || hovered === null) {
12448
- layers.select(entity);
12449
- return;
12450
- }
12451
- // Default to the entity the click actually carried — it is the freshest,
12452
- // most direct signal of what the user hit, and it hit-tests per element
12453
- // for markers/clusters/routes. The single exception is a shape-on-shape
12454
- // conflict: the adapter resolves polygon clicks with its own z-order and
12455
- // full (unclipped) hit areas, so it cannot honor the fill-tiling ownership
12456
- // model that decides which overlapping site is visually on top (ADR-0021).
12457
- // There the settle-resolved hovered shape is the precise winner, so we
12458
- // trust it instead. This is also why an asset pill overlapping a site
12459
- // selects the asset: a marker click is never a shape, so it bypasses the
12460
- // exception even when a stale site shape is still the hovered entity.
12461
- if (entity.type === "shape" && hovered.type === "shape") {
12462
- layers.select(hovered);
12463
- return;
12464
- }
12465
- layers.select(entity);
12466
- }, [layers]);
12467
- const fitOnDblClick = useDblClickFit(api, layers.handles);
12468
- const handleDblClick = useCallback((entity) => {
12469
- onEntityDblClick?.(entity);
12470
- fitOnDblClick?.(entity);
12471
- }, [onEntityDblClick, fitOnDblClick]);
12472
12660
  const setDomHoverActive = useEntityInteraction({
12473
12661
  layerPort,
12474
- hover: layers.hover,
12475
- select: coordinatedSelect,
12476
- dblClick: handleDblClick,
12477
- onClick: onEntityClick,
12662
+ hover: interaction.hover,
12663
+ select: interaction.select,
12664
+ dblClick: interaction.dblClick,
12665
+ onClick: interaction.click,
12478
12666
  });
12479
- const coordinatedHover = useCallback((entity) => {
12480
- setDomHoverActive(entity !== null);
12481
- setDecorationHoverActiveRef.current(entity !== null);
12482
- if (entity !== null && entity.type === "shape" && entity.handleId !== undefined) {
12483
- promoteFeatureRef.current(entity.handleId, entity.id);
12484
- }
12485
- layers.hover(entity);
12486
- }, [layers, setDomHoverActive]);
12667
+ useEffect(() => {
12668
+ setDomHoverActiveRef.current = setDomHoverActive;
12669
+ }, [setDomHoverActive]);
12670
+ // Entering drawing mode drops whatever was hovered, so nothing stays highlighted
12671
+ // under the draw tool; selection is left alone (the wizard may show the stored site).
12672
+ useEffect(() => {
12673
+ if (!drawing)
12674
+ return;
12675
+ setDomHoverActive(false);
12676
+ setDecorationHoverActiveRef.current(false);
12677
+ layers.hover(null);
12678
+ }, [drawing, layers, setDomHoverActive]);
12487
12679
  const interactionMapStore = useShapeDecorations({
12488
12680
  onDecorationsChange: setDecorationLayers,
12489
12681
  onStyleOverridesChange: setViewportStyleOverrides,
@@ -12493,8 +12685,8 @@ const Layers = ({ layers, api, onEntityClick, onEntityDblClick }) => {
12493
12685
  hasSourceReadiness,
12494
12686
  readyRevision,
12495
12687
  interaction: layers.interaction,
12496
- select: layers.select,
12497
- hover: coordinatedHover,
12688
+ select: interaction.decorationSelect,
12689
+ hover: interaction.decorationHover,
12498
12690
  });
12499
12691
  const interactionMap = useSyncExternalStore(interactionMapStore.subscribe, interactionMapStore.getSnapshot, interactionMapStore.getSnapshot);
12500
12692
  const markerPortalStore = layerPort?.markerPortals ?? null;
@@ -12581,8 +12773,8 @@ const Layers = ({ layers, api, onEntityClick, onEntityDblClick }) => {
12581
12773
  }
12582
12774
  : itemState;
12583
12775
  const rendered = descriptor.renderFn(descriptor.sourceData, callState);
12584
- const interaction = interactionMap.get(sourceId);
12585
- return { descriptor, rendered, interaction, zIndex };
12776
+ const decorationInteraction = interactionMap.get(sourceId);
12777
+ return { descriptor, rendered, decorationInteraction, zIndex };
12586
12778
  };
12587
12779
  const portalEntries = [
12588
12780
  ...markerPortalDescriptors.map(d => buildPortalEntry(d, "marker")),
@@ -12596,9 +12788,9 @@ const Layers = ({ layers, api, onEntityClick, onEntityDblClick }) => {
12596
12788
  });
12597
12789
  if (portalEntries.length === 0)
12598
12790
  return null;
12599
- return (jsx(Fragment, { children: portalEntries.map(({ descriptor, rendered, interaction }) => {
12600
- if (interaction !== undefined) {
12601
- return createPortal(jsx(InteractiveDecoration, { entity: interaction.entity, hover: coordinatedHover, onClick: interaction.onClick, onDblClick: handleDblClick, onHover: interaction.onHover, select: layers.select, children: rendered }), descriptor.container, descriptor.key);
12791
+ return (jsx(Fragment, { children: portalEntries.map(({ descriptor, rendered, decorationInteraction }) => {
12792
+ if (decorationInteraction !== undefined) {
12793
+ return createPortal(jsx(InteractiveDecoration, { entity: decorationInteraction.entity, hover: interaction.decorationHover, onClick: decorationInteraction.onClick, onDblClick: interaction.dblClick, onHover: decorationInteraction.onHover, select: interaction.decorationSelect, children: rendered }), descriptor.container, descriptor.key);
12602
12794
  }
12603
12795
  return createPortal(rendered, descriptor.container, descriptor.key);
12604
12796
  }) }));