@trackunit/react-map 0.0.10 → 0.0.11

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
@@ -1239,7 +1239,6 @@ function useMapAdapterState(adapterConfig) {
1239
1239
  * All action functions and the Map component reference are stable across re-renders.
1240
1240
  *
1241
1241
  * @param adapterConfig - Adapter configuration from an adapter factory (e.g., googleMapsAdapter)
1242
- * @param options - Optional configuration for controls and other features
1243
1242
  * @returns Tuple of [Map component, API object]
1244
1243
  * @example Basic usage
1245
1244
  * ```tsx
@@ -1278,7 +1277,7 @@ function useMapAdapterState(adapterConfig) {
1278
1277
  * };
1279
1278
  * ```
1280
1279
  */
1281
- const useMap = (adapterConfig, options) => {
1280
+ const useMap = (adapterConfig) => {
1282
1281
  const { adapter, renderConfig } = useMapAdapterState(adapterConfig);
1283
1282
  // Subscribe to status changes only (isReady, appearance, etc. — low frequency).
1284
1283
  // Camera state (center/zoom/bounds) is accessed via useCameraState(api) instead,
@@ -1355,7 +1354,6 @@ const useMap = (adapterConfig, options) => {
1355
1354
  actions,
1356
1355
  on,
1357
1356
  containerRef: containerRefHolder,
1358
- options,
1359
1357
  adapterConfig: renderConfig,
1360
1358
  annotations: annotationStore,
1361
1359
  loadingIndicator: loadingIndicatorStore,
@@ -1367,7 +1365,6 @@ const useMap = (adapterConfig, options) => {
1367
1365
  actions,
1368
1366
  on,
1369
1367
  containerRefHolder,
1370
- options,
1371
1368
  renderConfig,
1372
1369
  annotationStore,
1373
1370
  loadingIndicatorStore,
@@ -1607,7 +1604,12 @@ const collapseAll = (categories, allControls) => {
1607
1604
  }
1608
1605
  // Nothing to show
1609
1606
  if (allItems.length === 0) {
1610
- return { navigation: { controls: [] }, settings: { controls: [] }, tools: null, statistics: { controls: [] } };
1607
+ return {
1608
+ navigation: { controls: [] },
1609
+ settings: { controls: [] },
1610
+ tools: { controls: [] },
1611
+ statistics: { controls: [] },
1612
+ };
1611
1613
  }
1612
1614
  const singleMenu = {
1613
1615
  type: "menu",
@@ -1620,7 +1622,7 @@ const collapseAll = (categories, allControls) => {
1620
1622
  return {
1621
1623
  navigation: { controls: [singleMenu] },
1622
1624
  settings: { controls: [] },
1623
- tools: null,
1625
+ tools: { controls: [] },
1624
1626
  statistics: { controls: [] },
1625
1627
  };
1626
1628
  };
@@ -1644,10 +1646,12 @@ const collapseControls = (config, mode, metadata) => {
1644
1646
  if (mode === "full")
1645
1647
  return config;
1646
1648
  // Categories that participate in collapse (statistics excluded — summary widgets stay visible).
1649
+ // Tools fold into the single all-controls menu in "collapsed" mode; in "full"/"categorized"
1650
+ // they render standalone in the top-right corner.
1647
1651
  const categories = [
1648
1652
  { key: "navigation", label: metadata.navigation.label, controls: config.navigation.controls },
1649
1653
  { key: "settings", label: metadata.settings.label, controls: config.settings.controls },
1650
- // tools are still null - to be implemented
1654
+ { key: "tools", label: metadata.tools.label, controls: config.tools.controls },
1651
1655
  ];
1652
1656
  if (mode === "collapsed") {
1653
1657
  const collapsed = collapseAll(categories, metadata.allControls);
@@ -1660,7 +1664,10 @@ const collapseControls = (config, mode, metadata) => {
1660
1664
  const settings = {
1661
1665
  controls: collapseCategory(config.settings.controls, "settings", metadata.settings),
1662
1666
  };
1663
- return { navigation, settings, tools: null, statistics: config.statistics };
1667
+ const tools = {
1668
+ controls: collapseCategory(config.tools.controls, "tools", metadata.tools),
1669
+ };
1670
+ return { navigation, settings, tools, statistics: config.statistics };
1664
1671
  };
1665
1672
 
1666
1673
  /**
@@ -2413,6 +2420,109 @@ const GLOBE_CENTERS = [
2413
2420
  /** Pick the globe icon whose projection center is closest to the given longitude. */
2414
2421
  const longitudeToGlobeIcon = (longitude) => GLOBE_CENTERS.reduce((closest, current) => Math.abs(current.center - longitude) < Math.abs(closest.center - longitude) ? current : closest).icon;
2415
2422
 
2423
+ /**
2424
+ * Internal component that renders controls.
2425
+ * Separated to allow Suspense boundary in parent.
2426
+ */
2427
+ const ControlsContent = ({ api, controls: baseControlsConfig, className }) => {
2428
+ const [t] = useTranslation();
2429
+ // Measure container to derive available space and responsive mode
2430
+ const containerRef = react.useRef(null);
2431
+ const { ref: measureRef, geometry } = reactComponents.useMeasure();
2432
+ const mergedRef = reactComponents.useMergeRefs([containerRef, measureRef]);
2433
+ const { displayAnnotations, isExiting, shouldRender: shouldRenderAnnotations, } = useAnnotationPresence(api.annotations);
2434
+ const skipContainerBreakpoints = baseControlsConfig.navigation.controls.length === 0 &&
2435
+ baseControlsConfig.settings.controls.length === 0 &&
2436
+ baseControlsConfig.tools.controls.length === 0 &&
2437
+ baseControlsConfig.statistics.controls.length === 0 &&
2438
+ !shouldRenderAnnotations;
2439
+ const breakpoints = reactComponents.useContainerBreakpoints(containerRef, { skip: skipContainerBreakpoints });
2440
+ const containerHeight = geometry?.height ?? Infinity;
2441
+ const safeAreaLayout = react.useContext(SafeAreaLayoutContext);
2442
+ const measuredAvailableSpace = breakpoints.isSm && containerHeight >= HEIGHT_COMFORTABLE_THRESHOLD ? "comfortable" : "constrained";
2443
+ const measuredResponsiveMode = deriveResponsiveMode(breakpoints.isSm, breakpoints.isMd, containerHeight);
2444
+ const availableSpace = safeAreaLayout?.availableSpace ?? measuredAvailableSpace;
2445
+ const responsiveMode = safeAreaLayout?.responsiveMode ?? measuredResponsiveMode;
2446
+ // Menus use modals only in collapsed mode (narrowest containers); popovers otherwise
2447
+ const menuPresentation = responsiveMode === "collapsed" ? "modal" : "popover";
2448
+ // Derive portal ID from the map container's stable ID (set by createMapComponent)
2449
+ const portalId = api.containerRef.current?.id;
2450
+ // Geolocation position for region-appropriate globe icon (updated if the user clicks "My Location")
2451
+ const { position } = reactCoreHooks.useGeolocation();
2452
+ const navigationIcon = position !== null ? longitudeToGlobeIcon(position[0]) : "GlobeEuropeAfrica";
2453
+ // Category metadata: icon + translated label for menu triggers, tooltips, and section headers
2454
+ const categoryMetadata = react.useMemo(() => ({
2455
+ navigation: { icon: navigationIcon, label: t("controls.category.navigation") },
2456
+ settings: { icon: "Cog6Tooth", label: t("controls.category.settings") },
2457
+ tools: { icon: "Wrench", label: t("controls.category.tools") },
2458
+ statistics: { icon: "ChartBarSquare", label: t("controls.category.statistics") },
2459
+ allControls: { icon: "EllipsisHorizontal", label: t("controls.allControls") },
2460
+ }), [t, navigationIcon]);
2461
+ // Apply responsive collapsing -- restructures config based on available space
2462
+ const controlsConfig = react.useMemo(() => collapseControls(baseControlsConfig, responsiveMode, categoryMetadata), [baseControlsConfig, responsiveMode, categoryMetadata]);
2463
+ react.useLayoutEffect(() => api.annotations.claimRenderer(), [api.annotations]);
2464
+ const { navigation, settings, tools, statistics } = controlsConfig;
2465
+ const hasNavigationControls = navigation.controls.length > 0;
2466
+ const hasSettingsControls = settings.controls.length > 0;
2467
+ const hasToolsControls = tools.controls.length > 0;
2468
+ const hasStatisticsControls = statistics.controls.length > 0;
2469
+ const hasStatisticsAreaContent = shouldRenderAnnotations || hasStatisticsControls;
2470
+ if (!hasNavigationControls && !hasSettingsControls && !hasToolsControls && !hasStatisticsAreaContent) {
2471
+ return null;
2472
+ }
2473
+ return (jsxRuntime.jsxs("div", { className: tailwindMerge.twMerge(
2474
+ // Overlay positioning within Map's ZStack grid
2475
+ "col-start-1 row-start-1",
2476
+ // Pointer events only on children, not the overlay itself
2477
+ "pointer-events-none",
2478
+ // Layout grid for semantic areas
2479
+ "grid h-full w-full",
2480
+ // Responsive grid: navigation bottom-right, settings top-left, statistics bottom-left
2481
+ "grid-cols-[1fr_auto] grid-rows-[auto_1fr_auto]",
2482
+ // Isolate creates a new stacking context to avoid z-index issues
2483
+ "isolate", className), "data-testid": "map-controls", ref: mergedRef, children: [hasToolsControls ? (jsxRuntime.jsx("div", { className: "pointer-events-auto col-start-2 row-start-1 flex flex-col gap-1", "data-testid": "map-controls-tools", children: tools.controls.map(control => (jsxRuntime.jsx(ControlRenderer, { availableSpace: availableSpace, containerRef: containerRef, control: control, customPortalId: portalId, menuPresentation: menuPresentation }, control.id))) })) : null, hasNavigationControls ? (jsxRuntime.jsx("div", { className: "pointer-events-auto col-start-2 row-start-3 flex flex-col gap-1", children: navigation.controls.map(control => (jsxRuntime.jsx(ControlRenderer, { availableSpace: availableSpace, containerRef: containerRef, control: control, customPortalId: portalId, menuPresentation: menuPresentation }, control.id))) })) : null, hasSettingsControls ? (jsxRuntime.jsx("div", { className: "pointer-events-auto col-start-1 row-start-1 flex w-fit flex-col gap-1", "data-testid": "map-controls-settings", children: settings.controls.map(control => (jsxRuntime.jsx(ControlRenderer, { availableSpace: availableSpace, containerRef: containerRef, control: control, customPortalId: portalId, menuPresentation: menuPresentation }, control.id))) })) : null, hasStatisticsAreaContent ? (jsxRuntime.jsxs("div", { className: tailwindMerge.twMerge("pointer-events-none col-start-1 row-start-3 flex max-h-full max-w-[20rem] flex-col items-start justify-end gap-1 overflow-visible", !hasStatisticsControls && "self-end"), "data-testid": "map-controls-statistics", children: [shouldRenderAnnotations ? (jsxRuntime.jsx("div", { className: "pointer-events-auto", "data-testid": "map-controls-statistics-annotations", children: jsxRuntime.jsx(AnnotationStack, { annotations: displayAnnotations, isExiting: isExiting }) })) : null, hasStatisticsControls ? (jsxRuntime.jsx("div", { className: "pointer-events-auto flex max-h-full w-fit max-w-full flex-col gap-1 overflow-auto", "data-testid": "map-controls-statistics-controls", children: statistics.controls.map(control => (jsxRuntime.jsx(ControlRenderer, { availableSpace: availableSpace, containerRef: containerRef, control: control, customPortalId: portalId, menuPresentation: menuPresentation }, control.id))) })) : null] })) : null] }));
2484
+ };
2485
+ /**
2486
+ * Map controls renderer — lays out a `ControlsConfig` across the four semantic
2487
+ * areas of the map (top-right tools, bottom-right navigation, top-left settings,
2488
+ * bottom-left statistics + annotations).
2489
+ *
2490
+ * Build the `controls` prop with `useDefaultControls`, `useControls`, or
2491
+ * `composeControlAreas`. For the common "default built-ins only" case prefer
2492
+ * the `<DefaultControls>` wrapper which combines these two steps.
2493
+ *
2494
+ * Inherits layout decisions from the surrounding SafeAreaOverlay when rendered
2495
+ * inside `<Map>`, and falls back to its own container measurement when
2496
+ * rendered standalone.
2497
+ *
2498
+ * Uses Suspense internally to handle translation loading without blocking the map.
2499
+ *
2500
+ * @example
2501
+ * ```tsx
2502
+ * import { useMap, useDefaultControls, Controls } from "@trackunit/react-map";
2503
+ * import { googleMapsAdapter } from "@trackunit/react-map-adapter-google";
2504
+ *
2505
+ * const MyMap = () => {
2506
+ * const [Map, api] = useMap(googleMapsAdapter({ apiKey }));
2507
+ * const controls = useDefaultControls(api);
2508
+ *
2509
+ * return (
2510
+ * <Map>
2511
+ * <Controls api={api} controls={controls} />
2512
+ * </Map>
2513
+ * );
2514
+ * };
2515
+ * ```
2516
+ */
2517
+ const ControlsInner = ({ api, controls, className }) => {
2518
+ // Wrap in Suspense to handle useTranslation loading without blocking parent components
2519
+ return (jsxRuntime.jsx(react.Suspense, { fallback: null, children: jsxRuntime.jsx(ControlsContent, { api: api, className: className, controls: controls }) }));
2520
+ };
2521
+ // `api` identity is stable between camera events since useMap only re-runs its useMemo
2522
+ // when MapStatus (isReady, appearance, etc.) changes — not on pan/zoom. Default React.memo
2523
+ // shallow equality is sufficient; no custom comparator needed.
2524
+ const Controls = react.memo(ControlsInner);
2525
+
2416
2526
  const cvaMapPreview = cssClassVarianceUtilities.cvaMerge([
2417
2527
  "relative",
2418
2528
  "h-[72px]",
@@ -2608,11 +2718,11 @@ const buildAppearanceOptions = (themes, mapTypes, t) => {
2608
2718
  * const controlsConfig: ControlsConfig = {
2609
2719
  * navigation: { controls: navigationControls },
2610
2720
  * settings: { controls: appearanceControls },
2611
- * tools: null,
2721
+ * tools: { controls: [] },
2612
2722
  * statistics: { controls: [] },
2613
2723
  * };
2614
2724
  *
2615
- * <Controls api={api} controlsConfig={controlsConfig} />
2725
+ * <Controls api={api} controls={controlsConfig} />
2616
2726
  * ```
2617
2727
  */
2618
2728
  const useMapAppearanceControls = ({ api, options: appearanceOptions, persistenceKey = "map-appearance-settings", }) => {
@@ -2730,17 +2840,36 @@ const defineControlStack = (controls) => {
2730
2840
  return stack;
2731
2841
  };
2732
2842
 
2843
+ // ============================================================================
2844
+ // Hook
2845
+ // ============================================================================
2733
2846
  /**
2734
- * Builds the controls configuration from useMap options and api.
2847
+ * Builds the default built-in controls configuration (zoom, fullscreen, my
2848
+ * location, map style) from the map API.
2849
+ *
2850
+ * Replaces `useControlsConfig` with a cleaner, flat options structure. Each
2851
+ * control can be disabled individually via `options`. Omitting `options`
2852
+ * enables all controls.
2735
2853
  *
2736
- * Creates control config objects with bound callbacks for each enabled control.
2737
- * The Controls component uses this configuration to render controls.
2738
- * Consumers can compose custom stacks from the named built-in handles.
2854
+ * Returns a `BuiltInControlsConfig` a `ControlsConfig` enriched with named
2855
+ * handles for each built-in control so consumers can reference them without
2856
+ * searching by id.
2857
+ *
2858
+ * @example
2859
+ * ```tsx
2860
+ * const builtIn = useDefaultControls(api);
2861
+ * return <Controls controls={builtIn} api={api} />;
2862
+ * ```
2863
+ * @example Disable specific controls
2864
+ * ```tsx
2865
+ * const builtIn = useDefaultControls(api, {
2866
+ * navigation: { myLocation: { enabled: false } },
2867
+ * });
2868
+ * ```
2739
2869
  */
2740
- const useControlsConfig = ({ options, api }) => {
2870
+ const useDefaultControls = (api, options) => {
2741
2871
  const [t] = useTranslation();
2742
2872
  const { getPosition } = reactCoreHooks.useGeolocation();
2743
- // Fullscreen state and callbacks derived from the container ref
2744
2873
  const isFullscreen = reactComponents.useIsFullscreen();
2745
2874
  const requestFullscreen = react.useCallback(() => {
2746
2875
  void api.containerRef.current?.requestFullscreen();
@@ -2748,20 +2877,18 @@ const useControlsConfig = ({ options, api }) => {
2748
2877
  const exitFullscreen = react.useCallback(() => {
2749
2878
  void document.exitFullscreen();
2750
2879
  }, []);
2751
- const zoomControl = react.useMemo(() => options?.controls?.zoom?.enabled === false
2880
+ const zoomControl = react.useMemo(() => options?.navigation?.zoom?.enabled === false
2752
2881
  ? undefined
2753
2882
  : {
2754
- // Zoom stepper control - uses relative zoom(delta)
2755
2883
  type: "stepper",
2756
2884
  id: "zoom",
2757
2885
  label: t("controls.zoom"),
2758
2886
  increment: () => void api.actions.zoomBy(1),
2759
2887
  decrement: () => void api.actions.zoomBy(-1),
2760
- }, [api.actions, options?.controls?.zoom?.enabled, t]);
2761
- const fullscreenControl = react.useMemo(() => options?.controls?.fullscreen?.enabled === false
2888
+ }, [api.actions, options?.navigation?.zoom?.enabled, t]);
2889
+ const fullscreenControl = react.useMemo(() => options?.navigation?.fullscreen?.enabled === false
2762
2890
  ? undefined
2763
2891
  : {
2764
- // Fullscreen toggle control
2765
2892
  type: "toggle",
2766
2893
  id: "fullscreen",
2767
2894
  icon: { on: "ArrowsPointingIn", off: "ArrowsPointingOut" },
@@ -2776,11 +2903,10 @@ const useControlsConfig = ({ options, api }) => {
2776
2903
  }
2777
2904
  },
2778
2905
  "data-testid": "map-fullscreen-button",
2779
- }, [exitFullscreen, isFullscreen, options?.controls?.fullscreen?.enabled, requestFullscreen, t]);
2780
- const myLocationControl = react.useMemo(() => options?.controls?.myLocation?.enabled === false
2906
+ }, [exitFullscreen, isFullscreen, options?.navigation?.fullscreen?.enabled, requestFullscreen, t]);
2907
+ const myLocationControl = react.useMemo(() => options?.navigation?.myLocation?.enabled === false
2781
2908
  ? undefined
2782
2909
  : {
2783
- // My location button - zooms the map to a ~2x2 km box around the user's position
2784
2910
  type: "button",
2785
2911
  id: "my-location",
2786
2912
  icon: "Gps",
@@ -2790,7 +2916,6 @@ const useControlsConfig = ({ options, api }) => {
2790
2916
  if (pos === null)
2791
2917
  return;
2792
2918
  const [longitude, latitude] = pos;
2793
- // ~1 km in degrees: lat is constant, lon varies by cos(lat)
2794
2919
  const latOffset = 1 / 111;
2795
2920
  const lonOffset = 1 / (111 * Math.cos((latitude * Math.PI) / 180));
2796
2921
  void api.actions.fitBounds([
@@ -2802,11 +2927,10 @@ const useControlsConfig = ({ options, api }) => {
2802
2927
  });
2803
2928
  },
2804
2929
  "data-testid": "map-my-location-button",
2805
- }, [api.actions, getPosition, options?.controls?.myLocation?.enabled, t]);
2930
+ }, [api.actions, getPosition, options?.navigation?.myLocation?.enabled, t]);
2806
2931
  const navigationControls = react.useMemo(() => defineControlStack([zoomControl, fullscreenControl, myLocationControl]), [fullscreenControl, myLocationControl, zoomControl]);
2807
- // Map appearance controls (settings area) - live mini-map previews for switching theme/style
2808
2932
  const mapAppearanceControls = useMapAppearanceControls({ api });
2809
- const mapStyleControl = react.useMemo(() => options?.controls?.mapStyle?.enabled === false ? undefined : mapAppearanceControls[0], [mapAppearanceControls, options?.controls?.mapStyle?.enabled]);
2933
+ const mapStyleControl = react.useMemo(() => options?.settings?.mapStyle?.enabled === false ? undefined : mapAppearanceControls[0], [mapAppearanceControls, options?.settings?.mapStyle?.enabled]);
2810
2934
  const settingsControls = react.useMemo(() => defineControlStack([mapStyleControl]), [mapStyleControl]);
2811
2935
  return react.useMemo(() => ({
2812
2936
  navigation: {
@@ -2819,107 +2943,112 @@ const useControlsConfig = ({ options, api }) => {
2819
2943
  controls: settingsControls,
2820
2944
  mapStyle: mapStyleControl,
2821
2945
  },
2822
- tools: null,
2946
+ tools: { controls: [] },
2823
2947
  statistics: { controls: [] },
2824
2948
  }), [fullscreenControl, mapStyleControl, myLocationControl, navigationControls, settingsControls, zoomControl]);
2825
2949
  };
2826
2950
 
2827
2951
  /**
2828
- * Internal component that builds and renders controls.
2829
- * Separated to allow Suspense boundary in parent.
2830
- */
2831
- const ControlsContent = ({ api, className, controlsConfigProp }) => {
2832
- const [t] = useTranslation();
2833
- // Measure container to derive available space and responsive mode
2834
- const containerRef = react.useRef(null);
2835
- const { ref: measureRef, geometry } = reactComponents.useMeasure();
2836
- const mergedRef = reactComponents.useMergeRefs([containerRef, measureRef]);
2837
- const builtControlsConfig = useControlsConfig({ options: api.options, api });
2838
- const baseControlsConfig = controlsConfigProp ?? builtControlsConfig;
2839
- const { displayAnnotations, isExiting, shouldRender: shouldRenderAnnotations, } = useAnnotationPresence(api.annotations);
2840
- const skipContainerBreakpoints = baseControlsConfig.navigation.controls.length === 0 &&
2841
- baseControlsConfig.settings.controls.length === 0 &&
2842
- baseControlsConfig.statistics.controls.length === 0 &&
2843
- !shouldRenderAnnotations;
2844
- const breakpoints = reactComponents.useContainerBreakpoints(containerRef, { skip: skipContainerBreakpoints });
2845
- const containerHeight = geometry?.height ?? Infinity;
2846
- const safeAreaLayout = react.useContext(SafeAreaLayoutContext);
2847
- const measuredAvailableSpace = breakpoints.isSm && containerHeight >= HEIGHT_COMFORTABLE_THRESHOLD ? "comfortable" : "constrained";
2848
- const measuredResponsiveMode = deriveResponsiveMode(breakpoints.isSm, breakpoints.isMd, containerHeight);
2849
- const availableSpace = safeAreaLayout?.availableSpace ?? measuredAvailableSpace;
2850
- const responsiveMode = safeAreaLayout?.responsiveMode ?? measuredResponsiveMode;
2851
- // Menus use modals only in collapsed mode (narrowest containers); popovers otherwise
2852
- const menuPresentation = responsiveMode === "collapsed" ? "modal" : "popover";
2853
- // Derive portal ID from the map container's stable ID (set by createMapComponent)
2854
- const portalId = api.containerRef.current?.id;
2855
- // Geolocation position for region-appropriate globe icon (updated if the user clicks "My Location")
2856
- const { position } = reactCoreHooks.useGeolocation();
2857
- const navigationIcon = position !== null ? longitudeToGlobeIcon(position[0]) : "GlobeEuropeAfrica";
2858
- // Category metadata: icon + translated label for menu triggers, tooltips, and section headers
2859
- const categoryMetadata = react.useMemo(() => ({
2860
- navigation: { icon: navigationIcon, label: t("controls.category.navigation") },
2861
- settings: { icon: "Cog6Tooth", label: t("controls.category.settings") },
2862
- tools: { icon: "Wrench", label: t("controls.category.tools") },
2863
- statistics: { icon: "ChartBarSquare", label: t("controls.category.statistics") },
2864
- allControls: { icon: "EllipsisHorizontal", label: t("controls.allControls") },
2865
- }), [t, navigationIcon]);
2866
- // Apply responsive collapsing -- restructures config based on available space
2867
- const controlsConfig = react.useMemo(() => collapseControls(baseControlsConfig, responsiveMode, categoryMetadata), [baseControlsConfig, responsiveMode, categoryMetadata]);
2868
- react.useLayoutEffect(() => api.annotations.claimRenderer(), [api.annotations]);
2869
- const { navigation, settings, statistics } = controlsConfig;
2870
- const hasNavigationControls = navigation.controls.length > 0;
2871
- const hasSettingsControls = settings.controls.length > 0;
2872
- const hasStatisticsControls = statistics.controls.length > 0;
2873
- const hasStatisticsAreaContent = shouldRenderAnnotations || hasStatisticsControls;
2874
- if (!hasNavigationControls && !hasSettingsControls && !hasStatisticsAreaContent) {
2875
- return null;
2876
- }
2877
- return (jsxRuntime.jsxs("div", { className: tailwindMerge.twMerge(
2878
- // Overlay positioning within Map's ZStack grid
2879
- "col-start-1 row-start-1",
2880
- // Pointer events only on children, not the overlay itself
2881
- "pointer-events-none",
2882
- // Layout grid for semantic areas
2883
- "grid h-full w-full",
2884
- // Responsive grid: navigation bottom-right, settings top-left, statistics bottom-left
2885
- "grid-cols-[1fr_auto] grid-rows-[auto_1fr_auto]",
2886
- // Isolate creates a new stacking context to avoid z-index issues
2887
- "isolate", className), "data-testid": "map-controls", ref: mergedRef, children: [hasNavigationControls ? (jsxRuntime.jsx("div", { className: "pointer-events-auto col-start-2 row-start-3 flex flex-col gap-1", children: navigation.controls.map(control => (jsxRuntime.jsx(ControlRenderer, { availableSpace: availableSpace, containerRef: containerRef, control: control, customPortalId: portalId, menuPresentation: menuPresentation }, control.id))) })) : null, hasSettingsControls ? (jsxRuntime.jsx("div", { className: "pointer-events-auto col-start-1 row-start-1 flex w-fit flex-col gap-1", "data-testid": "map-controls-settings", children: settings.controls.map(control => (jsxRuntime.jsx(ControlRenderer, { availableSpace: availableSpace, containerRef: containerRef, control: control, customPortalId: portalId, menuPresentation: menuPresentation }, control.id))) })) : null, hasStatisticsAreaContent ? (jsxRuntime.jsxs("div", { className: tailwindMerge.twMerge("pointer-events-none col-start-1 row-start-3 flex max-h-full max-w-[20rem] flex-col items-start justify-end gap-1 overflow-visible", !hasStatisticsControls && "self-end"), "data-testid": "map-controls-statistics", children: [shouldRenderAnnotations ? (jsxRuntime.jsx("div", { className: "pointer-events-auto", "data-testid": "map-controls-statistics-annotations", children: jsxRuntime.jsx(AnnotationStack, { annotations: displayAnnotations, isExiting: isExiting }) })) : null, hasStatisticsControls ? (jsxRuntime.jsx("div", { className: "pointer-events-auto flex max-h-full w-fit max-w-full flex-col gap-1 overflow-auto", "data-testid": "map-controls-statistics-controls", children: statistics.controls.map(control => (jsxRuntime.jsx(ControlRenderer, { availableSpace: availableSpace, containerRef: containerRef, control: control, customPortalId: portalId, menuPresentation: menuPresentation }, control.id))) })) : null] })) : null] }));
2888
- };
2889
- /**
2890
- * Map controls component that renders navigation and settings controls.
2891
- *
2892
- * Builds controls configuration internally using useControlsConfig hook.
2893
- * Uses semantic areas (navigation, settings, tools, statistics) for organization.
2894
- * All layout decisions are owned by this component for responsive positioning.
2895
- *
2896
- * Inherits layout decisions from the surrounding SafeAreaOverlay when rendered
2897
- * inside <Map>, and falls back to its own measurement when rendered standalone.
2952
+ * Convenience wrapper that calls `useDefaultControls` and passes the result
2953
+ * directly to `<Controls>`.
2898
2954
  *
2899
- * Uses Suspense internally to handle translation loading without blocking the map.
2955
+ * Use this for the common case where only the built-in controls (zoom,
2956
+ * fullscreen, my location, map style) are needed, with no custom additions.
2957
+ * For custom control stacks, call `useDefaultControls` + `useControls` (or
2958
+ * `composeControlAreas`) and pass the result to `<Controls>` directly.
2900
2959
  *
2901
2960
  * @example
2902
2961
  * ```tsx
2903
- * import { useMap, Controls } from "@trackunit/react-map";
2962
+ * import { useMap, DefaultControls } from "@trackunit/react-map";
2904
2963
  * import { googleMapsAdapter } from "@trackunit/react-map-adapter-google";
2905
2964
  *
2906
- * const [Map, api] = useMap(googleMapsAdapter({ apiKey }));
2965
+ * const MyMap = () => {
2966
+ * const [Map, api] = useMap(googleMapsAdapter({ apiKey }));
2907
2967
  *
2908
- * return (
2909
- * <Map>
2910
- * <Controls api={api} />
2911
- * </Map>
2912
- * );
2968
+ * return (
2969
+ * <Map>
2970
+ * <DefaultControls api={api} />
2971
+ * </Map>
2972
+ * );
2973
+ * };
2913
2974
  * ```
2914
2975
  */
2915
- const ControlsInner = ({ api, className, controlsConfig: controlsConfigProp }) => {
2916
- // Wrap in Suspense to handle useTranslation loading without blocking parent components
2917
- return (jsxRuntime.jsx(react.Suspense, { fallback: null, children: jsxRuntime.jsx(ControlsContent, { api: api, className: className, controlsConfigProp: controlsConfigProp }) }));
2976
+ const DefaultControls = ({ api, options, className }) => {
2977
+ const controls = useDefaultControls(api, options);
2978
+ return jsxRuntime.jsx(Controls, { api: api, className: className, controls: controls });
2979
+ };
2980
+
2981
+ // ============================================================================
2982
+ // Internal helpers
2983
+ // ============================================================================
2984
+ /**
2985
+ * Applies prepend/append modifiers to a single area section.
2986
+ * Returns the original section reference unchanged when no modifiers are given.
2987
+ */
2988
+ const applyAreaModifiers = (section, area) => {
2989
+ const { prepend, append } = area;
2990
+ const hasPrepend = prepend !== undefined && prepend.length > 0;
2991
+ const hasAppend = append !== undefined && append.length > 0;
2992
+ if (!hasPrepend && !hasAppend)
2993
+ return section;
2994
+ return {
2995
+ ...section,
2996
+ controls: defineControlStack([...(prepend ?? []), ...section.controls, ...(append ?? [])]),
2997
+ };
2998
+ };
2999
+ const CATEGORY_KEYS = ["navigation", "settings", "tools", "statistics"];
3000
+ // ============================================================================
3001
+ // Hook
3002
+ // ============================================================================
3003
+ /**
3004
+ * Merges per-area `prepend`/`append` modifier lists into a base `ControlsConfig`.
3005
+ *
3006
+ * Memoizes internally — stable references are returned when neither `base` nor
3007
+ * `areas` has changed. Modifiers are applied via `defineControlStack`, which
3008
+ * filters `undefined` entries and rejects duplicate control ids.
3009
+ *
3010
+ * When `areas` is omitted the `base` reference is returned as-is.
3011
+ *
3012
+ * @example
3013
+ * ```ts
3014
+ * const builtIn = useDefaultControls(api);
3015
+ * const controls = useControls(builtIn, {
3016
+ * navigation: { append: [layerNavControl] },
3017
+ * settings: { prepend: [layerSettingsControl] },
3018
+ * });
3019
+ * ```
3020
+ */
3021
+ const useControls = (base, areas) => {
3022
+ return react.useMemo(() => {
3023
+ if (areas === undefined)
3024
+ return base;
3025
+ let hasChanges = false;
3026
+ const updated = {
3027
+ navigation: base.navigation,
3028
+ settings: base.settings,
3029
+ tools: base.tools,
3030
+ statistics: base.statistics,
3031
+ };
3032
+ for (const key of CATEGORY_KEYS) {
3033
+ const area = areas[key];
3034
+ if (area === undefined)
3035
+ continue;
3036
+ const modified = applyAreaModifiers(base[key], area);
3037
+ if (modified !== base[key]) {
3038
+ updated[key] = modified;
3039
+ hasChanges = true;
3040
+ }
3041
+ }
3042
+ if (!hasChanges)
3043
+ return base;
3044
+ return {
3045
+ navigation: updated.navigation,
3046
+ settings: updated.settings,
3047
+ tools: updated.tools,
3048
+ statistics: updated.statistics,
3049
+ };
3050
+ }, [base, areas]);
2918
3051
  };
2919
- // `api` identity is stable between camera events since useMap only re-runs its useMemo
2920
- // when MapStatus (isReady, appearance, etc.) changes — not on pan/zoom. Default React.memo
2921
- // shallow equality is sufficient; no custom comparator needed.
2922
- const Controls = react.memo(ControlsInner);
2923
3052
 
2924
3053
  const MARKER_SPRING_CONFIG = {
2925
3054
  stiffness: 380,
@@ -4592,7 +4721,7 @@ const useLayerReady = (loading) => {
4592
4721
  // ============================================================================
4593
4722
  // Constants
4594
4723
  // ============================================================================
4595
- const EMPTY_CONTROLS$4 = [];
4724
+ const EMPTY_CONTROLS$3 = {};
4596
4725
  // ============================================================================
4597
4726
  // Hook
4598
4727
  // ============================================================================
@@ -4632,7 +4761,7 @@ const useImageOverlay = (options) => {
4632
4761
  ready: layerReady.ready,
4633
4762
  getBounds,
4634
4763
  counts,
4635
- controls: controls ?? EMPTY_CONTROLS$4,
4764
+ controls: controls ?? EMPTY_CONTROLS$3,
4636
4765
  fitParticipation,
4637
4766
  layerType: "image-overlay",
4638
4767
  url,
@@ -4913,7 +5042,7 @@ const createLazyGetter = (compute) => {
4913
5042
  // ============================================================================
4914
5043
  // Constants
4915
5044
  // ============================================================================
4916
- const EMPTY_CONTROLS$3 = [];
5045
+ const EMPTY_CONTROLS$2 = {};
4917
5046
  const EMPTY_FEATURES$1 = { type: "FeatureCollection", features: [] };
4918
5047
  // ============================================================================
4919
5048
  // Internal helpers
@@ -5001,7 +5130,7 @@ const useRoute = (options) => {
5001
5130
  ready: layerReady.ready,
5002
5131
  getBounds,
5003
5132
  counts,
5004
- controls: controls ?? EMPTY_CONTROLS$3,
5133
+ controls: controls ?? EMPTY_CONTROLS$2,
5005
5134
  fitParticipation,
5006
5135
  layerType: "route",
5007
5136
  features,
@@ -5191,7 +5320,12 @@ const interactionReducer = (state, action) => {
5191
5320
  // ============================================================================
5192
5321
  // Constants
5193
5322
  // ============================================================================
5194
- const EMPTY_CONTROLS$2 = [];
5323
+ const EMPTY_AREA_CONTROLS = {
5324
+ navigation: [],
5325
+ settings: [],
5326
+ tools: [],
5327
+ statistics: [],
5328
+ };
5195
5329
  const RESOLVED_PROMISE = Promise.resolve();
5196
5330
  // ============================================================================
5197
5331
  // Hook
@@ -5257,17 +5391,28 @@ const useLayers = (api, layers) => {
5257
5391
  const promises = layers.map(layer => layer.ready);
5258
5392
  return Promise.all(promises).then(() => undefined);
5259
5393
  }, [layers]);
5260
- // ---- Controls (aggregated from all layers) ----
5394
+ // ---- Controls (aggregated per area from all layers) ----
5261
5395
  const controls = react.useMemo(() => {
5262
- const allControls = [];
5396
+ const areaKeys = ["navigation", "settings", "tools", "statistics"];
5397
+ const areas = {
5398
+ navigation: [],
5399
+ settings: [],
5400
+ tools: [],
5401
+ statistics: [],
5402
+ };
5403
+ let hasAny = false;
5263
5404
  for (const layer of layers) {
5264
- if (layer.controls.length > 0) {
5265
- allControls.push(...layer.controls);
5405
+ for (const key of areaKeys) {
5406
+ const areaControls = layer.controls[key];
5407
+ if (areaControls !== undefined && areaControls.length > 0) {
5408
+ areas[key].push(...areaControls);
5409
+ hasAny = true;
5410
+ }
5266
5411
  }
5267
5412
  }
5268
- if (allControls.length === 0)
5269
- return EMPTY_CONTROLS$2;
5270
- return allControls;
5413
+ if (!hasAny)
5414
+ return EMPTY_AREA_CONTROLS;
5415
+ return areas;
5271
5416
  }, [layers]);
5272
5417
  // ---- Build the return object ----
5273
5418
  return react.useMemo(() => ({
@@ -5483,7 +5628,7 @@ const resolveServerClusters = (cluster, data, getId, resolveGroups, viewportCtx)
5483
5628
  // Empty constants (stable references)
5484
5629
  // ============================================================================
5485
5630
  const EMPTY_FEATURES = { type: "FeatureCollection", features: [] };
5486
- const EMPTY_CONTROLS$1 = [];
5631
+ const EMPTY_CONTROLS$1 = {};
5487
5632
  /**
5488
5633
  * Filter items to only those with valid positions and extract id + position.
5489
5634
  * Invalid positions (e.g. latitude out of range) are skipped. Logs once when any are filtered.
@@ -6184,7 +6329,7 @@ const buildMultiPartDecorations = (feature, strokeColor, pointRadius, strokeWidt
6184
6329
  // ============================================================================
6185
6330
  // Constants
6186
6331
  // ============================================================================
6187
- const EMPTY_CONTROLS = [];
6332
+ const EMPTY_CONTROLS = {};
6188
6333
  const EMPTY_STYLE = {};
6189
6334
  // ============================================================================
6190
6335
  // Internal helpers
@@ -9255,285 +9400,165 @@ const ShapeAnnotationLabel = ({ geometry, color, label, theme, isHovered = false
9255
9400
  return jsxRuntime.jsx("div", { className: "pointer-events-none", children: pill });
9256
9401
  };
9257
9402
 
9258
- const markerStateFromParams = (params) => {
9259
- if ("markerState" in params) {
9260
- return params.markerState;
9261
- }
9262
- const input = {
9263
- selected: params.selected,
9264
- hovered: params.hovered,
9265
- expandedRank: params.expandedRank,
9266
- };
9267
- return resolveMarkerState(input);
9268
- };
9269
- const stackPhaseForMarkerState = (markerState) => {
9270
- if (markerState === "selected")
9271
- return "selected";
9272
- if (markerState === "hovered")
9273
- return "hovered";
9274
- if (markerState === "expanded")
9275
- return "expanded";
9276
- return "idle";
9277
- };
9278
9403
  /**
9279
- * Maps `<MapMarker>` presentation + state to `DomPortalStackingResult` for use with
9280
- * `markerRender.resolveDomPortalStacking` in `<Layers>`.
9404
+ * Build-your-own hook: stable reference to the cluster count formatter.
9281
9405
  *
9282
- * Pass either `expandedRank` (for `resolveMarkerState`) or a pre-resolved `markerState`
9283
- * when the expanded state is data-driven and does not use `expandedRank` (for example, story helpers).
9284
- */
9285
- const resolveMapMarkerDomPortalStacking = (params) => {
9286
- const { hasStick, isMounting, labelVisible } = params;
9287
- if (isMounting === true) {
9288
- return { geometry: "circle", phase: "idle" };
9289
- }
9290
- const markerState = markerStateFromParams(params);
9291
- const form = resolveMarkerForm(markerState, labelVisible);
9292
- const geometry = hasStick === true ? "stick" : form === "pill" ? "pill" : "circle";
9293
- return {
9294
- geometry,
9295
- phase: stackPhaseForMarkerState(markerState),
9296
- };
9297
- };
9298
-
9299
- /**
9300
- * Build-your-own hook: stable references to the full adaptive marker kit.
9301
- *
9302
- * Returns the complete set of primitives needed to hand-roll an adaptive marker
9303
- * layer (DOM ↔ symbol mode switching, zoom-based sizing, portal stacking) while
9304
- * staying consistent with `useMarkers`'s built-in rendering pipeline.
9406
+ * Returns `formatClusterCount` for consumers building custom cluster markers
9407
+ * that need consistent `"1.2k / 3.4m"` formatting without reimplementing
9408
+ * the truncation rules.
9305
9409
  *
9306
9410
  * @example
9307
9411
  * ```tsx
9308
- * const {
9309
- * pickRenderMedium, pickMarkerSize, pickSymbolDescriptor,
9310
- * resolveAdaptiveMarkerState, resolveMapMarkerDomPortalStacking,
9311
- * } = useAdaptiveMarkerHelpers();
9312
- *
9313
- * // Inside your adaptive render callback:
9314
- * const medium = pickRenderMedium(ctx);
9315
- * if (medium === "symbol") return pickSymbolDescriptor(color, theme, ctx);
9316
- * const size = pickMarkerSize(ctx);
9317
- * const state = resolveAdaptiveMarkerState({ selected, hovered });
9412
+ * const { formatClusterCount } = useClusterCountFormat();
9413
+ * // Inside your cluster render:
9414
+ * return <div>{formatClusterCount(clusterCount)}</div>;
9318
9415
  * ```
9319
9416
  */
9320
- const useAdaptiveMarkerHelpers = () => {
9417
+ const useClusterCountFormat = () => {
9321
9418
  return react.useMemo(() => ({
9322
- pickMarkerSize,
9323
- pickRenderMedium,
9324
- pickSymbolDescriptor,
9325
- pickSymbolDiameter,
9326
- resolveAdaptiveMarkerState,
9327
- downgradeMarkerDomSize,
9328
- upgradeMarkerDomSize,
9329
- resolveMapMarkerDomPortalStacking,
9330
- DEFAULT_MARKER_SIZE_BREAKPOINTS,
9331
- MARKER_DISC_BORDER_WIDTH_PX,
9419
+ formatClusterCount,
9332
9420
  }), []);
9333
9421
  };
9334
9422
 
9335
9423
  /**
9336
- * Build-your-own hook: stable reference to the `fitFeatureBounds` map action.
9424
+ * Pure function that builds a `ControlsConfig` from per-area control stacks.
9337
9425
  *
9338
- * Returns the `fitFeatureBounds` helper for consumers who need to programmatically
9339
- * zoom/fit the viewport to a GeoJSON geometry for example in click handlers that
9340
- * should navigate to a selected shape or annotation.
9426
+ * Each area in `areas` is passed through `defineControlStack` (which filters
9427
+ * `undefined` entries and rejects duplicate ids). Omitted areas default to
9428
+ * `{ controls: [] }`.
9341
9429
  *
9342
9430
  * @example
9343
- * ```tsx
9344
- * const { fitFeatureBounds } = useFitFeatureBounds();
9345
- * // On click:
9346
- * fitFeatureBounds(api, feature.geometry);
9347
- * ```
9348
- */
9349
- const useFitFeatureBounds = () => {
9350
- return react.useMemo(() => ({
9351
- fitFeatureBounds,
9352
- }), []);
9353
- };
9354
-
9355
- const PADDING_PX = 12;
9356
- /**
9357
- * Default auto-pan resolver: pans the minimum amount to give the panel the
9358
- * best possible screen space. Handles two cases:
9359
- *
9360
- * 1. **Clipping** — panel bleeds outside the map container on any side. Pans
9361
- * to clear the bleed with 12 px of breathing room.
9362
- *
9363
- * 2. **Scroll-squish** — panel is within the container but shift-pressed against
9364
- * an edge, so content overflows and the panel scrolls. Pans to give the panel
9365
- * room to grow, up to the CSS `max-height` cap (`panelCssMaxHeight`).
9366
- *
9367
- * Returns `null` when the panel already fits without scrolling and without
9368
- * clipping — meaning no pan is needed.
9369
- *
9370
- * Pass as `resolveAutoPan` to {@link usePanel} for the standard behavior:
9371
- *
9372
- * ```tsx
9373
- * usePanel(api, {
9374
- * id: "my-panel",
9375
- * open: isOpen,
9376
- * resolveAutoPan: defaultAutoPanResolver,
9377
- * children: <MyPanelContent />,
9431
+ * ```ts
9432
+ * const controls = composeControlAreas({
9433
+ * navigation: [builtIn.navigation.zoom, builtIn.navigation.fullscreen],
9434
+ * settings: [builtIn.settings.mapStyle],
9378
9435
  * });
9379
9436
  * ```
9380
9437
  */
9381
- const defaultAutoPanResolver = (ctx) => {
9382
- const { clipping, placement, panelRect, anchorRect, panelScrollHeight, panelCssMaxHeight } = ctx;
9383
- // --- Clipping: clear overflow past container edges ---
9384
- const x = clipping.right > 0 ? clipping.right + PADDING_PX : clipping.left > 0 ? -(clipping.left + PADDING_PX) : 0;
9385
- const clippingY = clipping.bottom > 0 ? clipping.bottom + PADDING_PX : clipping.top > 0 ? -(clipping.top + PADDING_PX) : 0;
9386
- // --- Scroll-squish: pan to give the panel room to stop scrolling.
9387
- // Cap at panelCssMaxHeight — panning further cannot reduce scrolling.
9388
- const squishAmount = Math.max(0, Math.min(panelScrollHeight, panelCssMaxHeight) - panelRect.height);
9389
- let squishY = 0;
9390
- if (squishAmount > 0) {
9391
- if (placement.startsWith("top")) {
9392
- // Panel is above the anchor. Move anchor down (negative y) to open space above.
9393
- squishY = -(squishAmount + PADDING_PX);
9394
- }
9395
- else if (placement.startsWith("bottom")) {
9396
- // Panel is below the anchor. Move anchor up (positive y) to open space below.
9397
- squishY = squishAmount + PADDING_PX;
9398
- }
9399
- else {
9400
- // left/right placements: panel is vertically centered on the anchor.
9401
- // Infer which edge shift pressed the panel against by comparing the
9402
- // panel's natural center position to its actual rendered top.
9403
- const anchorCenterY = anchorRect.top + anchorRect.height / 2;
9404
- const naturalPanelTop = anchorCenterY - panelRect.height / 2;
9405
- const shiftDelta = panelRect.top - naturalPanelTop;
9406
- if (shiftDelta > 1) {
9407
- // Shift pressed panel down (anchor near top) — move anchor down.
9408
- squishY = -(squishAmount + PADDING_PX);
9409
- }
9410
- else if (shiftDelta < -1) {
9411
- // Shift pressed panel up (anchor near bottom) — move anchor up.
9412
- squishY = squishAmount + PADDING_PX;
9413
- }
9414
- // shiftDelta ≈ 0: squish is from CSS max-height alone, not position — skip.
9415
- }
9416
- }
9417
- // After shift, clippingY and squishY are mutually exclusive on the same axis
9418
- // (shift keeps the panel within container bounds). clippingY takes priority as
9419
- // a defensive fallback for the edge case where both are non-zero.
9420
- const y = clippingY || squishY;
9421
- if (x === 0 && y === 0)
9422
- return null;
9423
- return { x, y, restoreOnDismiss: true };
9424
- };
9438
+ const composeControlAreas = (areas) => ({
9439
+ navigation: { controls: defineControlStack(areas.navigation ?? []) },
9440
+ settings: { controls: defineControlStack(areas.settings ?? []) },
9441
+ tools: { controls: defineControlStack(areas.tools ?? []) },
9442
+ statistics: { controls: defineControlStack(areas.statistics ?? []) },
9443
+ });
9425
9444
 
9426
9445
  /**
9427
- * Build-your-own hook: stable reference to the default auto-pan resolver.
9446
+ * Build-your-own hook: stable references to control composition helpers.
9428
9447
  *
9429
- * Returns `defaultAutoPanResolver` for consumers who want to pass it to
9430
- * `usePanel({ resolveAutoPan })` or use it as a baseline to wrap/replace
9431
- * with custom logic.
9448
+ * Returns `defineControlStack` and `composeControlAreas` for consumers building
9449
+ * custom control layouts without duplicating the stack-ordering logic already
9450
+ * used by `Controls`.
9432
9451
  *
9433
9452
  * @example
9434
9453
  * ```tsx
9435
- * const { defaultAutoPanResolver } = useAutoPanResolver();
9436
- * usePanel(api, {
9437
- * id: "my-panel",
9438
- * open: isOpen,
9439
- * resolveAutoPan: defaultAutoPanResolver,
9440
- * children: <MyContent />,
9454
+ * const { defineControlStack, composeControlAreas } = useControlStack();
9455
+ * const controls = composeControlAreas({
9456
+ * navigation: [builtIn.navigation.zoom, builtIn.navigation.fullscreen],
9457
+ * settings: [builtIn.settings.mapStyle],
9441
9458
  * });
9442
9459
  * ```
9443
9460
  */
9444
- const useAutoPanResolver = () => {
9461
+ const useControlStack = () => {
9445
9462
  return react.useMemo(() => ({
9446
- defaultAutoPanResolver,
9463
+ defineControlStack,
9464
+ composeControlAreas,
9447
9465
  }), []);
9448
9466
  };
9449
9467
 
9450
9468
  /**
9451
- * Build-your-own hook: stable reference to the cluster count formatter.
9469
+ * Build-your-own hook: stable reference to the `fitFeatureBounds` map action.
9452
9470
  *
9453
- * Returns `formatClusterCount` for consumers building custom cluster markers
9454
- * that need consistent `"1.2k / 3.4m"` formatting without reimplementing
9455
- * the truncation rules.
9471
+ * Returns the `fitFeatureBounds` helper for consumers who need to programmatically
9472
+ * zoom/fit the viewport to a GeoJSON geometry for example in click handlers that
9473
+ * should navigate to a selected shape or annotation.
9456
9474
  *
9457
9475
  * @example
9458
9476
  * ```tsx
9459
- * const { formatClusterCount } = useClusterCountFormat();
9460
- * // Inside your cluster render:
9461
- * return <div>{formatClusterCount(clusterCount)}</div>;
9477
+ * const { fitFeatureBounds } = useFitFeatureBounds();
9478
+ * // On click:
9479
+ * fitFeatureBounds(api, feature.geometry);
9462
9480
  * ```
9463
9481
  */
9464
- const useClusterCountFormat = () => {
9482
+ const useFitFeatureBounds = () => {
9465
9483
  return react.useMemo(() => ({
9466
- formatClusterCount,
9484
+ fitFeatureBounds,
9467
9485
  }), []);
9468
9486
  };
9469
9487
 
9470
- /**
9471
- * Build-your-own hook: stable references to control composition/layout helpers.
9472
- *
9473
- * Returns `defineControlStack` and `collapseControls` for consumers building
9474
- * custom control layouts without duplicating the stack-ordering and responsive-
9475
- * collapse logic already used by `Controls`.
9476
- *
9477
- * @example
9478
- * ```tsx
9479
- * const { defineControlStack, collapseControls } = useControlStack();
9480
- * const controls = defineControlStack([myControl, undefined, otherControl]);
9481
- * const collapsed = collapseControls(config, responsiveMode, metadata);
9482
- * ```
9483
- */
9484
- const useControlStack = () => {
9485
- return react.useMemo(() => ({
9486
- defineControlStack,
9487
- collapseControls,
9488
- }), []);
9488
+ const markerStateFromParams = (params) => {
9489
+ if ("markerState" in params) {
9490
+ return params.markerState;
9491
+ }
9492
+ const input = {
9493
+ selected: params.selected,
9494
+ hovered: params.hovered,
9495
+ expandedRank: params.expandedRank,
9496
+ };
9497
+ return resolveMarkerState(input);
9498
+ };
9499
+ const stackPhaseForMarkerState = (markerState) => {
9500
+ if (markerState === "selected")
9501
+ return "selected";
9502
+ if (markerState === "hovered")
9503
+ return "hovered";
9504
+ if (markerState === "expanded")
9505
+ return "expanded";
9506
+ return "idle";
9489
9507
  };
9490
-
9491
9508
  /**
9492
- * Build-your-own hook: stable references to theme-aware marker color primitives.
9493
- *
9494
- * Returns the `defaultMarkerColorConfig` factory and `resolveMarkerColors` resolver
9495
- * for consumers building custom marker components that need to stay visually
9496
- * consistent with `MapMarker`'s pill/indicator color system.
9509
+ * Maps `<MapMarker>` presentation + state to `DomPortalStackingResult` for use with
9510
+ * `markerRender.resolveDomPortalStacking` in `<Layers>`.
9497
9511
  *
9498
- * @example
9499
- * ```tsx
9500
- * const { defaultMarkerColorConfig, resolveMarkerColors } = useMarkerColors();
9501
- * const config = defaultMarkerColorConfig(theme);
9502
- * const colors = resolveMarkerColors(fillColor, state, theme, config);
9503
- * // Spread colors.cssVars onto the marker root element.
9504
- * ```
9512
+ * Pass either `expandedRank` (for `resolveMarkerState`) or a pre-resolved `markerState`
9513
+ * when the expanded state is data-driven and does not use `expandedRank` (for example, story helpers).
9505
9514
  */
9506
- const useMarkerColors = () => {
9507
- return react.useMemo(() => ({
9508
- defaultMarkerColorConfig,
9509
- resolveMarkerColors,
9510
- MARKER_DARK_PILL,
9511
- MARKER_LIGHT_PILL,
9512
- }), []);
9515
+ const resolveMapMarkerDomPortalStacking = (params) => {
9516
+ const { hasStick, isMounting, labelVisible } = params;
9517
+ if (isMounting === true) {
9518
+ return { geometry: "circle", phase: "idle" };
9519
+ }
9520
+ const markerState = markerStateFromParams(params);
9521
+ const form = resolveMarkerForm(markerState, labelVisible);
9522
+ const geometry = hasStick === true ? "stick" : form === "pill" ? "pill" : "circle";
9523
+ return {
9524
+ geometry,
9525
+ phase: stackPhaseForMarkerState(markerState),
9526
+ };
9513
9527
  };
9514
9528
 
9515
9529
  /**
9516
- * Build-your-own hook: stable references to marker state resolution primitives.
9530
+ * Build-your-own hook: stable references to the full adaptive marker kit.
9517
9531
  *
9518
- * Returns a memoized object of the pure resolver functions for consumers that
9519
- * want to hand-roll their own MapMarker replacement while staying consistent
9520
- * with the library's state priority rules (selected → expanded → hovered → default).
9532
+ * Returns the complete set of primitives needed to hand-roll an adaptive marker
9533
+ * layer (DOM symbol mode switching, zoom-based sizing, portal stacking) while
9534
+ * staying consistent with `useMarkers`'s built-in rendering pipeline.
9521
9535
  *
9522
9536
  * @example
9523
9537
  * ```tsx
9524
- * const { resolveMarkerState, resolveMarkerForm } = useMarkerStateResolvers();
9525
- * const state = resolveMarkerState({ selected, hovered, expandedRank });
9526
- * const form = resolveMarkerForm(state, labelVisible);
9538
+ * const {
9539
+ * pickRenderMedium, pickMarkerSize, pickSymbolDescriptor,
9540
+ * resolveAdaptiveMarkerState, resolveMapMarkerDomPortalStacking,
9541
+ * } = useAdaptiveMarkerHelpers();
9542
+ *
9543
+ * // Inside your adaptive render callback:
9544
+ * const medium = pickRenderMedium(ctx);
9545
+ * if (medium === "symbol") return pickSymbolDescriptor(color, theme, ctx);
9546
+ * const size = pickMarkerSize(ctx);
9547
+ * const state = resolveAdaptiveMarkerState({ selected, hovered });
9527
9548
  * ```
9528
9549
  */
9529
- const useMarkerStateResolvers = () => {
9550
+ const useAdaptiveMarkerHelpers = () => {
9530
9551
  return react.useMemo(() => ({
9531
- resolveMarkerState,
9532
- resolveMarkerForm,
9533
- resolveEffectiveSize,
9534
- resolveMarkerDomSize,
9552
+ pickMarkerSize,
9553
+ pickRenderMedium,
9554
+ pickSymbolDescriptor,
9555
+ pickSymbolDiameter,
9535
9556
  resolveAdaptiveMarkerState,
9536
- MARKER_SIZE_MAP,
9557
+ downgradeMarkerDomSize,
9558
+ upgradeMarkerDomSize,
9559
+ resolveMapMarkerDomPortalStacking,
9560
+ DEFAULT_MARKER_SIZE_BREAKPOINTS,
9561
+ MARKER_DISC_BORDER_WIDTH_PX,
9537
9562
  }), []);
9538
9563
  };
9539
9564
 
@@ -9631,6 +9656,150 @@ const useShapeLabelHelpers = () => {
9631
9656
  }), []);
9632
9657
  };
9633
9658
 
9659
+ /**
9660
+ * Build-your-own hook: stable references to marker state resolution primitives.
9661
+ *
9662
+ * Returns a memoized object of the pure resolver functions for consumers that
9663
+ * want to hand-roll their own MapMarker replacement while staying consistent
9664
+ * with the library's state priority rules (selected → expanded → hovered → default).
9665
+ *
9666
+ * @example
9667
+ * ```tsx
9668
+ * const { resolveMarkerState, resolveMarkerForm } = useMarkerStateResolvers();
9669
+ * const state = resolveMarkerState({ selected, hovered, expandedRank });
9670
+ * const form = resolveMarkerForm(state, labelVisible);
9671
+ * ```
9672
+ */
9673
+ const useMarkerStateResolvers = () => {
9674
+ return react.useMemo(() => ({
9675
+ resolveMarkerState,
9676
+ resolveMarkerForm,
9677
+ resolveEffectiveSize,
9678
+ resolveMarkerDomSize,
9679
+ resolveAdaptiveMarkerState,
9680
+ MARKER_SIZE_MAP,
9681
+ }), []);
9682
+ };
9683
+
9684
+ /**
9685
+ * Build-your-own hook: stable references to theme-aware marker color primitives.
9686
+ *
9687
+ * Returns the `defaultMarkerColorConfig` factory and `resolveMarkerColors` resolver
9688
+ * for consumers building custom marker components that need to stay visually
9689
+ * consistent with `MapMarker`'s pill/indicator color system.
9690
+ *
9691
+ * @example
9692
+ * ```tsx
9693
+ * const { defaultMarkerColorConfig, resolveMarkerColors } = useMarkerColors();
9694
+ * const config = defaultMarkerColorConfig(theme);
9695
+ * const colors = resolveMarkerColors(fillColor, state, theme, config);
9696
+ * // Spread colors.cssVars onto the marker root element.
9697
+ * ```
9698
+ */
9699
+ const useMarkerColors = () => {
9700
+ return react.useMemo(() => ({
9701
+ defaultMarkerColorConfig,
9702
+ resolveMarkerColors,
9703
+ MARKER_DARK_PILL,
9704
+ MARKER_LIGHT_PILL,
9705
+ }), []);
9706
+ };
9707
+
9708
+ const PADDING_PX = 12;
9709
+ /**
9710
+ * Default auto-pan resolver: pans the minimum amount to give the panel the
9711
+ * best possible screen space. Handles two cases:
9712
+ *
9713
+ * 1. **Clipping** — panel bleeds outside the map container on any side. Pans
9714
+ * to clear the bleed with 12 px of breathing room.
9715
+ *
9716
+ * 2. **Scroll-squish** — panel is within the container but shift-pressed against
9717
+ * an edge, so content overflows and the panel scrolls. Pans to give the panel
9718
+ * room to grow, up to the CSS `max-height` cap (`panelCssMaxHeight`).
9719
+ *
9720
+ * Returns `null` when the panel already fits without scrolling and without
9721
+ * clipping — meaning no pan is needed.
9722
+ *
9723
+ * Pass as `resolveAutoPan` to {@link usePanel} for the standard behavior:
9724
+ *
9725
+ * ```tsx
9726
+ * usePanel(api, {
9727
+ * id: "my-panel",
9728
+ * open: isOpen,
9729
+ * resolveAutoPan: defaultAutoPanResolver,
9730
+ * children: <MyPanelContent />,
9731
+ * });
9732
+ * ```
9733
+ */
9734
+ const defaultAutoPanResolver = (ctx) => {
9735
+ const { clipping, placement, panelRect, anchorRect, panelScrollHeight, panelCssMaxHeight } = ctx;
9736
+ // --- Clipping: clear overflow past container edges ---
9737
+ const x = clipping.right > 0 ? clipping.right + PADDING_PX : clipping.left > 0 ? -(clipping.left + PADDING_PX) : 0;
9738
+ const clippingY = clipping.bottom > 0 ? clipping.bottom + PADDING_PX : clipping.top > 0 ? -(clipping.top + PADDING_PX) : 0;
9739
+ // --- Scroll-squish: pan to give the panel room to stop scrolling.
9740
+ // Cap at panelCssMaxHeight — panning further cannot reduce scrolling.
9741
+ const squishAmount = Math.max(0, Math.min(panelScrollHeight, panelCssMaxHeight) - panelRect.height);
9742
+ let squishY = 0;
9743
+ if (squishAmount > 0) {
9744
+ if (placement.startsWith("top")) {
9745
+ // Panel is above the anchor. Move anchor down (negative y) to open space above.
9746
+ squishY = -(squishAmount + PADDING_PX);
9747
+ }
9748
+ else if (placement.startsWith("bottom")) {
9749
+ // Panel is below the anchor. Move anchor up (positive y) to open space below.
9750
+ squishY = squishAmount + PADDING_PX;
9751
+ }
9752
+ else {
9753
+ // left/right placements: panel is vertically centered on the anchor.
9754
+ // Infer which edge shift pressed the panel against by comparing the
9755
+ // panel's natural center position to its actual rendered top.
9756
+ const anchorCenterY = anchorRect.top + anchorRect.height / 2;
9757
+ const naturalPanelTop = anchorCenterY - panelRect.height / 2;
9758
+ const shiftDelta = panelRect.top - naturalPanelTop;
9759
+ if (shiftDelta > 1) {
9760
+ // Shift pressed panel down (anchor near top) — move anchor down.
9761
+ squishY = -(squishAmount + PADDING_PX);
9762
+ }
9763
+ else if (shiftDelta < -1) {
9764
+ // Shift pressed panel up (anchor near bottom) — move anchor up.
9765
+ squishY = squishAmount + PADDING_PX;
9766
+ }
9767
+ // shiftDelta ≈ 0: squish is from CSS max-height alone, not position — skip.
9768
+ }
9769
+ }
9770
+ // After shift, clippingY and squishY are mutually exclusive on the same axis
9771
+ // (shift keeps the panel within container bounds). clippingY takes priority as
9772
+ // a defensive fallback for the edge case where both are non-zero.
9773
+ const y = clippingY || squishY;
9774
+ if (x === 0 && y === 0)
9775
+ return null;
9776
+ return { x, y, restoreOnDismiss: true };
9777
+ };
9778
+
9779
+ /**
9780
+ * Build-your-own hook: stable reference to the default auto-pan resolver.
9781
+ *
9782
+ * Returns `defaultAutoPanResolver` for consumers who want to pass it to
9783
+ * `usePanel({ resolveAutoPan })` or use it as a baseline to wrap/replace
9784
+ * with custom logic.
9785
+ *
9786
+ * @example
9787
+ * ```tsx
9788
+ * const { defaultAutoPanResolver } = useAutoPanResolver();
9789
+ * usePanel(api, {
9790
+ * id: "my-panel",
9791
+ * open: isOpen,
9792
+ * resolveAutoPan: defaultAutoPanResolver,
9793
+ * children: <MyContent />,
9794
+ * });
9795
+ * ```
9796
+ */
9797
+ const useAutoPanResolver = () => {
9798
+ return react.useMemo(() => ({
9799
+ defaultAutoPanResolver,
9800
+ }), []);
9801
+ };
9802
+
9634
9803
  const VIEWPORT_BOUNDS = [-50, -50, 50, 50];
9635
9804
  const INITIAL_STATE = {
9636
9805
  center: [0, 0],
@@ -9806,6 +9975,7 @@ exports.ClusterMarker = ClusterMarker;
9806
9975
  exports.ClusterStick = ClusterStick;
9807
9976
  exports.Controls = Controls;
9808
9977
  exports.DEFAULT_MARKER_SIZE_BREAKPOINTS = DEFAULT_MARKER_SIZE_BREAKPOINTS;
9978
+ exports.DefaultControls = DefaultControls;
9809
9979
  exports.Layers = Layers;
9810
9980
  exports.MARKER_DARK_PILL = MARKER_DARK_PILL;
9811
9981
  exports.MARKER_DISC_BORDER_WIDTH_PX = MARKER_DISC_BORDER_WIDTH_PX;
@@ -9827,7 +9997,8 @@ exports.useCameraIdle = useCameraIdle;
9827
9997
  exports.useCameraState = useCameraState;
9828
9998
  exports.useClusterCountFormat = useClusterCountFormat;
9829
9999
  exports.useControlStack = useControlStack;
9830
- exports.useControlsConfig = useControlsConfig;
10000
+ exports.useControls = useControls;
10001
+ exports.useDefaultControls = useDefaultControls;
9831
10002
  exports.useDirectionIndicator = useDirectionIndicator;
9832
10003
  exports.useExpandedIds = useExpandedIds;
9833
10004
  exports.useFitFeatureBounds = useFitFeatureBounds;