@smartnet360/svelte-components 0.0.66 → 0.0.68

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.
@@ -0,0 +1,237 @@
1
+ <script lang="ts">
2
+ /**
3
+ * CellLabelsLayer - Renders tech-aware cell labels positioned along azimuth
4
+ *
5
+ * Features:
6
+ * - Groups cells by site + azimuth (with configurable tolerance)
7
+ * - Projects labels along bearing from site center
8
+ * - Stacks multiple labels vertically when cells share direction
9
+ * - Tech-specific field selection (2G vs 4G/5G)
10
+ * - Configurable styling (size, color, halo, zoom)
11
+ */
12
+
13
+ import { onMount, onDestroy } from 'svelte';
14
+ import { useMapbox } from '../../../core/hooks/useMapbox';
15
+ import { waitForStyleLoad, generateLayerId, generateSourceId } from '../../../shared/utils/mapboxHelpers';
16
+ import type { CellStoreContext } from '../stores/cellStoreContext.svelte';
17
+ import type { Cell } from '../types';
18
+ import * as turf from '@turf/turf';
19
+
20
+ interface Props {
21
+ /** Cell store context */
22
+ store: CellStoreContext;
23
+ /** Unique namespace for layer/source IDs */
24
+ namespace: string;
25
+ }
26
+
27
+ let { store, namespace }: Props = $props();
28
+
29
+ const mapStore = useMapbox();
30
+ const sourceId = generateSourceId(namespace, 'cell-labels');
31
+ const layerId = generateLayerId(namespace, 'cell-labels');
32
+
33
+ let map = $state<mapboxgl.Map | null>(null);
34
+ let unsubscribe: (() => void) | null = null;
35
+
36
+ /**
37
+ * Get label text for a cell based on tech and selected field
38
+ */
39
+ function getCellLabelText(cell: Cell, field: keyof Cell | 'none'): string {
40
+ if (field === 'none') return '';
41
+
42
+ const value = cell[field];
43
+ return value == null ? '' : String(value);
44
+ }
45
+
46
+ /**
47
+ * Round azimuth to nearest tolerance value
48
+ */
49
+ function roundAzimuth(azimuth: number, tolerance: number): number {
50
+ return Math.round(azimuth / tolerance) * tolerance;
51
+ }
52
+
53
+ /**
54
+ * Group cells by site + rounded azimuth
55
+ */
56
+ function groupCellsByAzimuth(cells: Cell[], tolerance: number): Map<string, Cell[]> {
57
+ const groups = new Map<string, Cell[]>();
58
+
59
+ cells.forEach(cell => {
60
+ const roundedAzimuth = roundAzimuth(cell.azimuth, tolerance);
61
+ const groupKey = `${cell.siteId}:${roundedAzimuth}`;
62
+
63
+ if (!groups.has(groupKey)) {
64
+ groups.set(groupKey, []);
65
+ }
66
+ groups.get(groupKey)!.push(cell);
67
+ });
68
+
69
+ return groups;
70
+ }
71
+
72
+ /**
73
+ * Build GeoJSON features for cell labels
74
+ */
75
+ function buildLabelFeatures(): GeoJSON.FeatureCollection {
76
+ const features: GeoJSON.Feature[] = [];
77
+
78
+ if (!store.showLabels || store.filteredCells.length === 0) {
79
+ return { type: 'FeatureCollection', features: [] };
80
+ }
81
+
82
+ // Group cells by site + azimuth
83
+ const cellGroups = groupCellsByAzimuth(store.filteredCells, store.azimuthTolerance);
84
+
85
+ cellGroups.forEach((cells, groupKey) => {
86
+ // Sort cells within group for consistent stacking
87
+ cells.sort((a, b) => a.id.localeCompare(b.id));
88
+
89
+ cells.forEach((cell, index) => {
90
+ // Determine which fields to use based on tech
91
+ const is2G = cell.tech === '2G';
92
+ const primaryField = is2G ? store.primaryLabelField2G : store.primaryLabelField4G5G;
93
+ const secondaryField = is2G ? store.secondaryLabelField2G : store.secondaryLabelField4G5G;
94
+
95
+ // Get label text
96
+ const primaryText = getCellLabelText(cell, primaryField);
97
+ const secondaryText = getCellLabelText(cell, secondaryField);
98
+
99
+ // Build combined label (on single line with separator)
100
+ let labelText = primaryText;
101
+ if (secondaryText) {
102
+ labelText += ` | ${secondaryText}`;
103
+ }
104
+
105
+ if (!labelText.trim()) return; // Skip if no text
106
+
107
+ // Project label position along azimuth
108
+ const origin = turf.point([cell.siteLongitude, cell.siteLatitude]);
109
+ const labelPosition = turf.destination(
110
+ origin,
111
+ store.labelOffset / 1000, // Convert meters to kilometers
112
+ cell.azimuth,
113
+ { units: 'kilometers' }
114
+ );
115
+
116
+ // Calculate vertical offset for stacking (pixels)
117
+ const stackOffset = index * (store.labelSize + 4);
118
+
119
+ features.push({
120
+ type: 'Feature',
121
+ geometry: labelPosition.geometry,
122
+ properties: {
123
+ text: labelText,
124
+ stackOffset,
125
+ cellId: cell.id
126
+ }
127
+ });
128
+ });
129
+ });
130
+
131
+ return { type: 'FeatureCollection', features };
132
+ }
133
+
134
+ /**
135
+ * Update label layer on map
136
+ */
137
+ function updateLabels() {
138
+ if (!map || !map.getSource(sourceId)) return;
139
+
140
+ const geojson = buildLabelFeatures();
141
+ const source = map.getSource(sourceId) as mapboxgl.GeoJSONSource;
142
+ source.setData(geojson);
143
+ }
144
+
145
+ onMount(async () => {
146
+ unsubscribe = mapStore.subscribe(async (m) => {
147
+ if (!m) {
148
+ map = null;
149
+ return;
150
+ }
151
+
152
+ map = m;
153
+
154
+ // Wait for style to load
155
+ await waitForStyleLoad(map);
156
+
157
+ // Add source
158
+ if (!map.getSource(sourceId)) {
159
+ map.addSource(sourceId, {
160
+ type: 'geojson',
161
+ data: { type: 'FeatureCollection', features: [] }
162
+ });
163
+ }
164
+
165
+ // Add label layer
166
+ if (!map.getLayer(layerId)) {
167
+ map.addLayer({
168
+ id: layerId,
169
+ type: 'symbol',
170
+ source: sourceId,
171
+ minzoom: store.minLabelZoom,
172
+ layout: {
173
+ 'text-field': ['get', 'text'],
174
+ 'text-font': ['Open Sans Regular', 'Arial Unicode MS Regular'],
175
+ 'text-size': store.labelSize,
176
+ 'text-anchor': 'center',
177
+ 'text-offset': [
178
+ 0,
179
+ ['/', ['get', 'stackOffset'], store.labelSize] // Dynamic vertical offset
180
+ ],
181
+ 'text-allow-overlap': true,
182
+ 'text-ignore-placement': false,
183
+ 'text-max-width': 999, // Essentially disable wrapping
184
+ 'text-justify': 'center' // Center multi-line text
185
+ },
186
+ paint: {
187
+ 'text-color': store.labelColor,
188
+ 'text-halo-color': store.labelHaloColor,
189
+ 'text-halo-width': store.labelHaloWidth
190
+ }
191
+ });
192
+ }
193
+
194
+ // Initial render
195
+ updateLabels();
196
+ });
197
+ });
198
+
199
+ // Watch for changes in store properties and update labels
200
+ $effect(() => {
201
+ // Dependencies that should trigger label refresh
202
+ store.filteredCells;
203
+ store.showLabels;
204
+ store.primaryLabelField4G5G;
205
+ store.secondaryLabelField4G5G;
206
+ store.primaryLabelField2G;
207
+ store.secondaryLabelField2G;
208
+ store.labelOffset;
209
+ store.azimuthTolerance;
210
+
211
+ updateLabels();
212
+ });
213
+
214
+ // Watch for style changes and update layer paint/layout properties
215
+ $effect(() => {
216
+ if (!map || !map.getLayer(layerId)) return;
217
+
218
+ map.setLayoutProperty(layerId, 'text-size', store.labelSize);
219
+ map.setPaintProperty(layerId, 'text-color', store.labelColor);
220
+ map.setPaintProperty(layerId, 'text-halo-color', store.labelHaloColor);
221
+ map.setPaintProperty(layerId, 'text-halo-width', store.labelHaloWidth);
222
+ map.setLayerZoomRange(layerId, store.minLabelZoom, 24);
223
+ });
224
+
225
+ onDestroy(() => {
226
+ unsubscribe?.();
227
+
228
+ if (map) {
229
+ if (map.getLayer(layerId)) {
230
+ map.removeLayer(layerId);
231
+ }
232
+ if (map.getSource(sourceId)) {
233
+ map.removeSource(sourceId);
234
+ }
235
+ }
236
+ });
237
+ </script>
@@ -0,0 +1,10 @@
1
+ import type { CellStoreContext } from '../stores/cellStoreContext.svelte';
2
+ interface Props {
3
+ /** Cell store context */
4
+ store: CellStoreContext;
5
+ /** Unique namespace for layer/source IDs */
6
+ namespace: string;
7
+ }
8
+ declare const CellLabelsLayer: import("svelte").Component<Props, {}, "">;
9
+ type CellLabelsLayer = ReturnType<typeof CellLabelsLayer>;
10
+ export default CellLabelsLayer;
@@ -13,6 +13,18 @@ export interface CellStoreValue {
13
13
  lineWidth: number;
14
14
  fillOpacity: number;
15
15
  baseRadius: number;
16
+ showLabels: boolean;
17
+ primaryLabelField4G5G: keyof Cell;
18
+ secondaryLabelField4G5G: keyof Cell | 'none';
19
+ primaryLabelField2G: keyof Cell;
20
+ secondaryLabelField2G: keyof Cell | 'none';
21
+ labelSize: number;
22
+ labelColor: string;
23
+ labelOffset: number;
24
+ labelHaloColor: string;
25
+ labelHaloWidth: number;
26
+ minLabelZoom: number;
27
+ azimuthTolerance: number;
16
28
  statusStyles: Map<CellStatus, CellStatusStyle>;
17
29
  groupingConfig: CellTreeConfig;
18
30
  groupColorMap: Map<string, string>;
@@ -32,6 +44,18 @@ export interface CellStoreContext {
32
44
  readonly groupingConfig: CellTreeConfig;
33
45
  readonly groupColorMap: Map<string, string>;
34
46
  readonly cellGroupMap: Map<string, string>;
47
+ readonly showLabels: boolean;
48
+ readonly primaryLabelField4G5G: keyof Cell;
49
+ readonly secondaryLabelField4G5G: keyof Cell | 'none';
50
+ readonly primaryLabelField2G: keyof Cell;
51
+ readonly secondaryLabelField2G: keyof Cell | 'none';
52
+ readonly labelSize: number;
53
+ readonly labelColor: string;
54
+ readonly labelOffset: number;
55
+ readonly labelHaloColor: string;
56
+ readonly labelHaloWidth: number;
57
+ readonly minLabelZoom: number;
58
+ readonly azimuthTolerance: number;
35
59
  setFilteredCells(cells: Cell[]): void;
36
60
  setIncludePlannedCells(value: boolean): void;
37
61
  setShowCells(value: boolean): void;
@@ -46,6 +70,18 @@ export interface CellStoreContext {
46
70
  getGroupColor(groupKey: string): string | undefined;
47
71
  setGroupColor(groupKey: string, color: string): void;
48
72
  clearGroupColor(groupKey: string): void;
73
+ setShowLabels(value: boolean): void;
74
+ setPrimaryLabelField4G5G(field: keyof Cell): void;
75
+ setSecondaryLabelField4G5G(field: keyof Cell | 'none'): void;
76
+ setPrimaryLabelField2G(field: keyof Cell): void;
77
+ setSecondaryLabelField2G(field: keyof Cell | 'none'): void;
78
+ setLabelSize(value: number): void;
79
+ setLabelColor(value: string): void;
80
+ setLabelOffset(value: number): void;
81
+ setLabelHaloColor(value: string): void;
82
+ setLabelHaloWidth(value: number): void;
83
+ setMinLabelZoom(value: number): void;
84
+ setAzimuthTolerance(value: number): void;
49
85
  }
50
86
  /**
51
87
  * Create a cell store context with reactive state
@@ -57,7 +57,20 @@ export function createCellStoreContext(cells) {
57
57
  groupingConfig: persistedSettings.groupingConfig ?? DEFAULT_CELL_TREE_CONFIG,
58
58
  groupColorMap: initialColorMap,
59
59
  cellGroupMap: new Map(), // Will be populated when tree is built
60
- currentZoom: 12 // Default zoom
60
+ currentZoom: 12, // Default zoom
61
+ // Label settings with defaults
62
+ showLabels: persistedSettings.showLabels ?? false,
63
+ primaryLabelField4G5G: (persistedSettings.primaryLabelField4G5G ?? 'fband'),
64
+ secondaryLabelField4G5G: (persistedSettings.secondaryLabelField4G5G ?? 'none'),
65
+ primaryLabelField2G: (persistedSettings.primaryLabelField2G ?? 'bcch'),
66
+ secondaryLabelField2G: (persistedSettings.secondaryLabelField2G ?? 'none'),
67
+ labelSize: persistedSettings.labelSize ?? 12,
68
+ labelColor: persistedSettings.labelColor ?? '#000000',
69
+ labelOffset: persistedSettings.labelOffset ?? 300,
70
+ labelHaloColor: persistedSettings.labelHaloColor ?? '#ffffff',
71
+ labelHaloWidth: persistedSettings.labelHaloWidth ?? 1,
72
+ minLabelZoom: persistedSettings.minLabelZoom ?? 14,
73
+ azimuthTolerance: persistedSettings.azimuthTolerance ?? 5
61
74
  });
62
75
  // Derived: Filter cells by status based on includePlannedCells flag
63
76
  // IMPORTANT: This is a pure $derived - it only READS from state, never writes
@@ -79,7 +92,20 @@ export function createCellStoreContext(cells) {
79
92
  fillOpacity: state.fillOpacity,
80
93
  baseRadius: state.baseRadius,
81
94
  groupingConfig: state.groupingConfig,
82
- groupColors: groupColorsObj
95
+ groupColors: groupColorsObj,
96
+ // Label settings
97
+ showLabels: state.showLabels,
98
+ primaryLabelField4G5G: state.primaryLabelField4G5G,
99
+ secondaryLabelField4G5G: state.secondaryLabelField4G5G,
100
+ primaryLabelField2G: state.primaryLabelField2G,
101
+ secondaryLabelField2G: state.secondaryLabelField2G,
102
+ labelSize: state.labelSize,
103
+ labelColor: state.labelColor,
104
+ labelOffset: state.labelOffset,
105
+ labelHaloColor: state.labelHaloColor,
106
+ labelHaloWidth: state.labelHaloWidth,
107
+ minLabelZoom: state.minLabelZoom,
108
+ azimuthTolerance: state.azimuthTolerance
83
109
  };
84
110
  saveSettings(settings);
85
111
  });
@@ -107,6 +133,19 @@ export function createCellStoreContext(cells) {
107
133
  get groupingConfig() { return state.groupingConfig; },
108
134
  get groupColorMap() { return state.groupColorMap; },
109
135
  get cellGroupMap() { return state.cellGroupMap; },
136
+ // Label getters
137
+ get showLabels() { return state.showLabels; },
138
+ get primaryLabelField4G5G() { return state.primaryLabelField4G5G; },
139
+ get secondaryLabelField4G5G() { return state.secondaryLabelField4G5G; },
140
+ get primaryLabelField2G() { return state.primaryLabelField2G; },
141
+ get secondaryLabelField2G() { return state.secondaryLabelField2G; },
142
+ get labelSize() { return state.labelSize; },
143
+ get labelColor() { return state.labelColor; },
144
+ get labelOffset() { return state.labelOffset; },
145
+ get labelHaloColor() { return state.labelHaloColor; },
146
+ get labelHaloWidth() { return state.labelHaloWidth; },
147
+ get minLabelZoom() { return state.minLabelZoom; },
148
+ get azimuthTolerance() { return state.azimuthTolerance; },
110
149
  // Methods
111
150
  setFilteredCells(cells) {
112
151
  state.filteredCells = cells;
@@ -152,6 +191,43 @@ export function createCellStoreContext(cells) {
152
191
  clearGroupColor(groupKey) {
153
192
  state.groupColorMap.delete(groupKey);
154
193
  state.groupColorMap = new Map(state.groupColorMap);
194
+ },
195
+ // Label setters
196
+ setShowLabels(value) {
197
+ state.showLabels = value;
198
+ },
199
+ setPrimaryLabelField4G5G(field) {
200
+ state.primaryLabelField4G5G = field;
201
+ },
202
+ setSecondaryLabelField4G5G(field) {
203
+ state.secondaryLabelField4G5G = field;
204
+ },
205
+ setPrimaryLabelField2G(field) {
206
+ state.primaryLabelField2G = field;
207
+ },
208
+ setSecondaryLabelField2G(field) {
209
+ state.secondaryLabelField2G = field;
210
+ },
211
+ setLabelSize(value) {
212
+ state.labelSize = value;
213
+ },
214
+ setLabelColor(value) {
215
+ state.labelColor = value;
216
+ },
217
+ setLabelOffset(value) {
218
+ state.labelOffset = value;
219
+ },
220
+ setLabelHaloColor(value) {
221
+ state.labelHaloColor = value;
222
+ },
223
+ setLabelHaloWidth(value) {
224
+ state.labelHaloWidth = value;
225
+ },
226
+ setMinLabelZoom(value) {
227
+ state.minLabelZoom = value;
228
+ },
229
+ setAzimuthTolerance(value) {
230
+ state.azimuthTolerance = value;
155
231
  }
156
232
  };
157
233
  }
@@ -82,13 +82,19 @@ export interface ParsedTechBand {
82
82
  }
83
83
  /**
84
84
  * Available fields for grouping cells in the filter tree
85
+ * Now accepts any property key from Cell interface
85
86
  */
86
- export type CellGroupingField = 'tech' | 'band' | 'status' | 'siteId' | 'customSubgroup' | 'type' | 'planner' | 'none';
87
+ export type CellGroupingField = keyof Cell | 'none';
88
+ /**
89
+ * Optional label map for human-readable field names
90
+ * Maps field keys to display labels
91
+ */
92
+ export type CellGroupingLabels = Partial<Record<CellGroupingField, string>>;
87
93
  /**
88
94
  * Configuration for dynamic tree grouping
89
95
  */
90
96
  export interface CellTreeConfig {
91
- /** Primary grouping field (required) */
97
+ /** Primary grouping field (required, cannot be 'none') */
92
98
  level1: Exclude<CellGroupingField, 'none'>;
93
99
  /** Secondary grouping field (optional, can be 'none') */
94
100
  level2: CellGroupingField;
@@ -8,5 +8,5 @@
8
8
  */
9
9
  export const DEFAULT_CELL_TREE_CONFIG = {
10
10
  level1: 'tech',
11
- level2: 'band'
11
+ level2: 'frq'
12
12
  };
@@ -2,9 +2,9 @@
2
2
  * Cell Tree Builder
3
3
  *
4
4
  * Build hierarchical tree structure for cell filtering
5
- * Structure: Tech Band Status → Individual Cells (configurable)
5
+ * Structure: Configurable hierarchy with any Cell properties
6
6
  */
7
- import type { Cell, CellTreeConfig } from '../types';
7
+ import type { Cell, CellTreeConfig, CellGroupingLabels } from '../types';
8
8
  import type { TreeNode } from '../../../../core/TreeView/tree.model';
9
9
  /**
10
10
  * Build hierarchical tree from flat cell array with dynamic grouping
@@ -12,9 +12,10 @@ import type { TreeNode } from '../../../../core/TreeView/tree.model';
12
12
  * @param cells - Array of cells to build tree from
13
13
  * @param config - Grouping configuration (level1, level2)
14
14
  * @param colorMap - Optional map of leaf group IDs to custom colors
15
+ * @param labelMap - Optional map of field keys to human-readable labels
15
16
  * @returns Object with tree and cell-to-group lookup map
16
17
  */
17
- export declare function buildCellTree(cells: Cell[], config: CellTreeConfig, colorMap?: Map<string, string>): {
18
+ export declare function buildCellTree(cells: Cell[], config: CellTreeConfig, colorMap?: Map<string, string>, labelMap?: CellGroupingLabels): {
18
19
  tree: TreeNode;
19
20
  cellGroupMap: Map<string, string>;
20
21
  };
@@ -2,55 +2,54 @@
2
2
  * Cell Tree Builder
3
3
  *
4
4
  * Build hierarchical tree structure for cell filtering
5
- * Structure: Tech Band Status → Individual Cells (configurable)
5
+ * Structure: Configurable hierarchy with any Cell properties
6
6
  */
7
+ /**
8
+ * Smart sort comparator that handles numeric suffixes
9
+ *
10
+ * Examples:
11
+ * "700", "1800" → sorted as 700, 1800 (numeric)
12
+ * "LTE700", "LTE1800" → sorted as 700, 1800 (numeric suffix)
13
+ * "On_Air", "Planned" → sorted alphabetically
14
+ *
15
+ * @param a - First value to compare
16
+ * @param b - Second value to compare
17
+ * @returns Sort order (-1, 0, 1)
18
+ */
19
+ function smartSort(a, b) {
20
+ // Extract trailing 3-4 digits
21
+ const numRegex = /(\d{3,4})$/;
22
+ const matchA = a.match(numRegex);
23
+ const matchB = b.match(numRegex);
24
+ // If both have numeric suffixes, compare numerically
25
+ if (matchA && matchB) {
26
+ const numA = parseInt(matchA[1], 10);
27
+ const numB = parseInt(matchB[1], 10);
28
+ return numA - numB;
29
+ }
30
+ // Otherwise alphabetical
31
+ return a.localeCompare(b);
32
+ }
7
33
  /**
8
34
  * Get the value of a grouping field from a cell
9
35
  */
10
36
  function getGroupingValue(cell, field) {
11
- switch (field) {
12
- case 'tech':
13
- return cell.tech;
14
- case 'band':
15
- return cell.frq; // Use numeric band from 'frq' field
16
- case 'status':
17
- return cell.status;
18
- case 'siteId':
19
- return cell.siteId;
20
- case 'customSubgroup':
21
- return cell.customSubgroup || 'Ungrouped';
22
- case 'type':
23
- return cell.type;
24
- case 'planner':
25
- return cell.planner || 'Unassigned';
26
- case 'none':
27
- return '';
28
- default:
29
- return 'Unknown';
30
- }
37
+ if (field === 'none')
38
+ return '';
39
+ const value = cell[field];
40
+ // Handle null/undefined
41
+ if (value == null)
42
+ return 'Unassigned';
43
+ // Convert to string
44
+ return String(value);
31
45
  }
32
46
  /**
33
47
  * Get a human-readable label for a grouping field value
34
48
  */
35
- function getGroupLabel(field, value, count) {
36
- switch (field) {
37
- case 'tech':
38
- return `${value} (${count})`;
39
- case 'band':
40
- return `${value} MHz (${count})`;
41
- case 'status':
42
- return `${value} (${count})`;
43
- case 'siteId':
44
- return `${value} (${count})`;
45
- case 'customSubgroup':
46
- return `${value} (${count})`;
47
- case 'type':
48
- return `${value} (${count})`;
49
- case 'planner':
50
- return `${value} (${count})`;
51
- default:
52
- return `${value} (${count})`;
53
- }
49
+ function getGroupLabel(field, value, count, labelMap) {
50
+ // Use custom label if provided
51
+ const fieldLabel = labelMap?.[field] || String(field);
52
+ return `${value} (${count})`;
54
53
  }
55
54
  /**
56
55
  * Generate a unique node ID based on grouping path
@@ -67,21 +66,22 @@ function generateNodeId(field, value, parentId) {
67
66
  * @param cells - Array of cells to build tree from
68
67
  * @param config - Grouping configuration (level1, level2)
69
68
  * @param colorMap - Optional map of leaf group IDs to custom colors
69
+ * @param labelMap - Optional map of field keys to human-readable labels
70
70
  * @returns Object with tree and cell-to-group lookup map
71
71
  */
72
- export function buildCellTree(cells, config, colorMap) {
72
+ export function buildCellTree(cells, config, colorMap, labelMap) {
73
73
  const { level1, level2 } = config;
74
74
  // If level2 is 'none', create flat 2-level tree
75
75
  if (level2 === 'none') {
76
- return buildFlatTree(cells, level1, colorMap);
76
+ return buildFlatTree(cells, level1, colorMap, labelMap);
77
77
  }
78
78
  // Otherwise, create nested 3-level tree
79
- return buildNestedTree(cells, level1, level2, colorMap);
79
+ return buildNestedTree(cells, level1, level2, colorMap, labelMap);
80
80
  }
81
81
  /**
82
82
  * Build flat 2-level tree (Root → Level1 groups)
83
83
  */
84
- function buildFlatTree(cells, level1, colorMap) {
84
+ function buildFlatTree(cells, level1, colorMap, labelMap) {
85
85
  // Group cells by level1 field
86
86
  const groups = new Map();
87
87
  const cellGroupMap = new Map();
@@ -92,8 +92,8 @@ function buildFlatTree(cells, level1, colorMap) {
92
92
  }
93
93
  groups.get(value).push(cell);
94
94
  });
95
- // Sort groups alphabetically
96
- const sortedGroups = Array.from(groups.entries()).sort(([a], [b]) => a.localeCompare(b));
95
+ // Sort groups with smart numeric/alphabetical sorting
96
+ const sortedGroups = Array.from(groups.entries()).sort(([a], [b]) => smartSort(a, b));
97
97
  // Build tree nodes and populate cellGroupMap
98
98
  const children = sortedGroups.map(([value, groupCells]) => {
99
99
  const nodeId = generateNodeId(level1, value);
@@ -105,7 +105,7 @@ function buildFlatTree(cells, level1, colorMap) {
105
105
  });
106
106
  return {
107
107
  id: nodeId,
108
- label: getGroupLabel(level1, value, groupCells.length),
108
+ label: getGroupLabel(level1, value, groupCells.length, labelMap),
109
109
  defaultChecked: true,
110
110
  children: [],
111
111
  metadata: {
@@ -131,7 +131,7 @@ function buildFlatTree(cells, level1, colorMap) {
131
131
  /**
132
132
  * Build nested 3-level tree (Root → Level1 → Level2 groups)
133
133
  */
134
- function buildNestedTree(cells, level1, level2, colorMap) {
134
+ function buildNestedTree(cells, level1, level2, colorMap, labelMap) {
135
135
  // Group cells by level1, then by level2
136
136
  const level1Groups = new Map();
137
137
  const cellGroupMap = new Map();
@@ -147,13 +147,13 @@ function buildNestedTree(cells, level1, level2, colorMap) {
147
147
  }
148
148
  level2Groups.get(value2).push(cell);
149
149
  });
150
- // Sort level1 groups
151
- const sortedLevel1 = Array.from(level1Groups.entries()).sort(([a], [b]) => a.localeCompare(b));
150
+ // Sort level1 groups with smart numeric/alphabetical sorting
151
+ const sortedLevel1 = Array.from(level1Groups.entries()).sort(([a], [b]) => smartSort(a, b));
152
152
  // Build tree nodes and populate cellGroupMap
153
153
  const children = sortedLevel1.map(([value1, level2Groups]) => {
154
154
  const parentId = generateNodeId(level1, value1);
155
- // Sort level2 groups
156
- const sortedLevel2 = Array.from(level2Groups.entries()).sort(([a], [b]) => a.localeCompare(b));
155
+ // Sort level2 groups with smart numeric/alphabetical sorting
156
+ const sortedLevel2 = Array.from(level2Groups.entries()).sort(([a], [b]) => smartSort(a, b));
157
157
  const level2Children = sortedLevel2.map(([value2, groupCells]) => {
158
158
  const nodeId = generateNodeId(level2, value2, parentId);
159
159
  const color = colorMap?.get(nodeId);
@@ -164,7 +164,7 @@ function buildNestedTree(cells, level1, level2, colorMap) {
164
164
  });
165
165
  return {
166
166
  id: nodeId,
167
- label: getGroupLabel(level2, value2, groupCells.length),
167
+ label: getGroupLabel(level2, value2, groupCells.length, labelMap),
168
168
  defaultChecked: true,
169
169
  children: [],
170
170
  metadata: {
@@ -182,7 +182,7 @@ function buildNestedTree(cells, level1, level2, colorMap) {
182
182
  const totalCells = Array.from(level2Groups.values()).flat().length;
183
183
  return {
184
184
  id: parentId,
185
- label: getGroupLabel(level1, value1, totalCells),
185
+ label: getGroupLabel(level1, value1, totalCells, labelMap),
186
186
  defaultChecked: true,
187
187
  children: level2Children,
188
188
  metadata: {
@@ -4,8 +4,8 @@
4
4
  * A decoupled, feature-based architecture for Mapbox cellular network visualization.
5
5
  * Each feature (sites, cells) is completely independent with its own store, layers, and controls.
6
6
  */
7
- export { type MapStore, MAP_CONTEXT_KEY, MapboxProvider, ViewportSync, MapStyleControl, createMapStore, createViewportStore, type ViewportStore, type ViewportState, useMapbox, tryUseMapbox } from './core';
7
+ export { type MapStore, MAP_CONTEXT_KEY, MapboxProvider, ViewportSync, MapStoreBridge, MapStyleControl, createMapStore, createViewportStore, type ViewportStore, type ViewportState, useMapbox, tryUseMapbox } from './core';
8
8
  export { MapControl, addSourceIfMissing, removeSourceIfExists, addLayerIfMissing, removeLayerIfExists, updateGeoJSONSource, removeLayerAndSource, isStyleLoaded, waitForStyleLoad, setFeatureState, removeFeatureState, generateLayerId, generateSourceId } from './shared';
9
- export { type Site, type SiteStoreValue, type SiteStoreContext, createSiteStore, createSiteStoreContext, SitesLayer, SiteFilterControl, SiteSizeSlider, sitesToGeoJSON, siteToFeature, buildSiteTree, getFilteredSites } from './features/sites';
10
- export { type Cell, type CellStatus, type CellStatusStyle, type CellGroupingField, type CellTreeConfig, type TechnologyBandKey, type CellStoreValue, type CellStoreContext, createCellStoreContext, CellsLayer, CellFilterControl, CellStyleControl, cellsToGeoJSON, buildCellTree, getFilteredCells, calculateRadius, getZoomFactor, createArcPolygon, DEFAULT_CELL_TREE_CONFIG, TECHNOLOGY_BAND_COLORS, DEFAULT_STATUS_STYLES, RADIUS_MULTIPLIER } from './features/cells';
9
+ export { type Site, type SiteStoreValue, type SiteStoreContext, createSiteStore, createSiteStoreContext, SitesLayer, SiteFilterControl, SiteSelectionControl, SiteSizeSlider, sitesToGeoJSON, siteToFeature, buildSiteTree, getFilteredSites } from './features/sites';
10
+ export { type Cell, type CellStatus, type CellStatusStyle, type CellGroupingField, type CellGroupingLabels, type CellTreeConfig, type TechnologyBandKey, type CellStoreValue, type CellStoreContext, createCellStoreContext, CellsLayer, CellLabelsLayer, CellFilterControl, CellStyleControl, cellsToGeoJSON, buildCellTree, getFilteredCells, calculateRadius, getZoomFactor, createArcPolygon, DEFAULT_CELL_TREE_CONFIG, TECHNOLOGY_BAND_COLORS, DEFAULT_STATUS_STYLES, RADIUS_MULTIPLIER } from './features/cells';
11
11
  export { DemoMap, demoSites, demoCells } from './demo';