@trackunit/react-map 0.2.180-alpha-7fd06f36b49.0 → 0.2.183

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
@@ -13,6 +13,7 @@ var esToolkit = require('es-toolkit');
13
13
  var reactCoreHooks = require('@trackunit/react-core-hooks');
14
14
  var reactFormComponents = require('@trackunit/react-form-components');
15
15
  var zod = require('zod');
16
+ var sharedUtils = require('@trackunit/shared-utils');
16
17
  var reactMinimalPieChart = require('react-minimal-pie-chart');
17
18
  var uiDesignTokens = require('@trackunit/ui-design-tokens');
18
19
  var geoJsonUtils = require('@trackunit/geo-json-utils');
@@ -2792,22 +2793,20 @@ const AppearancePreview = ({ adapterConfig, mapType, label, selected, onSelect,
2792
2793
  * @internal
2793
2794
  */
2794
2795
  const AppearancePreviewGrid = ({ adapterConfig, ariaLabel, options, currentTheme, currentMapType, onChange, }) => {
2795
- const derivedConfigs = react.useMemo(() => {
2796
- const configs = new Map();
2797
- for (const option of options) {
2798
- if (!configs.has(option.theme)) {
2799
- configs.set(option.theme, adapterConfig.derive({ theme: option.theme }));
2800
- }
2801
- }
2802
- return configs;
2803
- }, [adapterConfig, options]);
2804
- return (jsxRuntime.jsx("div", { "aria-label": ariaLabel, className: "flex flex-wrap items-start gap-2 px-3 py-2", role: "radiogroup", children: options.map(option => {
2805
- const derivedConfig = derivedConfigs.get(option.theme);
2806
- if (derivedConfig === undefined) {
2807
- return null;
2808
- }
2809
- return (jsxRuntime.jsx(AppearancePreview, { adapterConfig: derivedConfig, "data-testid": `map-style-preview-${option.key}`, label: option.label, mapType: option.mapType, onSelect: () => onChange(option.theme, option.mapType), selected: option.theme === currentTheme && option.mapType === currentMapType }, option.key));
2810
- }) }));
2796
+ const derivedOptions = react.useMemo(() => options.map(option => ({
2797
+ option,
2798
+ // Pin the full appearance per option: the main map's config may carry a
2799
+ // persisted seed (theme, mapType, roads, traffic — see
2800
+ // usePersistedAppearanceSeed), and a preview depicts exactly one
2801
+ // theme/mapType combination with no overlays.
2802
+ config: adapterConfig.derive({
2803
+ theme: option.theme,
2804
+ mapType: option.mapType,
2805
+ showRoads: false,
2806
+ showTraffic: false,
2807
+ }),
2808
+ })), [adapterConfig, options]);
2809
+ return (jsxRuntime.jsx("div", { "aria-label": ariaLabel, className: "flex flex-wrap items-start gap-2 px-3 py-2", role: "radiogroup", children: derivedOptions.map(({ option, config }) => (jsxRuntime.jsx(AppearancePreview, { adapterConfig: config, "data-testid": `map-style-preview-${option.key}`, label: option.label, mapType: option.mapType, onSelect: () => onChange(option.theme, option.mapType), selected: option.theme === currentTheme && option.mapType === currentMapType }, option.key))) }));
2811
2810
  };
2812
2811
 
2813
2812
  // ============================================================================
@@ -2815,10 +2814,16 @@ const AppearancePreviewGrid = ({ adapterConfig, ariaLabel, options, currentTheme
2815
2814
  // ============================================================================
2816
2815
  /**
2817
2816
  * Increment this number when the persisted appearance shape changes, and add a
2818
- * matching migration step in useMapAppearanceControls so stored settings are
2817
+ * matching migration step in appearanceMigrations.ts so stored settings are
2819
2818
  * upgraded instead of reset. (v2: added showTraffic.)
2820
2819
  */
2821
2820
  const APPEARANCE_SETTINGS_VERSION = 2;
2821
+ /**
2822
+ * Default localStorage key for the persisted map appearance settings.
2823
+ * Shared by `useMapAppearanceControls` (owner) and `usePersistedAppearanceSeed`
2824
+ * (read-only) so both resolve the same stored preference by default.
2825
+ */
2826
+ const DEFAULT_APPEARANCE_PERSISTENCE_KEY = "map-appearance-settings";
2822
2827
  // ============================================================================
2823
2828
  // Persisted Appearance Schema (extends core mapAppearanceSchema with version)
2824
2829
  // ============================================================================
@@ -2853,8 +2858,6 @@ const appearanceReducer = (state, action) => {
2853
2858
  }
2854
2859
  };
2855
2860
 
2856
- const DEFAULT_THEMES = ["light", "dark"];
2857
- const DEFAULT_MAP_TYPES = ["roadmap", "satellite"];
2858
2861
  /**
2859
2862
  * The v1 persisted shape, frozen as a literal. Deliberately NOT derived from
2860
2863
  * the live mapAppearanceSchema: deriving would make every future appearance
@@ -2890,6 +2893,20 @@ const appearanceMigrationV2 = {
2890
2893
  : DEFAULT_PERSISTED_APPEARANCE;
2891
2894
  },
2892
2895
  };
2896
+ /**
2897
+ * Migration pipeline for the persisted map appearance settings.
2898
+ *
2899
+ * Shared by `useMapAppearanceControls` (the owning reader/writer) and
2900
+ * `usePersistedAppearanceSeed` (a read-only consumer), so both interpret
2901
+ * stored data — including pre-envelope and v1 shapes — identically.
2902
+ */
2903
+ const APPEARANCE_MIGRATION = {
2904
+ version: APPEARANCE_SETTINGS_VERSION,
2905
+ steps: [appearanceMigrationV1, appearanceMigrationV2],
2906
+ };
2907
+
2908
+ const DEFAULT_THEMES = ["light", "dark"];
2909
+ const DEFAULT_MAP_TYPES = ["roadmap", "satellite"];
2893
2910
  const themeToTranslationKey = (theme) => {
2894
2911
  switch (theme) {
2895
2912
  case "light":
@@ -2978,7 +2995,7 @@ const buildAppearanceOptions = (themes, mapTypes, t) => {
2978
2995
  * <Controls api={api} controls={controlsConfig} />
2979
2996
  * ```
2980
2997
  */
2981
- const useMapAppearanceControls = ({ api, options: appearanceOptions, persistenceKey = "map-appearance-settings", }) => {
2998
+ const useMapAppearanceControls = ({ api, options: appearanceOptions, persistenceKey = DEFAULT_APPEARANCE_PERSISTENCE_KEY, }) => {
2982
2999
  const [t] = useTranslation();
2983
3000
  const themes = appearanceOptions?.themes ?? DEFAULT_THEMES;
2984
3001
  const mapTypes = appearanceOptions?.mapTypes ?? DEFAULT_MAP_TYPES;
@@ -2993,7 +3010,7 @@ const useMapAppearanceControls = ({ api, options: appearanceOptions, persistence
2993
3010
  const [localState, dispatch] = reactComponents.useLocalStorageReducer({
2994
3011
  key: persistenceKey,
2995
3012
  defaultState: DEFAULT_PERSISTED_APPEARANCE,
2996
- migration: { version: APPEARANCE_SETTINGS_VERSION, steps: [appearanceMigrationV1, appearanceMigrationV2] },
3013
+ migration: APPEARANCE_MIGRATION,
2997
3014
  reducer: appearanceReducer,
2998
3015
  schema: persistedAppearanceSchema,
2999
3016
  });
@@ -3241,7 +3258,11 @@ const useDefaultControls = (api, options) => {
3241
3258
  "data-testid": "map-my-location-button",
3242
3259
  }, [api.actions, getPosition, options?.navigation?.myLocation?.enabled, t]);
3243
3260
  const navigationControls = react.useMemo(() => defineControlStack([myLocationControl, zoomControl, fullscreenControl]), [fullscreenControl, myLocationControl, zoomControl]);
3244
- const mapAppearanceControls = useMapAppearanceControls({ api });
3261
+ const mapAppearanceControls = useMapAppearanceControls({
3262
+ api,
3263
+ options: options?.settings?.mapStyle?.options,
3264
+ persistenceKey: options?.settings?.mapStyle?.persistenceKey,
3265
+ });
3245
3266
  const mapStyleControl = react.useMemo(() => options?.settings?.mapStyle?.enabled === false ? undefined : mapAppearanceControls[0], [mapAppearanceControls, options?.settings?.mapStyle?.enabled]);
3246
3267
  const settingsControls = react.useMemo(() => defineControlStack([mapStyleControl]), [mapStyleControl]);
3247
3268
  return react.useMemo(() => ({
@@ -3362,6 +3383,57 @@ const useControls = (base, areas) => {
3362
3383
  }, [base, areas]);
3363
3384
  };
3364
3385
 
3386
+ /**
3387
+ * Reads the persisted map appearance once (on mount) so it can seed the adapter
3388
+ * factory, letting the FIRST map be constructed with the user's stored theme,
3389
+ * map type, and traffic preference instead of being corrected after mount.
3390
+ *
3391
+ * Without the seed, a stored dark theme or traffic-on preference only reaches
3392
+ * the adapter once the map is ready, forcing an immediate map recreation on
3393
+ * every page load: a visible blink, a wasted provider map construction (each
3394
+ * one is a billed Dynamic Maps load on Google), and overlays built on a
3395
+ * throwaway instance (ADR 0029).
3396
+ *
3397
+ * Read-only: `useMapAppearanceControls` keeps owning the persistence and pushes
3398
+ * all later changes through the adapter's appearance actions. The seed is
3399
+ * deliberately not reactive — it only matters at construction time.
3400
+ *
3401
+ * Storage being unavailable or corrupt is never an error: the seed then falls
3402
+ * back to the defaults, which is exactly the pre-seeding behavior.
3403
+ *
3404
+ * @example
3405
+ * ```tsx
3406
+ * const appearanceSeed = usePersistedAppearanceSeed();
3407
+ * const [Map, api] = useMap(googleMapsAdapter({ apiKey, ...appearanceSeed }));
3408
+ * ```
3409
+ */
3410
+ const usePersistedAppearanceSeed = (params) => {
3411
+ const [seed] = react.useState(() => {
3412
+ let persisted = DEFAULT_PERSISTED_APPEARANCE;
3413
+ try {
3414
+ persisted = sharedUtils.readLocalStorageValue({
3415
+ key: params?.persistenceKey ?? DEFAULT_APPEARANCE_PERSISTENCE_KEY,
3416
+ defaultState: DEFAULT_PERSISTED_APPEARANCE,
3417
+ schema: persistedAppearanceSchema,
3418
+ migration: APPEARANCE_MIGRATION,
3419
+ });
3420
+ }
3421
+ catch {
3422
+ // Accessing localStorage can throw (privacy mode, blocked site data,
3423
+ // non-browser rendering) — fall through to the defaults.
3424
+ }
3425
+ return {
3426
+ theme: persisted.theme,
3427
+ mapType: persisted.mapType,
3428
+ showRoads: persisted.showRoads,
3429
+ // `traffic: false` opts the whole surface out of traffic (see
3430
+ // useMapAppearanceControls), so it must override a stored preference here too
3431
+ showTraffic: params?.options?.traffic === false ? false : persisted.showTraffic,
3432
+ };
3433
+ });
3434
+ return seed;
3435
+ };
3436
+
3365
3437
  const MARKER_SPRING_CONFIG = {
3366
3438
  stiffness: 380,
3367
3439
  damping: 30,
@@ -5173,7 +5245,7 @@ const useLayerReady = (loading) => {
5173
5245
  // ============================================================================
5174
5246
  // Constants
5175
5247
  // ============================================================================
5176
- const EMPTY_CONTROLS$3 = {};
5248
+ const EMPTY_CONTROLS$4 = {};
5177
5249
  // ============================================================================
5178
5250
  // Hook
5179
5251
  // ============================================================================
@@ -5213,7 +5285,7 @@ const useImageOverlay = (options) => {
5213
5285
  ready: layerReady.ready,
5214
5286
  getBounds,
5215
5287
  counts,
5216
- controls: controls ?? EMPTY_CONTROLS$3,
5288
+ controls: controls ?? EMPTY_CONTROLS$4,
5217
5289
  fitParticipation,
5218
5290
  layerType: "image-overlay",
5219
5291
  url,
@@ -5494,7 +5566,7 @@ const createLazyGetter = (compute) => {
5494
5566
  // ============================================================================
5495
5567
  // Constants
5496
5568
  // ============================================================================
5497
- const EMPTY_CONTROLS$2 = {};
5569
+ const EMPTY_CONTROLS$3 = {};
5498
5570
  const EMPTY_FEATURES$1 = { type: "FeatureCollection", features: [] };
5499
5571
  // ============================================================================
5500
5572
  // Internal helpers
@@ -5582,7 +5654,7 @@ const useRoute = (options) => {
5582
5654
  ready: layerReady.ready,
5583
5655
  getBounds,
5584
5656
  counts,
5585
- controls: controls ?? EMPTY_CONTROLS$2,
5657
+ controls: controls ?? EMPTY_CONTROLS$3,
5586
5658
  fitParticipation,
5587
5659
  layerType: "route",
5588
5660
  features,
@@ -5708,6 +5780,78 @@ const useFitToContent = (api, handles, options) => {
5708
5780
  return react.useMemo(() => ({ hasFitted, isWaiting, fitNow }), [hasFitted, isWaiting, fitNow]);
5709
5781
  };
5710
5782
 
5783
+ const EMPTY_CONTROLS$2 = {};
5784
+ /**
5785
+ * Weighted point heatmap layer handle (provider-agnostic).
5786
+ *
5787
+ * - Normalizes `data` → GeoJSON `Point` features + `properties.weight`.
5788
+ * - Synced as `HeatmapSourceConfig` in {@link LayerSnapshot}.
5789
+ * - Renderers: ADR-0029 (`libs/react/map/docs/adr/0029-heatmap-provider-specific-renderers.md`).
5790
+ */
5791
+ const useHeatmap = (options) => {
5792
+ const { id, name, data, getPosition, getWeight, style, controls, loading, fitParticipation = "all" } = options;
5793
+ const layerReady = useLayerReady(loading);
5794
+ const normalized = react.useMemo(() => {
5795
+ const features = [];
5796
+ const positions = [];
5797
+ for (const item of data) {
5798
+ const position = getPosition(item);
5799
+ if (position === null)
5800
+ continue;
5801
+ const parsedPosition = geoJsonUtils.geoJsonPositionSchema.safeParse(position);
5802
+ if (!parsedPosition.success)
5803
+ continue;
5804
+ const weight = getWeight?.(item) ?? 1;
5805
+ if (!Number.isFinite(weight) || weight <= 0)
5806
+ continue;
5807
+ positions.push(parsedPosition.data);
5808
+ features.push({
5809
+ type: "Feature",
5810
+ geometry: { type: "Point", coordinates: parsedPosition.data },
5811
+ properties: { weight },
5812
+ });
5813
+ }
5814
+ return {
5815
+ features: { type: "FeatureCollection", features },
5816
+ positions,
5817
+ };
5818
+ }, [data, getPosition, getWeight]);
5819
+ const resolvedStyle = react.useMemo(() => ({
5820
+ radiusPixels: style?.radiusPixels ?? 30,
5821
+ intensity: style?.intensity ?? 1,
5822
+ opacity: style?.opacity ?? 1,
5823
+ }), [style?.radiusPixels, style?.intensity, style?.opacity]);
5824
+ const getBounds = react.useMemo(() => createLazyGetter(() => computeBoundsFromPositions(normalized.positions)), [normalized.positions]);
5825
+ const counts = react.useMemo(() => ({ total: data.length, points: normalized.features.features.length }), [data.length, normalized.features.features.length]);
5826
+ return react.useMemo(() => ({
5827
+ id,
5828
+ name,
5829
+ loadsData: layerReady.loadsData,
5830
+ loading: layerReady.isLoading,
5831
+ ready: layerReady.ready,
5832
+ getBounds,
5833
+ counts,
5834
+ controls: controls ?? EMPTY_CONTROLS$2,
5835
+ fitParticipation,
5836
+ layerType: "heatmap",
5837
+ features: normalized.features,
5838
+ style: resolvedStyle,
5839
+ pointCount: normalized.features.features.length,
5840
+ totalCount: data.length,
5841
+ }), [
5842
+ id,
5843
+ name,
5844
+ layerReady,
5845
+ getBounds,
5846
+ counts,
5847
+ controls,
5848
+ fitParticipation,
5849
+ normalized.features,
5850
+ resolvedStyle,
5851
+ data.length,
5852
+ ]);
5853
+ };
5854
+
5711
5855
  /**
5712
5856
  * Do two label footprints overlap? Each is a box of its own size centred on its point, so the boxes
5713
5857
  * overlap when the centres are closer than the sum of their half-extents in each axis. Shared by the
@@ -8002,6 +8146,13 @@ function toMapLayer(handle) {
8002
8146
  imageBounds: handle.imageBounds,
8003
8147
  opacity: handle.opacity,
8004
8148
  };
8149
+ case "heatmap":
8150
+ return {
8151
+ layerType: "heatmap",
8152
+ id: handle.id,
8153
+ features: handle.features,
8154
+ style: handle.style,
8155
+ };
8005
8156
  default: {
8006
8157
  const exhaustiveCheck = handle;
8007
8158
  throw new Error(`${String(exhaustiveCheck)} is not known`);
@@ -12884,6 +13035,7 @@ Object.defineProperty(exports, "mapTypeSchema", {
12884
13035
  exports.ClusterMarker = ClusterMarker;
12885
13036
  exports.ClusterStick = ClusterStick;
12886
13037
  exports.Controls = Controls;
13038
+ exports.DEFAULT_APPEARANCE_PERSISTENCE_KEY = DEFAULT_APPEARANCE_PERSISTENCE_KEY;
12887
13039
  exports.DEFAULT_MARKER_SIZE_BREAKPOINTS = DEFAULT_MARKER_SIZE_BREAKPOINTS;
12888
13040
  exports.DefaultControls = DefaultControls;
12889
13041
  exports.Layers = Layers;
@@ -12915,6 +13067,7 @@ exports.useEntitiesNearCursor = useEntitiesNearCursor;
12915
13067
  exports.useExpandedIds = useExpandedIds;
12916
13068
  exports.useFitFeatureBounds = useFitFeatureBounds;
12917
13069
  exports.useFitToContent = useFitToContent;
13070
+ exports.useHeatmap = useHeatmap;
12918
13071
  exports.useImageOverlay = useImageOverlay;
12919
13072
  exports.useLabelPlacement = useLabelPlacement;
12920
13073
  exports.useLayers = useLayers;
@@ -12928,6 +13081,7 @@ exports.useMarkerStateResolvers = useMarkerStateResolvers;
12928
13081
  exports.useMarkers = useMarkers;
12929
13082
  exports.usePanel = usePanel;
12930
13083
  exports.usePanelPreload = usePanelPreload;
13084
+ exports.usePersistedAppearanceSeed = usePersistedAppearanceSeed;
12931
13085
  exports.usePreviewMap = usePreviewMap;
12932
13086
  exports.useRoute = useRoute;
12933
13087
  exports.useShapeLabelHelpers = useShapeLabelHelpers;
package/index.esm.js CHANGED
@@ -12,6 +12,7 @@ import { isEqual } from 'es-toolkit';
12
12
  import { useGeolocation, useErrorHandlerOrNull } from '@trackunit/react-core-hooks';
13
13
  import { Checkbox, ToggleSwitch, RadioGroup as RadioGroup$1, RadioItem, FormGroup, Search as Search$1, SelectField, BaseSelect } from '@trackunit/react-form-components';
14
14
  import { z } from 'zod';
15
+ import { readLocalStorageValue } from '@trackunit/shared-utils';
15
16
  import { PieChart } from 'react-minimal-pie-chart';
16
17
  import { tailwindPalette } from '@trackunit/ui-design-tokens';
17
18
  import { lngLatToMercatorPxWS, validateBboxWithFallback, extractPositionsFromGeometry, geoJsonPositionSchema, validateFeatureCollection, EMPTY_FEATURE_COLLECTION, splitAntimeridianCrossingFeatures, validateBbox, projectPolygonalToWebMercator, geoJsonPolygonDifference, unprojectPolygonalFromWebMercator, geoJsonPolygonSchema, geoJsonMultiPolygonSchema, distanceToGeoJsonPolygonBoundary, getGeoJsonPolygonIntersection, isFullyContainedInGeometry, isGeoJsonPointInPolygon, isBboxInsideFeatureCollection, extractEdges, computeGeometryCentroid, mercatorPxToLngLatWS, isPositionInsideRing, lngLatToWebMercatorPx, edgePixelLength, isFullyContainedInGeoJsonGeometry } from '@trackunit/geo-json-utils';
@@ -2791,22 +2792,20 @@ const AppearancePreview = ({ adapterConfig, mapType, label, selected, onSelect,
2791
2792
  * @internal
2792
2793
  */
2793
2794
  const AppearancePreviewGrid = ({ adapterConfig, ariaLabel, options, currentTheme, currentMapType, onChange, }) => {
2794
- const derivedConfigs = useMemo(() => {
2795
- const configs = new Map();
2796
- for (const option of options) {
2797
- if (!configs.has(option.theme)) {
2798
- configs.set(option.theme, adapterConfig.derive({ theme: option.theme }));
2799
- }
2800
- }
2801
- return configs;
2802
- }, [adapterConfig, options]);
2803
- return (jsx("div", { "aria-label": ariaLabel, className: "flex flex-wrap items-start gap-2 px-3 py-2", role: "radiogroup", children: options.map(option => {
2804
- const derivedConfig = derivedConfigs.get(option.theme);
2805
- if (derivedConfig === undefined) {
2806
- return null;
2807
- }
2808
- return (jsx(AppearancePreview, { adapterConfig: derivedConfig, "data-testid": `map-style-preview-${option.key}`, label: option.label, mapType: option.mapType, onSelect: () => onChange(option.theme, option.mapType), selected: option.theme === currentTheme && option.mapType === currentMapType }, option.key));
2809
- }) }));
2795
+ const derivedOptions = useMemo(() => options.map(option => ({
2796
+ option,
2797
+ // Pin the full appearance per option: the main map's config may carry a
2798
+ // persisted seed (theme, mapType, roads, traffic — see
2799
+ // usePersistedAppearanceSeed), and a preview depicts exactly one
2800
+ // theme/mapType combination with no overlays.
2801
+ config: adapterConfig.derive({
2802
+ theme: option.theme,
2803
+ mapType: option.mapType,
2804
+ showRoads: false,
2805
+ showTraffic: false,
2806
+ }),
2807
+ })), [adapterConfig, options]);
2808
+ return (jsx("div", { "aria-label": ariaLabel, className: "flex flex-wrap items-start gap-2 px-3 py-2", role: "radiogroup", children: derivedOptions.map(({ option, config }) => (jsx(AppearancePreview, { adapterConfig: config, "data-testid": `map-style-preview-${option.key}`, label: option.label, mapType: option.mapType, onSelect: () => onChange(option.theme, option.mapType), selected: option.theme === currentTheme && option.mapType === currentMapType }, option.key))) }));
2810
2809
  };
2811
2810
 
2812
2811
  // ============================================================================
@@ -2814,10 +2813,16 @@ const AppearancePreviewGrid = ({ adapterConfig, ariaLabel, options, currentTheme
2814
2813
  // ============================================================================
2815
2814
  /**
2816
2815
  * Increment this number when the persisted appearance shape changes, and add a
2817
- * matching migration step in useMapAppearanceControls so stored settings are
2816
+ * matching migration step in appearanceMigrations.ts so stored settings are
2818
2817
  * upgraded instead of reset. (v2: added showTraffic.)
2819
2818
  */
2820
2819
  const APPEARANCE_SETTINGS_VERSION = 2;
2820
+ /**
2821
+ * Default localStorage key for the persisted map appearance settings.
2822
+ * Shared by `useMapAppearanceControls` (owner) and `usePersistedAppearanceSeed`
2823
+ * (read-only) so both resolve the same stored preference by default.
2824
+ */
2825
+ const DEFAULT_APPEARANCE_PERSISTENCE_KEY = "map-appearance-settings";
2821
2826
  // ============================================================================
2822
2827
  // Persisted Appearance Schema (extends core mapAppearanceSchema with version)
2823
2828
  // ============================================================================
@@ -2852,8 +2857,6 @@ const appearanceReducer = (state, action) => {
2852
2857
  }
2853
2858
  };
2854
2859
 
2855
- const DEFAULT_THEMES = ["light", "dark"];
2856
- const DEFAULT_MAP_TYPES = ["roadmap", "satellite"];
2857
2860
  /**
2858
2861
  * The v1 persisted shape, frozen as a literal. Deliberately NOT derived from
2859
2862
  * the live mapAppearanceSchema: deriving would make every future appearance
@@ -2889,6 +2892,20 @@ const appearanceMigrationV2 = {
2889
2892
  : DEFAULT_PERSISTED_APPEARANCE;
2890
2893
  },
2891
2894
  };
2895
+ /**
2896
+ * Migration pipeline for the persisted map appearance settings.
2897
+ *
2898
+ * Shared by `useMapAppearanceControls` (the owning reader/writer) and
2899
+ * `usePersistedAppearanceSeed` (a read-only consumer), so both interpret
2900
+ * stored data — including pre-envelope and v1 shapes — identically.
2901
+ */
2902
+ const APPEARANCE_MIGRATION = {
2903
+ version: APPEARANCE_SETTINGS_VERSION,
2904
+ steps: [appearanceMigrationV1, appearanceMigrationV2],
2905
+ };
2906
+
2907
+ const DEFAULT_THEMES = ["light", "dark"];
2908
+ const DEFAULT_MAP_TYPES = ["roadmap", "satellite"];
2892
2909
  const themeToTranslationKey = (theme) => {
2893
2910
  switch (theme) {
2894
2911
  case "light":
@@ -2977,7 +2994,7 @@ const buildAppearanceOptions = (themes, mapTypes, t) => {
2977
2994
  * <Controls api={api} controls={controlsConfig} />
2978
2995
  * ```
2979
2996
  */
2980
- const useMapAppearanceControls = ({ api, options: appearanceOptions, persistenceKey = "map-appearance-settings", }) => {
2997
+ const useMapAppearanceControls = ({ api, options: appearanceOptions, persistenceKey = DEFAULT_APPEARANCE_PERSISTENCE_KEY, }) => {
2981
2998
  const [t] = useTranslation();
2982
2999
  const themes = appearanceOptions?.themes ?? DEFAULT_THEMES;
2983
3000
  const mapTypes = appearanceOptions?.mapTypes ?? DEFAULT_MAP_TYPES;
@@ -2992,7 +3009,7 @@ const useMapAppearanceControls = ({ api, options: appearanceOptions, persistence
2992
3009
  const [localState, dispatch] = useLocalStorageReducer({
2993
3010
  key: persistenceKey,
2994
3011
  defaultState: DEFAULT_PERSISTED_APPEARANCE,
2995
- migration: { version: APPEARANCE_SETTINGS_VERSION, steps: [appearanceMigrationV1, appearanceMigrationV2] },
3012
+ migration: APPEARANCE_MIGRATION,
2996
3013
  reducer: appearanceReducer,
2997
3014
  schema: persistedAppearanceSchema,
2998
3015
  });
@@ -3240,7 +3257,11 @@ const useDefaultControls = (api, options) => {
3240
3257
  "data-testid": "map-my-location-button",
3241
3258
  }, [api.actions, getPosition, options?.navigation?.myLocation?.enabled, t]);
3242
3259
  const navigationControls = useMemo(() => defineControlStack([myLocationControl, zoomControl, fullscreenControl]), [fullscreenControl, myLocationControl, zoomControl]);
3243
- const mapAppearanceControls = useMapAppearanceControls({ api });
3260
+ const mapAppearanceControls = useMapAppearanceControls({
3261
+ api,
3262
+ options: options?.settings?.mapStyle?.options,
3263
+ persistenceKey: options?.settings?.mapStyle?.persistenceKey,
3264
+ });
3244
3265
  const mapStyleControl = useMemo(() => options?.settings?.mapStyle?.enabled === false ? undefined : mapAppearanceControls[0], [mapAppearanceControls, options?.settings?.mapStyle?.enabled]);
3245
3266
  const settingsControls = useMemo(() => defineControlStack([mapStyleControl]), [mapStyleControl]);
3246
3267
  return useMemo(() => ({
@@ -3361,6 +3382,57 @@ const useControls = (base, areas) => {
3361
3382
  }, [base, areas]);
3362
3383
  };
3363
3384
 
3385
+ /**
3386
+ * Reads the persisted map appearance once (on mount) so it can seed the adapter
3387
+ * factory, letting the FIRST map be constructed with the user's stored theme,
3388
+ * map type, and traffic preference instead of being corrected after mount.
3389
+ *
3390
+ * Without the seed, a stored dark theme or traffic-on preference only reaches
3391
+ * the adapter once the map is ready, forcing an immediate map recreation on
3392
+ * every page load: a visible blink, a wasted provider map construction (each
3393
+ * one is a billed Dynamic Maps load on Google), and overlays built on a
3394
+ * throwaway instance (ADR 0029).
3395
+ *
3396
+ * Read-only: `useMapAppearanceControls` keeps owning the persistence and pushes
3397
+ * all later changes through the adapter's appearance actions. The seed is
3398
+ * deliberately not reactive — it only matters at construction time.
3399
+ *
3400
+ * Storage being unavailable or corrupt is never an error: the seed then falls
3401
+ * back to the defaults, which is exactly the pre-seeding behavior.
3402
+ *
3403
+ * @example
3404
+ * ```tsx
3405
+ * const appearanceSeed = usePersistedAppearanceSeed();
3406
+ * const [Map, api] = useMap(googleMapsAdapter({ apiKey, ...appearanceSeed }));
3407
+ * ```
3408
+ */
3409
+ const usePersistedAppearanceSeed = (params) => {
3410
+ const [seed] = useState(() => {
3411
+ let persisted = DEFAULT_PERSISTED_APPEARANCE;
3412
+ try {
3413
+ persisted = readLocalStorageValue({
3414
+ key: params?.persistenceKey ?? DEFAULT_APPEARANCE_PERSISTENCE_KEY,
3415
+ defaultState: DEFAULT_PERSISTED_APPEARANCE,
3416
+ schema: persistedAppearanceSchema,
3417
+ migration: APPEARANCE_MIGRATION,
3418
+ });
3419
+ }
3420
+ catch {
3421
+ // Accessing localStorage can throw (privacy mode, blocked site data,
3422
+ // non-browser rendering) — fall through to the defaults.
3423
+ }
3424
+ return {
3425
+ theme: persisted.theme,
3426
+ mapType: persisted.mapType,
3427
+ showRoads: persisted.showRoads,
3428
+ // `traffic: false` opts the whole surface out of traffic (see
3429
+ // useMapAppearanceControls), so it must override a stored preference here too
3430
+ showTraffic: params?.options?.traffic === false ? false : persisted.showTraffic,
3431
+ };
3432
+ });
3433
+ return seed;
3434
+ };
3435
+
3364
3436
  const MARKER_SPRING_CONFIG = {
3365
3437
  stiffness: 380,
3366
3438
  damping: 30,
@@ -5172,7 +5244,7 @@ const useLayerReady = (loading) => {
5172
5244
  // ============================================================================
5173
5245
  // Constants
5174
5246
  // ============================================================================
5175
- const EMPTY_CONTROLS$3 = {};
5247
+ const EMPTY_CONTROLS$4 = {};
5176
5248
  // ============================================================================
5177
5249
  // Hook
5178
5250
  // ============================================================================
@@ -5212,7 +5284,7 @@ const useImageOverlay = (options) => {
5212
5284
  ready: layerReady.ready,
5213
5285
  getBounds,
5214
5286
  counts,
5215
- controls: controls ?? EMPTY_CONTROLS$3,
5287
+ controls: controls ?? EMPTY_CONTROLS$4,
5216
5288
  fitParticipation,
5217
5289
  layerType: "image-overlay",
5218
5290
  url,
@@ -5493,7 +5565,7 @@ const createLazyGetter = (compute) => {
5493
5565
  // ============================================================================
5494
5566
  // Constants
5495
5567
  // ============================================================================
5496
- const EMPTY_CONTROLS$2 = {};
5568
+ const EMPTY_CONTROLS$3 = {};
5497
5569
  const EMPTY_FEATURES$1 = { type: "FeatureCollection", features: [] };
5498
5570
  // ============================================================================
5499
5571
  // Internal helpers
@@ -5581,7 +5653,7 @@ const useRoute = (options) => {
5581
5653
  ready: layerReady.ready,
5582
5654
  getBounds,
5583
5655
  counts,
5584
- controls: controls ?? EMPTY_CONTROLS$2,
5656
+ controls: controls ?? EMPTY_CONTROLS$3,
5585
5657
  fitParticipation,
5586
5658
  layerType: "route",
5587
5659
  features,
@@ -5707,6 +5779,78 @@ const useFitToContent = (api, handles, options) => {
5707
5779
  return useMemo(() => ({ hasFitted, isWaiting, fitNow }), [hasFitted, isWaiting, fitNow]);
5708
5780
  };
5709
5781
 
5782
+ const EMPTY_CONTROLS$2 = {};
5783
+ /**
5784
+ * Weighted point heatmap layer handle (provider-agnostic).
5785
+ *
5786
+ * - Normalizes `data` → GeoJSON `Point` features + `properties.weight`.
5787
+ * - Synced as `HeatmapSourceConfig` in {@link LayerSnapshot}.
5788
+ * - Renderers: ADR-0029 (`libs/react/map/docs/adr/0029-heatmap-provider-specific-renderers.md`).
5789
+ */
5790
+ const useHeatmap = (options) => {
5791
+ const { id, name, data, getPosition, getWeight, style, controls, loading, fitParticipation = "all" } = options;
5792
+ const layerReady = useLayerReady(loading);
5793
+ const normalized = useMemo(() => {
5794
+ const features = [];
5795
+ const positions = [];
5796
+ for (const item of data) {
5797
+ const position = getPosition(item);
5798
+ if (position === null)
5799
+ continue;
5800
+ const parsedPosition = geoJsonPositionSchema.safeParse(position);
5801
+ if (!parsedPosition.success)
5802
+ continue;
5803
+ const weight = getWeight?.(item) ?? 1;
5804
+ if (!Number.isFinite(weight) || weight <= 0)
5805
+ continue;
5806
+ positions.push(parsedPosition.data);
5807
+ features.push({
5808
+ type: "Feature",
5809
+ geometry: { type: "Point", coordinates: parsedPosition.data },
5810
+ properties: { weight },
5811
+ });
5812
+ }
5813
+ return {
5814
+ features: { type: "FeatureCollection", features },
5815
+ positions,
5816
+ };
5817
+ }, [data, getPosition, getWeight]);
5818
+ const resolvedStyle = useMemo(() => ({
5819
+ radiusPixels: style?.radiusPixels ?? 30,
5820
+ intensity: style?.intensity ?? 1,
5821
+ opacity: style?.opacity ?? 1,
5822
+ }), [style?.radiusPixels, style?.intensity, style?.opacity]);
5823
+ const getBounds = useMemo(() => createLazyGetter(() => computeBoundsFromPositions(normalized.positions)), [normalized.positions]);
5824
+ const counts = useMemo(() => ({ total: data.length, points: normalized.features.features.length }), [data.length, normalized.features.features.length]);
5825
+ return useMemo(() => ({
5826
+ id,
5827
+ name,
5828
+ loadsData: layerReady.loadsData,
5829
+ loading: layerReady.isLoading,
5830
+ ready: layerReady.ready,
5831
+ getBounds,
5832
+ counts,
5833
+ controls: controls ?? EMPTY_CONTROLS$2,
5834
+ fitParticipation,
5835
+ layerType: "heatmap",
5836
+ features: normalized.features,
5837
+ style: resolvedStyle,
5838
+ pointCount: normalized.features.features.length,
5839
+ totalCount: data.length,
5840
+ }), [
5841
+ id,
5842
+ name,
5843
+ layerReady,
5844
+ getBounds,
5845
+ counts,
5846
+ controls,
5847
+ fitParticipation,
5848
+ normalized.features,
5849
+ resolvedStyle,
5850
+ data.length,
5851
+ ]);
5852
+ };
5853
+
5710
5854
  /**
5711
5855
  * Do two label footprints overlap? Each is a box of its own size centred on its point, so the boxes
5712
5856
  * overlap when the centres are closer than the sum of their half-extents in each axis. Shared by the
@@ -8001,6 +8145,13 @@ function toMapLayer(handle) {
8001
8145
  imageBounds: handle.imageBounds,
8002
8146
  opacity: handle.opacity,
8003
8147
  };
8148
+ case "heatmap":
8149
+ return {
8150
+ layerType: "heatmap",
8151
+ id: handle.id,
8152
+ features: handle.features,
8153
+ style: handle.style,
8154
+ };
8004
8155
  default: {
8005
8156
  const exhaustiveCheck = handle;
8006
8157
  throw new Error(`${String(exhaustiveCheck)} is not known`);
@@ -12828,4 +12979,4 @@ const mockMapApi = (overrides) => {
12828
12979
  */
12829
12980
  setupLibraryTranslations();
12830
12981
 
12831
- 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, PanelIconButton, ShapeAnnotationLabel, buildExpandedIds, cvaMapMarker, cvaMarkerIndicator, mockMapApi, useAdaptiveMarkerHelpers, useAutoPanResolver, useCameraIdle, useCameraState, useClusterCountFormat, useControlStack, useControls, useDefaultControls, useDirectionIndicator, useEntitiesNearCursor, useExpandedIds, useFitFeatureBounds, useFitToContent, useImageOverlay, useLabelPlacement, useLayers, useMap, useMapAnnotation, useMapAnnotations, useMapAppearanceControls, useMapKeyboardNavigation, useMarkerColors, useMarkerStateResolvers, useMarkers, usePanel, usePanelPreload, usePreviewMap, useRoute, useShapeLabelHelpers, useShapes, useViewportContext };
12982
+ export { ClusterMarker, ClusterStick, Controls, DEFAULT_APPEARANCE_PERSISTENCE_KEY, 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, PanelIconButton, ShapeAnnotationLabel, buildExpandedIds, cvaMapMarker, cvaMarkerIndicator, mockMapApi, useAdaptiveMarkerHelpers, useAutoPanResolver, useCameraIdle, useCameraState, useClusterCountFormat, useControlStack, useControls, useDefaultControls, useDirectionIndicator, useEntitiesNearCursor, useExpandedIds, useFitFeatureBounds, useFitToContent, useHeatmap, useImageOverlay, useLabelPlacement, useLayers, useMap, useMapAnnotation, useMapAnnotations, useMapAppearanceControls, useMapKeyboardNavigation, useMarkerColors, useMarkerStateResolvers, useMarkers, usePanel, usePanelPreload, usePersistedAppearanceSeed, usePreviewMap, useRoute, useShapeLabelHelpers, useShapes, useViewportContext };
package/package.json CHANGED
@@ -1,22 +1,23 @@
1
1
  {
2
2
  "name": "@trackunit/react-map",
3
- "version": "0.2.180-alpha-7fd06f36b49.0",
3
+ "version": "0.2.183",
4
4
  "repository": "https://github.com/Trackunit/manager",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "engines": {
7
7
  "node": ">=24.x"
8
8
  },
9
9
  "dependencies": {
10
- "@trackunit/react-components": "3.4.1-alpha-7fd06f36b49.0",
11
- "@trackunit/css-class-variance-utilities": "2.1.1-alpha-7fd06f36b49.0",
12
- "@trackunit/react-form-components": "2.8.1-alpha-7fd06f36b49.0",
13
- "@trackunit/react-core-hooks": "1.23.1-alpha-7fd06f36b49.0",
14
- "@trackunit/geo-json-utils": "1.16.1-alpha-7fd06f36b49.0",
15
- "@trackunit/i18n-library-translation": "2.6.1-alpha-7fd06f36b49.0",
10
+ "@trackunit/react-components": "3.4.4",
11
+ "@trackunit/css-class-variance-utilities": "2.1.3",
12
+ "@trackunit/react-form-components": "2.8.4",
13
+ "@trackunit/react-core-hooks": "1.23.3",
14
+ "@trackunit/geo-json-utils": "1.16.3",
15
+ "@trackunit/i18n-library-translation": "2.6.3",
16
16
  "react-minimal-pie-chart": "^8.4.0",
17
- "@trackunit/react-map-adapter-shared": "0.0.142-alpha-7fd06f36b49.0",
18
- "@trackunit/react-map-color-utils": "0.0.117-alpha-7fd06f36b49.0",
19
- "@trackunit/ui-design-tokens": "1.16.1-alpha-7fd06f36b49.0",
17
+ "@trackunit/react-map-adapter-shared": "0.0.144",
18
+ "@trackunit/react-map-color-utils": "0.0.119",
19
+ "@trackunit/shared-utils": "1.17.3",
20
+ "@trackunit/ui-design-tokens": "1.16.3",
20
21
  "@floating-ui/react": "^0.26.25",
21
22
  "es-toolkit": "^1.39.10",
22
23
  "tailwind-merge": "^2.0.0",
@@ -0,0 +1,9 @@
1
+ import type { MigrationConfig } from "@trackunit/react-components";
2
+ /**
3
+ * Migration pipeline for the persisted map appearance settings.
4
+ *
5
+ * Shared by `useMapAppearanceControls` (the owning reader/writer) and
6
+ * `usePersistedAppearanceSeed` (a read-only consumer), so both interpret
7
+ * stored data — including pre-envelope and v1 shapes — identically.
8
+ */
9
+ export declare const APPEARANCE_MIGRATION: MigrationConfig;
@@ -3,10 +3,16 @@ import { z } from "zod";
3
3
  import { type MapActions, type MapStatus, type MapTheme, type MapType } from "../core/types";
4
4
  /**
5
5
  * Increment this number when the persisted appearance shape changes, and add a
6
- * matching migration step in useMapAppearanceControls so stored settings are
6
+ * matching migration step in appearanceMigrations.ts so stored settings are
7
7
  * upgraded instead of reset. (v2: added showTraffic.)
8
8
  */
9
9
  export declare const APPEARANCE_SETTINGS_VERSION = 2;
10
+ /**
11
+ * Default localStorage key for the persisted map appearance settings.
12
+ * Shared by `useMapAppearanceControls` (owner) and `usePersistedAppearanceSeed`
13
+ * (read-only) so both resolve the same stored preference by default.
14
+ */
15
+ export declare const DEFAULT_APPEARANCE_PERSISTENCE_KEY = "map-appearance-settings";
10
16
  export declare const persistedAppearanceSchema: z.ZodObject<{
11
17
  theme: z.ZodEnum<["light", "dark"]>;
12
18
  mapType: z.ZodEnum<["roadmap", "satellite", "hybrid"]>;
@@ -0,0 +1,51 @@
1
+ import type { MapAppearance } from "@trackunit/react-map-adapter-shared";
2
+ import { type AppearanceOptions } from "./appearanceTypes";
3
+ /**
4
+ * The persisted appearance as a seed for adapter construction: every field
5
+ * resolved (`showTraffic` is optional in the core `MapAppearance` for external
6
+ * compatibility, but a seed always knows its value). The shape spreads directly
7
+ * into an adapter factory's `BaseAdapterConfig` fields.
8
+ */
9
+ export type MapAppearanceSeed = Readonly<Required<MapAppearance>>;
10
+ /** Parameters for the usePersistedAppearanceSeed hook */
11
+ export type UsePersistedAppearanceSeedParams = Readonly<{
12
+ /**
13
+ * localStorage persistence key. Must match the `persistenceKey` passed to
14
+ * `useMapAppearanceControls` on the same surface. Default: "map-appearance-settings"
15
+ */
16
+ persistenceKey?: string;
17
+ /**
18
+ * The surface's appearance options (the same object given to
19
+ * `useMapAppearanceControls` works). Only `traffic` affects the seed: a
20
+ * surface with `traffic: false` must not seed a traffic-on preference stored
21
+ * by another surface sharing the persistence key. `themes`/`mapTypes` never
22
+ * clamp the seed, matching the controls' push path, which does not clamp
23
+ * stored values either — hence the narrowed Pick.
24
+ */
25
+ options?: Pick<AppearanceOptions, "traffic">;
26
+ }>;
27
+ /**
28
+ * Reads the persisted map appearance once (on mount) so it can seed the adapter
29
+ * factory, letting the FIRST map be constructed with the user's stored theme,
30
+ * map type, and traffic preference instead of being corrected after mount.
31
+ *
32
+ * Without the seed, a stored dark theme or traffic-on preference only reaches
33
+ * the adapter once the map is ready, forcing an immediate map recreation on
34
+ * every page load: a visible blink, a wasted provider map construction (each
35
+ * one is a billed Dynamic Maps load on Google), and overlays built on a
36
+ * throwaway instance (ADR 0029).
37
+ *
38
+ * Read-only: `useMapAppearanceControls` keeps owning the persistence and pushes
39
+ * all later changes through the adapter's appearance actions. The seed is
40
+ * deliberately not reactive — it only matters at construction time.
41
+ *
42
+ * Storage being unavailable or corrupt is never an error: the seed then falls
43
+ * back to the defaults, which is exactly the pre-seeding behavior.
44
+ *
45
+ * @example
46
+ * ```tsx
47
+ * const appearanceSeed = usePersistedAppearanceSeed();
48
+ * const [Map, api] = useMap(googleMapsAdapter({ apiKey, ...appearanceSeed }));
49
+ * ```
50
+ */
51
+ export declare const usePersistedAppearanceSeed: (params?: UsePersistedAppearanceSeedParams) => MapAppearanceSeed;
@@ -1,4 +1,5 @@
1
1
  import type { AdapterConfig } from "@trackunit/react-map-adapter-shared";
2
+ import type { AppearanceOptions } from "../appearance/appearanceTypes";
2
3
  import type { ContainerRefHolder, MapActions, MapStatus } from "../core/types";
3
4
  import type { BuiltInControlsConfig } from "./types";
4
5
  /**
@@ -21,6 +22,18 @@ export type UseDefaultControlsOptions = Readonly<{
21
22
  settings?: Readonly<{
22
23
  mapStyle?: Readonly<{
23
24
  enabled?: boolean;
25
+ /**
26
+ * Appearance options forwarded to `useMapAppearanceControls`. A surface
27
+ * that also uses `usePersistedAppearanceSeed` must pass the same options
28
+ * to both, so opt-outs (e.g. `traffic: false`) apply to the seed too.
29
+ */
30
+ options?: AppearanceOptions;
31
+ /**
32
+ * localStorage key forwarded to `useMapAppearanceControls`. A surface
33
+ * that also uses `usePersistedAppearanceSeed` must pass the same key to
34
+ * both. Default: "map-appearance-settings"
35
+ */
36
+ persistenceKey?: string;
24
37
  }>;
25
38
  }>;
26
39
  }>;
@@ -1,7 +1,7 @@
1
- import type { AdapterConfig, AdapterInstance, AdapterRendererProps, CameraState, MapStatus, SafeAreaInsets } from "@trackunit/react-map-adapter-shared";
1
+ import type { AdapterConfig, AdapterInstance, AdapterRendererProps, BaseAdapterConfig, CameraState, MapStatus, SafeAreaInsets } from "@trackunit/react-map-adapter-shared";
2
2
  import type { ReactElement } from "react";
3
3
  import type { MapApi, MapState } from "./types";
4
- export type MockConfig = {
4
+ export type MockConfig = BaseAdapterConfig & {
5
5
  readonly apiKey: string;
6
6
  };
7
7
  export declare const INITIAL_MOCK_MAP_STATE: MapState;
package/src/index.d.ts CHANGED
@@ -17,12 +17,14 @@ export type { UseControlsAreas } from "./controls/types";
17
17
  export { useControls } from "./controls/useControls";
18
18
  export { useDefaultControls, type UseDefaultControlsOptions } from "./controls/useDefaultControls";
19
19
  export type { AppearanceAction, AppearanceOption, AppearanceOptions, MapAppearanceApi, PersistedAppearance, UseMapAppearanceControlsParams, } from "./appearance/appearanceTypes";
20
+ export { DEFAULT_APPEARANCE_PERSISTENCE_KEY } from "./appearance/appearanceTypes";
20
21
  export { useMapAppearanceControls } from "./appearance/useMapAppearanceControls";
22
+ export { usePersistedAppearanceSeed, type MapAppearanceSeed, type UsePersistedAppearanceSeedParams, } from "./appearance/usePersistedAppearanceSeed";
21
23
  export type { ControlMiddleware, MiddlewareContext } from "./controls/renderingRules";
22
24
  export type { CategoryMetadata } from "./controls/collapseControls";
23
25
  export type { ClusterEntity, Entity, MapInteractionState, MarkerEntity, RouteEntity, ShapeEntity, ShapeType, } from "@trackunit/react-map-adapter-shared";
24
26
  export { geometryTypeToShapeType, INITIAL_INTERACTION_STATE } from "@trackunit/react-map-adapter-shared";
25
- export type { AdaptiveMarkerResolution, AdaptiveRenderConfig, AdaptiveRenderState, AdaptiveResolutionContext, CircleSymbolDescriptor, ClientClusterConfig, ClusterConfig, ClusterInfo, ClusterRenderState, ClusterSymbolStyle, CommonRenderState, DomPortalStackingInput, DomPortalStackingResolver, DomPortalStackingResult, DomRenderConfig, DomRenderState, FitParticipation, GeoJsonFeature, GeoJsonFeatureCollection, GeoJsonGeometry, ImageOverlayHandle, LayerHandle, LayerMeta, MarkerAnchor, MarkerDomPortalStackGeometry, MarkerDomPortalStackPhase, MarkerLayerHandle, RenderConfig, RenderMedium, ResolutionContext, RouteLayerHandle, RouteStyle, ServerClusterConfig, ShapeInteractiveMode, ShapeLayerHandle, ShapeOverlapConfig, ShapeStyle, ShapeStyleOverrides, ShapesUnderCursorHit, SymbolDescriptor, SymbolRenderConfig, SymbolRenderState, } from "./layers/types";
27
+ export type { AdaptiveMarkerResolution, AdaptiveRenderConfig, AdaptiveRenderState, AdaptiveResolutionContext, CircleSymbolDescriptor, ClientClusterConfig, ClusterConfig, ClusterInfo, ClusterRenderState, ClusterSymbolStyle, CommonRenderState, DomPortalStackingInput, DomPortalStackingResolver, DomPortalStackingResult, DomRenderConfig, DomRenderState, FitParticipation, GeoJsonFeature, GeoJsonFeatureCollection, GeoJsonGeometry, HeatmapLayerHandle, ImageOverlayHandle, LayerHandle, LayerMeta, MarkerAnchor, MarkerDomPortalStackGeometry, MarkerDomPortalStackPhase, MarkerLayerHandle, RenderConfig, RenderMedium, ResolutionContext, RouteLayerHandle, RouteStyle, ServerClusterConfig, ShapeInteractiveMode, ShapeLayerHandle, ShapeOverlapConfig, ShapeStyle, ShapeStyleOverrides, ShapesUnderCursorHit, SymbolDescriptor, SymbolRenderConfig, SymbolRenderState, } from "./layers/types";
26
28
  export { computeMarkerDomPortalZIndex } from "./layers/types";
27
29
  export { ANCHOR_SELECTOR, HIT_SURFACE_SELECTOR } from "@trackunit/react-map-adapter-shared";
28
30
  export { ClusterMarker, type ClusterMarkerProps, type ClusterMarkerStickInput, type ClusterSegment, } from "./clusters/ClusterMarker";
@@ -53,6 +55,7 @@ export { useImageOverlay, type UseImageOverlayOptions, type UseImageOverlayRetur
53
55
  export { buildExpandedIds, useExpandedIds, type MapFocus, type MapFocusTier, type MapFocusTierDisplay, } from "./layers/mapFocus";
54
56
  export { useRoute, type UseRouteOptions, type UseRouteReturn } from "./layers/routes/useRoute";
55
57
  export { useFitToContent, type FitToContentOptions, type FitToContentResult } from "./layers/useFitToContent";
58
+ export { useHeatmap, type HeatmapStyle, type UseHeatmapOptions, type UseHeatmapReturn, } from "./layers/useHeatmap/useHeatmap";
56
59
  export { useLabelPlacement, type UseLabelPlacementParams } from "./layers/useLabelPlacement";
57
60
  export { useLayers, type UseLayersReturn } from "./layers/useLayers";
58
61
  export { DEFAULT_MARKER_SIZE_BREAKPOINTS, MARKER_DISC_BORDER_WIDTH_PX, type MarkerSizeBreakpoint, type PickMarkerSizeOptions, type PickRenderMediumOptions, type PickSymbolDiameterOptions, } from "./layers/useMarkers/adaptiveHelpers";
@@ -1,12 +1,12 @@
1
1
  import type { GeoJsonBbox, GeoJsonFeature, GeoJsonFeatureCollection, GeoJsonGeometry, GeoJsonPosition } from "@trackunit/geo-json-utils";
2
- import type { AdaptiveMarkerResolution, ClusterConfig, ClusterRenderConfig, RenderConfig, RouteStyle, ShapeInteractiveMode, ShapeStyle } from "@trackunit/react-map-adapter-shared";
2
+ import type { AdaptiveMarkerResolution, ClusterConfig, ClusterRenderConfig, HeatmapRenderStyle, RenderConfig, RouteStyle, ShapeInteractiveMode, ShapeStyle } from "@trackunit/react-map-adapter-shared";
3
3
  import type { CategoryKey, ControlConfig } from "../controls/types";
4
4
  import type { ShapeDecoration } from "./useShapes/shapeDecorations";
5
5
  import type { ShapesUnderCursorHit } from "./useShapes/shapeFillTiling";
6
6
  import type { ShapeLabelResolutionContext } from "./useShapes/shapeLabelResolution";
7
7
  import type { ResolveShapeStackOrder } from "./useShapes/shapeStackOrder";
8
8
  export type { ShapesUnderCursorHit };
9
- export type { AdaptiveMarkerResolution, AdaptiveRenderConfig, AdaptiveRenderState, AdaptiveResolutionContext, CircleSymbolDescriptor, ClientClusterConfig, ClusterConfig, ClusterDomRenderConfig, ClusterInfo, ClusterRenderConfig, ClusterRenderState, ClusterSymbolRenderConfig, ClusterSymbolStyle, CommonRenderState, DomPortalStackingInput, DomPortalStackingResolver, DomPortalStackingResult, DomRenderConfig, DomRenderState, GeoJsonFeature, GeoJsonFeatureCollection, GeoJsonGeometry, MarkerAnchor, MarkerDomPortalStackGeometry, MarkerDomPortalStackPhase, PixelOffset, RenderConfig, RenderMedium, ResolutionContext, RouteStyle, ServerClusterConfig, ShapeInteractiveMode, ShapeStyle, ShapeStyleOverrides, SymbolDescriptor, SymbolRenderConfig, SymbolRenderState, } from "@trackunit/react-map-adapter-shared";
9
+ export type { AdaptiveMarkerResolution, AdaptiveRenderConfig, AdaptiveRenderState, AdaptiveResolutionContext, CircleSymbolDescriptor, ClientClusterConfig, ClusterConfig, ClusterDomRenderConfig, ClusterInfo, ClusterRenderConfig, ClusterRenderState, ClusterSymbolRenderConfig, ClusterSymbolStyle, CommonRenderState, DomPortalStackingInput, DomPortalStackingResolver, DomPortalStackingResult, DomRenderConfig, DomRenderState, GeoJsonFeature, GeoJsonFeatureCollection, GeoJsonGeometry, HeatmapRenderStyle, MarkerAnchor, MarkerDomPortalStackGeometry, MarkerDomPortalStackPhase, PixelOffset, RenderConfig, RenderMedium, ResolutionContext, RouteStyle, ServerClusterConfig, ShapeInteractiveMode, ShapeStyle, ShapeStyleOverrides, SymbolDescriptor, SymbolRenderConfig, SymbolRenderState, } from "@trackunit/react-map-adapter-shared";
10
10
  export { computeMarkerDomPortalZIndex } from "@trackunit/react-map-adapter-shared";
11
11
  /**
12
12
  * Controls whether a layer's bounds are included in fit-to-content operations.
@@ -187,8 +187,13 @@ export interface ImageOverlayHandle extends LayerMeta {
187
187
  /** Opacity from 0 to 1 */
188
188
  readonly opacity: number;
189
189
  }
190
+ export interface HeatmapLayerHandle extends LayerMeta {
191
+ readonly layerType: "heatmap";
192
+ readonly features: GeoJsonFeatureCollection;
193
+ readonly style: HeatmapRenderStyle;
194
+ }
190
195
  /**
191
196
  * Discriminated union of all layer handle types.
192
197
  * Discriminated on `layerType`.
193
198
  */
194
- export type LayerHandle = MarkerLayerHandle | ShapeLayerHandle | RouteLayerHandle | ImageOverlayHandle;
199
+ export type LayerHandle = MarkerLayerHandle | ShapeLayerHandle | RouteLayerHandle | ImageOverlayHandle | HeatmapLayerHandle;
@@ -0,0 +1,7 @@
1
+ import type { GeoJsonBbox, GeoJsonPosition } from "@trackunit/geo-json-utils";
2
+ export type HeatmapPocPoint = Readonly<{
3
+ position: GeoJsonPosition;
4
+ weight: number;
5
+ }>;
6
+ export declare const HEATMAP_POC_BOUNDS: GeoJsonBbox;
7
+ export declare const HEATMAP_POC_POINTS: ReadonlyArray<HeatmapPocPoint>;
@@ -0,0 +1,31 @@
1
+ import { type GeoJsonPosition } from "@trackunit/geo-json-utils";
2
+ import type { CategoryKey, ControlConfig } from "../../controls/types";
3
+ import type { FitParticipation, HeatmapLayerHandle } from "../types";
4
+ export type HeatmapStyle = Readonly<{
5
+ radiusPixels?: number;
6
+ intensity?: number;
7
+ opacity?: number;
8
+ }>;
9
+ export type UseHeatmapOptions<TItem> = Readonly<{
10
+ id: string;
11
+ name: string;
12
+ data: ReadonlyArray<TItem>;
13
+ getPosition: (item: TItem) => GeoJsonPosition | null;
14
+ getWeight?: (item: TItem) => number;
15
+ style?: HeatmapStyle;
16
+ controls?: Partial<Record<CategoryKey, ReadonlyArray<ControlConfig>>>;
17
+ loading?: boolean;
18
+ fitParticipation?: FitParticipation;
19
+ }>;
20
+ export interface UseHeatmapReturn extends HeatmapLayerHandle {
21
+ readonly pointCount: number;
22
+ readonly totalCount: number;
23
+ }
24
+ /**
25
+ * Weighted point heatmap layer handle (provider-agnostic).
26
+ *
27
+ * - Normalizes `data` → GeoJSON `Point` features + `properties.weight`.
28
+ * - Synced as `HeatmapSourceConfig` in {@link LayerSnapshot}.
29
+ * - Renderers: ADR-0029 (`libs/react/map/docs/adr/0029-heatmap-provider-specific-renderers.md`).
30
+ */
31
+ export declare const useHeatmap: <TItem>(options: UseHeatmapOptions<TItem>) => UseHeatmapReturn;