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