@trackunit/react-map 0.0.10 → 0.1.0

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
@@ -6668,7 +6813,7 @@ const noopGuard = (_active) => undefined;
6668
6813
  * suppresses adapter hover-start/hover-end). Call with `false` when the DOM
6669
6814
  * hover ends, allowing adapter events to flow again.
6670
6815
  */
6671
- const useEntityInteraction = ({ layerPort, hover, select, dblClick, }) => {
6816
+ const useEntityInteraction = ({ layerPort, hover, select, dblClick, onClick, }) => {
6672
6817
  const hoverRef = react.useRef(hover);
6673
6818
  react.useEffect(() => {
6674
6819
  hoverRef.current = hover;
@@ -6681,6 +6826,10 @@ const useEntityInteraction = ({ layerPort, hover, select, dblClick, }) => {
6681
6826
  react.useEffect(() => {
6682
6827
  dblClickRef.current = dblClick;
6683
6828
  }, [dblClick]);
6829
+ const onClickRef = react.useRef(onClick);
6830
+ react.useEffect(() => {
6831
+ onClickRef.current = onClick;
6832
+ }, [onClick]);
6684
6833
  const guardRef = react.useRef(noopGuard);
6685
6834
  react.useEffect(() => {
6686
6835
  if (layerPort === null)
@@ -6698,6 +6847,7 @@ const useEntityInteraction = ({ layerPort, hover, select, dblClick, }) => {
6698
6847
  const { type } = event;
6699
6848
  switch (type) {
6700
6849
  case "click":
6850
+ onClickRef.current?.(event.entity);
6701
6851
  selectRef.current(event.entity);
6702
6852
  break;
6703
6853
  case "dblclick":
@@ -8771,7 +8921,7 @@ const applyPortalZIndex = (container, zIndex) => {
8771
8921
  * );
8772
8922
  * ```
8773
8923
  */
8774
- const Layers = ({ layers, api }) => {
8924
+ const Layers = ({ layers, api, onEntityClick, onEntityDblClick }) => {
8775
8925
  const layerPort = useLayerPort();
8776
8926
  const [decorationLayers, setDecorationLayers] = react.useState([]);
8777
8927
  const [viewportStyleOverrides, setViewportStyleOverrides] = react.useState(new Map());
@@ -8805,12 +8955,17 @@ const Layers = ({ layers, api }) => {
8805
8955
  layers.select(entity);
8806
8956
  }
8807
8957
  }, [layers]);
8808
- const handleDblClick = useDblClickFit(api, layers.handles);
8958
+ const fitOnDblClick = useDblClickFit(api, layers.handles);
8959
+ const handleDblClick = react.useCallback((entity) => {
8960
+ onEntityDblClick?.(entity);
8961
+ fitOnDblClick?.(entity);
8962
+ }, [onEntityDblClick, fitOnDblClick]);
8809
8963
  const setDomHoverActive = useEntityInteraction({
8810
8964
  layerPort,
8811
8965
  hover: layers.hover,
8812
8966
  select: coordinatedSelect,
8813
8967
  dblClick: handleDblClick,
8968
+ onClick: onEntityClick,
8814
8969
  });
8815
8970
  const coordinatedHover = react.useCallback((entity) => {
8816
8971
  setDomHoverActive(entity !== null);
@@ -9255,80 +9410,68 @@ const ShapeAnnotationLabel = ({ geometry, color, label, theme, isHovered = false
9255
9410
  return jsxRuntime.jsx("div", { className: "pointer-events-none", children: pill });
9256
9411
  };
9257
9412
 
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
9413
  /**
9279
- * Maps `<MapMarker>` presentation + state to `DomPortalStackingResult` for use with
9280
- * `markerRender.resolveDomPortalStacking` in `<Layers>`.
9414
+ * Build-your-own hook: stable reference to the cluster count formatter.
9281
9415
  *
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).
9416
+ * Returns `formatClusterCount` for consumers building custom cluster markers
9417
+ * that need consistent `"1.2k / 3.4m"` formatting without reimplementing
9418
+ * the truncation rules.
9419
+ *
9420
+ * @example
9421
+ * ```tsx
9422
+ * const { formatClusterCount } = useClusterCountFormat();
9423
+ * // Inside your cluster render:
9424
+ * return <div>{formatClusterCount(clusterCount)}</div>;
9425
+ * ```
9284
9426
  */
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
- };
9427
+ const useClusterCountFormat = () => {
9428
+ return react.useMemo(() => ({
9429
+ formatClusterCount,
9430
+ }), []);
9297
9431
  };
9298
9432
 
9299
9433
  /**
9300
- * Build-your-own hook: stable references to the full adaptive marker kit.
9434
+ * Pure function that builds a `ControlsConfig` from per-area control stacks.
9301
9435
  *
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.
9436
+ * Each area in `areas` is passed through `defineControlStack` (which filters
9437
+ * `undefined` entries and rejects duplicate ids). Omitted areas default to
9438
+ * `{ controls: [] }`.
9305
9439
  *
9306
9440
  * @example
9307
- * ```tsx
9308
- * const {
9309
- * pickRenderMedium, pickMarkerSize, pickSymbolDescriptor,
9310
- * resolveAdaptiveMarkerState, resolveMapMarkerDomPortalStacking,
9311
- * } = useAdaptiveMarkerHelpers();
9441
+ * ```ts
9442
+ * const controls = composeControlAreas({
9443
+ * navigation: [builtIn.navigation.zoom, builtIn.navigation.fullscreen],
9444
+ * settings: [builtIn.settings.mapStyle],
9445
+ * });
9446
+ * ```
9447
+ */
9448
+ const composeControlAreas = (areas) => ({
9449
+ navigation: { controls: defineControlStack(areas.navigation ?? []) },
9450
+ settings: { controls: defineControlStack(areas.settings ?? []) },
9451
+ tools: { controls: defineControlStack(areas.tools ?? []) },
9452
+ statistics: { controls: defineControlStack(areas.statistics ?? []) },
9453
+ });
9454
+
9455
+ /**
9456
+ * Build-your-own hook: stable references to control composition helpers.
9312
9457
  *
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 });
9458
+ * Returns `defineControlStack` and `composeControlAreas` for consumers building
9459
+ * custom control layouts without duplicating the stack-ordering logic already
9460
+ * used by `Controls`.
9461
+ *
9462
+ * @example
9463
+ * ```tsx
9464
+ * const { defineControlStack, composeControlAreas } = useControlStack();
9465
+ * const controls = composeControlAreas({
9466
+ * navigation: [builtIn.navigation.zoom, builtIn.navigation.fullscreen],
9467
+ * settings: [builtIn.settings.mapStyle],
9468
+ * });
9318
9469
  * ```
9319
9470
  */
9320
- const useAdaptiveMarkerHelpers = () => {
9471
+ const useControlStack = () => {
9321
9472
  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,
9473
+ defineControlStack,
9474
+ composeControlAreas,
9332
9475
  }), []);
9333
9476
  };
9334
9477
 
@@ -9352,188 +9495,80 @@ const useFitFeatureBounds = () => {
9352
9495
  }), []);
9353
9496
  };
9354
9497
 
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 />,
9378
- * });
9379
- * ```
9380
- */
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
- }
9498
+ const markerStateFromParams = (params) => {
9499
+ if ("markerState" in params) {
9500
+ return params.markerState;
9416
9501
  }
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
- };
9425
-
9426
- /**
9427
- * Build-your-own hook: stable reference to the default auto-pan resolver.
9428
- *
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.
9432
- *
9433
- * @example
9434
- * ```tsx
9435
- * const { defaultAutoPanResolver } = useAutoPanResolver();
9436
- * usePanel(api, {
9437
- * id: "my-panel",
9438
- * open: isOpen,
9439
- * resolveAutoPan: defaultAutoPanResolver,
9440
- * children: <MyContent />,
9441
- * });
9442
- * ```
9443
- */
9444
- const useAutoPanResolver = () => {
9445
- return react.useMemo(() => ({
9446
- defaultAutoPanResolver,
9447
- }), []);
9502
+ const input = {
9503
+ selected: params.selected,
9504
+ hovered: params.hovered,
9505
+ expandedRank: params.expandedRank,
9506
+ };
9507
+ return resolveMarkerState(input);
9448
9508
  };
9449
-
9450
- /**
9451
- * Build-your-own hook: stable reference to the cluster count formatter.
9452
- *
9453
- * Returns `formatClusterCount` for consumers building custom cluster markers
9454
- * that need consistent `"1.2k / 3.4m"` formatting without reimplementing
9455
- * the truncation rules.
9456
- *
9457
- * @example
9458
- * ```tsx
9459
- * const { formatClusterCount } = useClusterCountFormat();
9460
- * // Inside your cluster render:
9461
- * return <div>{formatClusterCount(clusterCount)}</div>;
9462
- * ```
9463
- */
9464
- const useClusterCountFormat = () => {
9465
- return react.useMemo(() => ({
9466
- formatClusterCount,
9467
- }), []);
9509
+ const stackPhaseForMarkerState = (markerState) => {
9510
+ if (markerState === "selected")
9511
+ return "selected";
9512
+ if (markerState === "hovered")
9513
+ return "hovered";
9514
+ if (markerState === "expanded")
9515
+ return "expanded";
9516
+ return "idle";
9468
9517
  };
9469
-
9470
9518
  /**
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`.
9519
+ * Maps `<MapMarker>` presentation + state to `DomPortalStackingResult` for use with
9520
+ * `markerRender.resolveDomPortalStacking` in `<Layers>`.
9476
9521
  *
9477
- * @example
9478
- * ```tsx
9479
- * const { defineControlStack, collapseControls } = useControlStack();
9480
- * const controls = defineControlStack([myControl, undefined, otherControl]);
9481
- * const collapsed = collapseControls(config, responsiveMode, metadata);
9482
- * ```
9522
+ * Pass either `expandedRank` (for `resolveMarkerState`) or a pre-resolved `markerState`
9523
+ * when the expanded state is data-driven and does not use `expandedRank` (for example, story helpers).
9483
9524
  */
9484
- const useControlStack = () => {
9485
- return react.useMemo(() => ({
9486
- defineControlStack,
9487
- collapseControls,
9488
- }), []);
9525
+ const resolveMapMarkerDomPortalStacking = (params) => {
9526
+ const { hasStick, isMounting, labelVisible } = params;
9527
+ if (isMounting === true) {
9528
+ return { geometry: "circle", phase: "idle" };
9529
+ }
9530
+ const markerState = markerStateFromParams(params);
9531
+ const form = resolveMarkerForm(markerState, labelVisible);
9532
+ const geometry = hasStick === true ? "stick" : form === "pill" ? "pill" : "circle";
9533
+ return {
9534
+ geometry,
9535
+ phase: stackPhaseForMarkerState(markerState),
9536
+ };
9489
9537
  };
9490
9538
 
9491
9539
  /**
9492
- * Build-your-own hook: stable references to theme-aware marker color primitives.
9540
+ * Build-your-own hook: stable references to the full adaptive marker kit.
9493
9541
  *
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.
9542
+ * Returns the complete set of primitives needed to hand-roll an adaptive marker
9543
+ * layer (DOM symbol mode switching, zoom-based sizing, portal stacking) while
9544
+ * staying consistent with `useMarkers`'s built-in rendering pipeline.
9497
9545
  *
9498
9546
  * @example
9499
9547
  * ```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
- * ```
9505
- */
9506
- const useMarkerColors = () => {
9507
- return react.useMemo(() => ({
9508
- defaultMarkerColorConfig,
9509
- resolveMarkerColors,
9510
- MARKER_DARK_PILL,
9511
- MARKER_LIGHT_PILL,
9512
- }), []);
9513
- };
9514
-
9515
- /**
9516
- * Build-your-own hook: stable references to marker state resolution primitives.
9517
- *
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).
9548
+ * const {
9549
+ * pickRenderMedium, pickMarkerSize, pickSymbolDescriptor,
9550
+ * resolveAdaptiveMarkerState, resolveMapMarkerDomPortalStacking,
9551
+ * } = useAdaptiveMarkerHelpers();
9521
9552
  *
9522
- * @example
9523
- * ```tsx
9524
- * const { resolveMarkerState, resolveMarkerForm } = useMarkerStateResolvers();
9525
- * const state = resolveMarkerState({ selected, hovered, expandedRank });
9526
- * const form = resolveMarkerForm(state, labelVisible);
9553
+ * // Inside your adaptive render callback:
9554
+ * const medium = pickRenderMedium(ctx);
9555
+ * if (medium === "symbol") return pickSymbolDescriptor(color, theme, ctx);
9556
+ * const size = pickMarkerSize(ctx);
9557
+ * const state = resolveAdaptiveMarkerState({ selected, hovered });
9527
9558
  * ```
9528
9559
  */
9529
- const useMarkerStateResolvers = () => {
9560
+ const useAdaptiveMarkerHelpers = () => {
9530
9561
  return react.useMemo(() => ({
9531
- resolveMarkerState,
9532
- resolveMarkerForm,
9533
- resolveEffectiveSize,
9534
- resolveMarkerDomSize,
9562
+ pickMarkerSize,
9563
+ pickRenderMedium,
9564
+ pickSymbolDescriptor,
9565
+ pickSymbolDiameter,
9535
9566
  resolveAdaptiveMarkerState,
9536
- MARKER_SIZE_MAP,
9567
+ downgradeMarkerDomSize,
9568
+ upgradeMarkerDomSize,
9569
+ resolveMapMarkerDomPortalStacking,
9570
+ DEFAULT_MARKER_SIZE_BREAKPOINTS,
9571
+ MARKER_DISC_BORDER_WIDTH_PX,
9537
9572
  }), []);
9538
9573
  };
9539
9574
 
@@ -9631,6 +9666,150 @@ const useShapeLabelHelpers = () => {
9631
9666
  }), []);
9632
9667
  };
9633
9668
 
9669
+ /**
9670
+ * Build-your-own hook: stable references to marker state resolution primitives.
9671
+ *
9672
+ * Returns a memoized object of the pure resolver functions for consumers that
9673
+ * want to hand-roll their own MapMarker replacement while staying consistent
9674
+ * with the library's state priority rules (selected → expanded → hovered → default).
9675
+ *
9676
+ * @example
9677
+ * ```tsx
9678
+ * const { resolveMarkerState, resolveMarkerForm } = useMarkerStateResolvers();
9679
+ * const state = resolveMarkerState({ selected, hovered, expandedRank });
9680
+ * const form = resolveMarkerForm(state, labelVisible);
9681
+ * ```
9682
+ */
9683
+ const useMarkerStateResolvers = () => {
9684
+ return react.useMemo(() => ({
9685
+ resolveMarkerState,
9686
+ resolveMarkerForm,
9687
+ resolveEffectiveSize,
9688
+ resolveMarkerDomSize,
9689
+ resolveAdaptiveMarkerState,
9690
+ MARKER_SIZE_MAP,
9691
+ }), []);
9692
+ };
9693
+
9694
+ /**
9695
+ * Build-your-own hook: stable references to theme-aware marker color primitives.
9696
+ *
9697
+ * Returns the `defaultMarkerColorConfig` factory and `resolveMarkerColors` resolver
9698
+ * for consumers building custom marker components that need to stay visually
9699
+ * consistent with `MapMarker`'s pill/indicator color system.
9700
+ *
9701
+ * @example
9702
+ * ```tsx
9703
+ * const { defaultMarkerColorConfig, resolveMarkerColors } = useMarkerColors();
9704
+ * const config = defaultMarkerColorConfig(theme);
9705
+ * const colors = resolveMarkerColors(fillColor, state, theme, config);
9706
+ * // Spread colors.cssVars onto the marker root element.
9707
+ * ```
9708
+ */
9709
+ const useMarkerColors = () => {
9710
+ return react.useMemo(() => ({
9711
+ defaultMarkerColorConfig,
9712
+ resolveMarkerColors,
9713
+ MARKER_DARK_PILL,
9714
+ MARKER_LIGHT_PILL,
9715
+ }), []);
9716
+ };
9717
+
9718
+ const PADDING_PX = 12;
9719
+ /**
9720
+ * Default auto-pan resolver: pans the minimum amount to give the panel the
9721
+ * best possible screen space. Handles two cases:
9722
+ *
9723
+ * 1. **Clipping** — panel bleeds outside the map container on any side. Pans
9724
+ * to clear the bleed with 12 px of breathing room.
9725
+ *
9726
+ * 2. **Scroll-squish** — panel is within the container but shift-pressed against
9727
+ * an edge, so content overflows and the panel scrolls. Pans to give the panel
9728
+ * room to grow, up to the CSS `max-height` cap (`panelCssMaxHeight`).
9729
+ *
9730
+ * Returns `null` when the panel already fits without scrolling and without
9731
+ * clipping — meaning no pan is needed.
9732
+ *
9733
+ * Pass as `resolveAutoPan` to {@link usePanel} for the standard behavior:
9734
+ *
9735
+ * ```tsx
9736
+ * usePanel(api, {
9737
+ * id: "my-panel",
9738
+ * open: isOpen,
9739
+ * resolveAutoPan: defaultAutoPanResolver,
9740
+ * children: <MyPanelContent />,
9741
+ * });
9742
+ * ```
9743
+ */
9744
+ const defaultAutoPanResolver = (ctx) => {
9745
+ const { clipping, placement, panelRect, anchorRect, panelScrollHeight, panelCssMaxHeight } = ctx;
9746
+ // --- Clipping: clear overflow past container edges ---
9747
+ const x = clipping.right > 0 ? clipping.right + PADDING_PX : clipping.left > 0 ? -(clipping.left + PADDING_PX) : 0;
9748
+ const clippingY = clipping.bottom > 0 ? clipping.bottom + PADDING_PX : clipping.top > 0 ? -(clipping.top + PADDING_PX) : 0;
9749
+ // --- Scroll-squish: pan to give the panel room to stop scrolling.
9750
+ // Cap at panelCssMaxHeight — panning further cannot reduce scrolling.
9751
+ const squishAmount = Math.max(0, Math.min(panelScrollHeight, panelCssMaxHeight) - panelRect.height);
9752
+ let squishY = 0;
9753
+ if (squishAmount > 0) {
9754
+ if (placement.startsWith("top")) {
9755
+ // Panel is above the anchor. Move anchor down (negative y) to open space above.
9756
+ squishY = -(squishAmount + PADDING_PX);
9757
+ }
9758
+ else if (placement.startsWith("bottom")) {
9759
+ // Panel is below the anchor. Move anchor up (positive y) to open space below.
9760
+ squishY = squishAmount + PADDING_PX;
9761
+ }
9762
+ else {
9763
+ // left/right placements: panel is vertically centered on the anchor.
9764
+ // Infer which edge shift pressed the panel against by comparing the
9765
+ // panel's natural center position to its actual rendered top.
9766
+ const anchorCenterY = anchorRect.top + anchorRect.height / 2;
9767
+ const naturalPanelTop = anchorCenterY - panelRect.height / 2;
9768
+ const shiftDelta = panelRect.top - naturalPanelTop;
9769
+ if (shiftDelta > 1) {
9770
+ // Shift pressed panel down (anchor near top) — move anchor down.
9771
+ squishY = -(squishAmount + PADDING_PX);
9772
+ }
9773
+ else if (shiftDelta < -1) {
9774
+ // Shift pressed panel up (anchor near bottom) — move anchor up.
9775
+ squishY = squishAmount + PADDING_PX;
9776
+ }
9777
+ // shiftDelta ≈ 0: squish is from CSS max-height alone, not position — skip.
9778
+ }
9779
+ }
9780
+ // After shift, clippingY and squishY are mutually exclusive on the same axis
9781
+ // (shift keeps the panel within container bounds). clippingY takes priority as
9782
+ // a defensive fallback for the edge case where both are non-zero.
9783
+ const y = clippingY || squishY;
9784
+ if (x === 0 && y === 0)
9785
+ return null;
9786
+ return { x, y, restoreOnDismiss: true };
9787
+ };
9788
+
9789
+ /**
9790
+ * Build-your-own hook: stable reference to the default auto-pan resolver.
9791
+ *
9792
+ * Returns `defaultAutoPanResolver` for consumers who want to pass it to
9793
+ * `usePanel({ resolveAutoPan })` or use it as a baseline to wrap/replace
9794
+ * with custom logic.
9795
+ *
9796
+ * @example
9797
+ * ```tsx
9798
+ * const { defaultAutoPanResolver } = useAutoPanResolver();
9799
+ * usePanel(api, {
9800
+ * id: "my-panel",
9801
+ * open: isOpen,
9802
+ * resolveAutoPan: defaultAutoPanResolver,
9803
+ * children: <MyContent />,
9804
+ * });
9805
+ * ```
9806
+ */
9807
+ const useAutoPanResolver = () => {
9808
+ return react.useMemo(() => ({
9809
+ defaultAutoPanResolver,
9810
+ }), []);
9811
+ };
9812
+
9634
9813
  const VIEWPORT_BOUNDS = [-50, -50, 50, 50];
9635
9814
  const INITIAL_STATE = {
9636
9815
  center: [0, 0],
@@ -9806,6 +9985,7 @@ exports.ClusterMarker = ClusterMarker;
9806
9985
  exports.ClusterStick = ClusterStick;
9807
9986
  exports.Controls = Controls;
9808
9987
  exports.DEFAULT_MARKER_SIZE_BREAKPOINTS = DEFAULT_MARKER_SIZE_BREAKPOINTS;
9988
+ exports.DefaultControls = DefaultControls;
9809
9989
  exports.Layers = Layers;
9810
9990
  exports.MARKER_DARK_PILL = MARKER_DARK_PILL;
9811
9991
  exports.MARKER_DISC_BORDER_WIDTH_PX = MARKER_DISC_BORDER_WIDTH_PX;
@@ -9827,7 +10007,8 @@ exports.useCameraIdle = useCameraIdle;
9827
10007
  exports.useCameraState = useCameraState;
9828
10008
  exports.useClusterCountFormat = useClusterCountFormat;
9829
10009
  exports.useControlStack = useControlStack;
9830
- exports.useControlsConfig = useControlsConfig;
10010
+ exports.useControls = useControls;
10011
+ exports.useDefaultControls = useDefaultControls;
9831
10012
  exports.useDirectionIndicator = useDirectionIndicator;
9832
10013
  exports.useExpandedIds = useExpandedIds;
9833
10014
  exports.useFitFeatureBounds = useFitFeatureBounds;