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