@smartnet360/svelte-components 0.0.74 → 0.0.76

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.
@@ -40,6 +40,8 @@
40
40
  navigationPosition?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
41
41
  /** Position for scale control */
42
42
  scalePosition?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
43
+ /** List of anchor layer IDs to create (invisible background layers for positioning) */
44
+ anchorLayerIds?: string[];
43
45
  /** Custom CSS class for container */
44
46
  class?: string;
45
47
  /** Optional child content (layers, controls, etc.) */
@@ -59,6 +61,7 @@
59
61
  controls = [],
60
62
  navigationPosition = 'top-right',
61
63
  scalePosition = 'bottom-left',
64
+ anchorLayerIds = [],
62
65
  class: className = '',
63
66
  children
64
67
  }: Props = $props();
@@ -71,6 +74,25 @@
71
74
  let map: MapboxMap | null = null;
72
75
  let isExternalMap = false;
73
76
 
77
+ /**
78
+ * Creates invisible anchor layers for predictable layer ordering
79
+ * These are background layers with visibility: none that serve as positioning markers
80
+ */
81
+ function createAnchorLayers(mapInstance: MapboxMap, layerIds: string[]) {
82
+ layerIds.forEach((layerId) => {
83
+ // Check if layer already exists (e.g., when style reloads)
84
+ if (!mapInstance.getLayer(layerId)) {
85
+ mapInstance.addLayer({
86
+ id: layerId,
87
+ type: 'background',
88
+ layout: {
89
+ visibility: 'none'
90
+ }
91
+ });
92
+ }
93
+ });
94
+ }
95
+
74
96
  onMount(() => {
75
97
  // If external map is provided, use it instead of creating a new one
76
98
  if (externalMap) {
@@ -129,10 +151,17 @@
129
151
 
130
152
  // Wait for style to load before distributing map
131
153
  const onStyleLoad = () => {
154
+ // Create anchor layers if specified
155
+ if (anchorLayerIds.length > 0 && map) {
156
+ createAnchorLayers(map, anchorLayerIds);
157
+ }
132
158
  mapStore.set(map);
133
159
  };
134
160
 
135
161
  if (map.isStyleLoaded()) {
162
+ if (anchorLayerIds.length > 0) {
163
+ createAnchorLayers(map, anchorLayerIds);
164
+ }
136
165
  mapStore.set(map);
137
166
  } else {
138
167
  map.once('style.load', onStyleLoad);
@@ -25,6 +25,8 @@ interface Props {
25
25
  navigationPosition?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
26
26
  /** Position for scale control */
27
27
  scalePosition?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
28
+ /** List of anchor layer IDs to create (invisible background layers for positioning) */
29
+ anchorLayerIds?: string[];
28
30
  /** Custom CSS class for container */
29
31
  class?: string;
30
32
  /** Optional child content (layers, controls, etc.) */
@@ -14,7 +14,7 @@
14
14
  import type { CellStoreContext } from '../stores/cellStoreContext.svelte';
15
15
  import { useMapbox } from '../../../core/hooks/useMapbox';
16
16
  import { cellsToGeoJSON } from '../utils/cellGeoJSON';
17
- import { CELL_FILL_Z_INDEX, CELL_LINE_Z_INDEX } from '../constants/zIndex';
17
+ import { CELL_FILL_Z_INDEX, CELL_LINE_Z_INDEX, Z_INDEX_BY_BAND } from '../constants/zIndex';
18
18
 
19
19
  interface Props {
20
20
  /** Cell store context */
@@ -24,28 +24,34 @@
24
24
  }
25
25
 
26
26
  let { store, namespace }: Props = $props();
27
-
28
- const FILL_LAYER_ID = `${namespace}-cells-fill`;
29
- const LINE_LAYER_ID = `${namespace}-cells-line`;
27
+
30
28
  const SOURCE_ID = `${namespace}-cells`;
31
-
29
+
32
30
  // Get map from mapbox hook
33
31
  const mapStore = useMapbox();
34
-
32
+
35
33
  let map = $state<MapboxMap | null>(null);
36
34
  let mounted = $state(false);
37
35
  let viewportUpdateTimer: ReturnType<typeof setTimeout> | null = null;
38
36
  let viewportVersion = $state(0); // Increment this to force $effect re-run on viewport changes
39
-
37
+
38
+ // Get all unique z-index values and sort them
39
+ const sortedZIndexes = [...new Set(Object.values(Z_INDEX_BY_BAND))].sort((a, b) => a - b);
40
+
41
+ // Create layer IDs for each z-index
42
+ const fillLayerIds = sortedZIndexes.map((zIndex) => `${namespace}-cells-fill-${zIndex}`);
43
+ const lineLayerIds = sortedZIndexes.map((zIndex) => `${namespace}-cells-line-${zIndex}`);
44
+ const allLayerIds = [...fillLayerIds, ...lineLayerIds];
45
+
40
46
  // Viewport change handler (pan/zoom/move) with debouncing
41
47
  function handleMoveEnd() {
42
48
  if (!map) return;
43
-
49
+
44
50
  // Clear any existing timer
45
51
  if (viewportUpdateTimer) {
46
52
  clearTimeout(viewportUpdateTimer);
47
53
  }
48
-
54
+
49
55
  // Debounce: wait 200ms after last move event before re-rendering
50
56
  viewportUpdateTimer = setTimeout(() => {
51
57
  if (!map) return;
@@ -57,86 +63,85 @@
57
63
  viewportVersion++;
58
64
  }, 200);
59
65
  }
60
-
66
+
61
67
  /**
62
68
  * Initialize or reinitialize the cell layers
63
69
  * Called on mount and when map style changes
64
70
  */
65
71
  function initializeLayer() {
66
72
  if (!map) return;
67
-
73
+
68
74
  console.log('CellsLayer: initializeLayer called');
69
-
75
+
70
76
  // Set initial zoom
71
77
  store.setCurrentZoom(map.getZoom());
72
-
78
+
73
79
  // Add moveend listener if not already added
74
80
  // Note: We need to be careful not to duplicate listeners
75
81
  map.off('moveend', handleMoveEnd); // Remove if exists
76
- map.on('moveend', handleMoveEnd); // Add it back
77
-
82
+ map.on('moveend', handleMoveEnd); // Add it back
83
+
78
84
  // Mark as mounted to trigger $effect
79
85
  mounted = true;
80
-
86
+
81
87
  // Force $effect to re-run by incrementing viewportVersion
82
88
  // This is critical after style.load when layers need to be recreated
83
89
  // but mounted/map haven't changed (they're already true/set)
84
90
  viewportVersion++;
85
91
  }
86
-
92
+
87
93
  onMount(() => {
88
94
  console.log('CellsLayer: onMount, waiting for map...');
89
-
95
+
90
96
  // Subscribe to map store
91
97
  const unsubscribe = mapStore.subscribe((mapInstance) => {
92
98
  if (mapInstance && !map) {
93
99
  console.log('CellsLayer: Map available, initializing...');
94
100
  map = mapInstance;
95
-
101
+
96
102
  // Initial layer setup
97
103
  initializeLayer();
98
-
104
+
99
105
  // Re-initialize layer when map style changes
100
106
  // Mapbox removes all custom layers/sources when setStyle() is called
101
107
  map.on('style.load', initializeLayer);
102
108
  }
103
109
  });
104
-
110
+
105
111
  return () => {
106
112
  unsubscribe();
107
113
  };
108
114
  });
109
-
115
+
110
116
  onDestroy(() => {
111
117
  if (!map) return;
112
-
118
+
113
119
  // Clean up timer
114
120
  if (viewportUpdateTimer) {
115
121
  clearTimeout(viewportUpdateTimer);
116
122
  }
117
-
123
+
118
124
  // Remove style.load listener
119
125
  map.off('style.load', initializeLayer);
120
-
126
+
121
127
  map.off('moveend', handleMoveEnd);
122
-
128
+
123
129
  // Clean up layers and source
124
- if (map.getLayer(LINE_LAYER_ID)) {
125
- map.removeLayer(LINE_LAYER_ID);
126
- }
127
- if (map.getLayer(FILL_LAYER_ID)) {
128
- map.removeLayer(FILL_LAYER_ID);
129
- }
130
+ allLayerIds.forEach((id) => {
131
+ if (map.getLayer(id)) {
132
+ map.removeLayer(id);
133
+ }
134
+ });
130
135
  if (map.getSource(SOURCE_ID)) {
131
136
  map.removeSource(SOURCE_ID);
132
137
  }
133
138
  });
134
-
139
+
135
140
  // Reactive: Update GeoJSON when cells/zoom/filters/settings change
136
141
  $effect(() => {
137
142
  // Track viewportVersion to force re-run on pan (even without zoom change)
138
143
  viewportVersion;
139
-
144
+
140
145
  console.log('CellsLayer $effect triggered:', {
141
146
  mounted,
142
147
  hasMap: !!map,
@@ -146,35 +151,35 @@
146
151
  baseRadius: store.baseRadius,
147
152
  viewportVersion
148
153
  });
149
-
150
- if (!mounted || !map || !store.showCells) {
154
+
155
+ if (!mounted || !map) {
156
+ return;
157
+ }
158
+
159
+ if (!store.showCells) {
151
160
  // Remove layers if showCells is false
152
- if (mounted && map) {
153
- console.log('CellsLayer: Removing layers (showCells=false or not mounted)');
154
- if (map.getLayer(LINE_LAYER_ID)) {
155
- map.removeLayer(LINE_LAYER_ID);
156
- }
157
- if (map.getLayer(FILL_LAYER_ID)) {
158
- map.removeLayer(FILL_LAYER_ID);
159
- }
160
- if (map.getSource(SOURCE_ID)) {
161
- map.removeSource(SOURCE_ID);
161
+ allLayerIds.forEach((id) => {
162
+ if (map.getLayer(id)) {
163
+ map.removeLayer(id);
162
164
  }
165
+ });
166
+ if (map.getSource(SOURCE_ID)) {
167
+ map.removeSource(SOURCE_ID);
163
168
  }
164
169
  return;
165
170
  }
166
-
171
+
167
172
  // Filter cells by viewport bounds (only render visible cells)
168
173
  const bounds = map.getBounds();
169
174
  if (!bounds) {
170
175
  console.warn('CellsLayer: Cannot get map bounds, skipping viewport filter');
171
176
  return;
172
177
  }
173
-
174
- const visibleCells = store.filteredCells.filter(cell =>
178
+
179
+ const visibleCells = store.filteredCells.filter((cell) =>
175
180
  bounds.contains([cell.longitude, cell.latitude])
176
181
  );
177
-
182
+
178
183
  console.log('CellsLayer: Viewport filtering:', {
179
184
  totalFiltered: store.filteredCells.length,
180
185
  visibleInViewport: visibleCells.length,
@@ -185,7 +190,7 @@
185
190
  west: bounds.getWest()
186
191
  }
187
192
  });
188
-
193
+
189
194
  // Generate GeoJSON from visible cells only
190
195
  const geoJSON = cellsToGeoJSON(
191
196
  visibleCells,
@@ -194,12 +199,12 @@
194
199
  store.groupColorMap,
195
200
  store.cellGroupMap // Pass lookup map for O(1) color lookup
196
201
  );
197
-
202
+
198
203
  console.log('CellsLayer: Generated GeoJSON:', {
199
204
  featureCount: geoJSON.features.length,
200
205
  firstFeature: geoJSON.features[0]
201
206
  });
202
-
207
+
203
208
  // Update or create source
204
209
  const source = map.getSource(SOURCE_ID);
205
210
  if (source && source.type === 'geojson') {
@@ -212,60 +217,76 @@
212
217
  data: geoJSON
213
218
  });
214
219
  }
215
-
216
- // Add fill layer if not exists
217
- if (!map.getLayer(FILL_LAYER_ID)) {
218
- console.log('CellsLayer: Creating fill layer');
219
- map.addLayer({
220
- id: FILL_LAYER_ID,
221
- type: 'fill',
222
- source: SOURCE_ID,
223
- paint: {
224
- 'fill-color': [
225
- 'coalesce',
226
- ['get', 'groupColor'],
227
- ['get', 'techBandColor'],
228
- '#888888' // Fallback
229
- ],
230
- 'fill-opacity': store.fillOpacity
231
- },
232
- metadata: {
233
- zIndex: CELL_FILL_Z_INDEX
234
- }
235
- });
236
- } else {
237
- // Update fill opacity
238
- console.log('CellsLayer: Updating fill opacity:', store.fillOpacity);
239
- map.setPaintProperty(FILL_LAYER_ID, 'fill-opacity', store.fillOpacity);
240
- }
241
-
242
- // Add line layer if not exists
243
- if (!map.getLayer(LINE_LAYER_ID)) {
244
- console.log('CellsLayer: Creating line layer');
245
- map.addLayer({
246
- id: LINE_LAYER_ID,
247
- type: 'line',
248
- source: SOURCE_ID,
249
- paint: {
250
- 'line-color': ['get', 'lineColor'],
251
- 'line-width': store.lineWidth,
252
- 'line-opacity': ['get', 'lineOpacity'],
253
- 'line-dasharray': [
254
- 'case',
255
- ['has', 'dashArray'],
256
- ['get', 'dashArray'],
257
- ['literal', [1, 0]] // Solid line default
258
- ]
259
- },
260
- metadata: {
261
- zIndex: CELL_LINE_Z_INDEX
262
- }
263
- });
264
- } else {
265
- // Update line width
266
- console.log('CellsLayer: Updating line width:', store.lineWidth);
267
- map.setPaintProperty(LINE_LAYER_ID, 'line-width', store.lineWidth);
220
+
221
+ // Get all unique bands for the current z-index
222
+ const bandsByZIndex = new Map<number, string[]>();
223
+ for (const [band, zIndex] of Object.entries(Z_INDEX_BY_BAND)) {
224
+ if (!bandsByZIndex.has(zIndex)) {
225
+ bandsByZIndex.set(zIndex, []);
226
+ }
227
+ bandsByZIndex.get(zIndex)?.push(band);
268
228
  }
229
+
230
+ // Add/update layers for each z-index level
231
+ sortedZIndexes.forEach((zIndex) => {
232
+ const fillLayerId = `${namespace}-cells-fill-${zIndex}`;
233
+ const lineLayerId = `${namespace}-cells-line-${zIndex}`;
234
+ const bands = bandsByZIndex.get(zIndex) || [];
235
+
236
+ // Add fill layer if not exists
237
+ if (!map.getLayer(fillLayerId)) {
238
+ console.log(`CellsLayer: Creating fill layer for z-index ${zIndex}`);
239
+ map.addLayer({
240
+ id: fillLayerId,
241
+ type: 'fill',
242
+ source: SOURCE_ID,
243
+ filter: ['in', ['get', 'techBandKey'], ['literal', bands]],
244
+ paint: {
245
+ 'fill-color': [
246
+ 'coalesce',
247
+ ['get', 'groupColor'],
248
+ ['get', 'techBandColor'],
249
+ '#888888' // Fallback
250
+ ],
251
+ 'fill-opacity': store.fillOpacity
252
+ },
253
+ metadata: {
254
+ zIndex: CELL_FILL_Z_INDEX + zIndex
255
+ }
256
+ });
257
+ } else {
258
+ // Update fill opacity
259
+ map.setPaintProperty(fillLayerId, 'fill-opacity', store.fillOpacity);
260
+ }
261
+
262
+ // Add line layer if not exists
263
+ if (!map.getLayer(lineLayerId)) {
264
+ console.log(`CellsLayer: Creating line layer for z-index ${zIndex}`);
265
+ map.addLayer({
266
+ id: lineLayerId,
267
+ type: 'line',
268
+ source: SOURCE_ID,
269
+ filter: ['in', ['get', 'techBandKey'], ['literal', bands]],
270
+ paint: {
271
+ 'line-color': ['get', 'lineColor'],
272
+ 'line-width': store.lineWidth,
273
+ 'line-opacity': ['get', 'lineOpacity'],
274
+ 'line-dasharray': [
275
+ 'case',
276
+ ['has', 'dashArray'],
277
+ ['get', 'dashArray'],
278
+ ['literal', [1, 0]] // Solid line default
279
+ ]
280
+ },
281
+ metadata: {
282
+ zIndex: CELL_LINE_Z_INDEX + zIndex
283
+ }
284
+ });
285
+ } else {
286
+ // Update line width
287
+ map.setPaintProperty(lineLayerId, 'line-width', store.lineWidth);
288
+ }
289
+ });
269
290
  });
270
291
  </script>
271
292
 
@@ -74,22 +74,10 @@ export function createCellStoreContext(cells) {
74
74
  });
75
75
  // Derived: Filter cells by status based on includePlannedCells flag
76
76
  // IMPORTANT: This is a pure $derived - it only READS from state, never writes
77
- // Memoized to avoid re-filtering when cells array reference hasn't changed
78
- let lastCellsRef = null;
79
- let lastIncludePlanned = null;
80
- let cachedFilteredCells = [];
81
- let cellsFilteredByStatus = $derived.by(() => {
82
- // Only recompute if cells reference or flag changed
83
- if (state.cells === lastCellsRef && state.includePlannedCells === lastIncludePlanned) {
84
- return cachedFilteredCells;
85
- }
86
- lastCellsRef = state.cells;
87
- lastIncludePlanned = state.includePlannedCells;
88
- cachedFilteredCells = state.includePlannedCells
89
- ? state.cells // Include all cells
90
- : state.cells.filter(cell => cell.status.startsWith('On_Air')); // Only On Air cells
91
- return cachedFilteredCells;
92
- });
77
+ let cellsFilteredByStatus = $derived(state.includePlannedCells
78
+ ? state.cells // Include all cells
79
+ : state.cells.filter(cell => cell.status.startsWith('On_Air')) // Only On Air cells
80
+ );
93
81
  // Auto-save settings when they change
94
82
  $effect(() => {
95
83
  // Convert Map to plain object for serialization
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import { TECHNOLOGY_BAND_COLORS } from '../constants/colors';
7
7
  import { DEFAULT_STATUS_STYLES } from '../constants/statusStyles';
8
+ import { Z_INDEX_BY_BAND } from '../constants/zIndex';
8
9
  import { calculateRadius } from './zoomScaling';
9
10
  import { createArcPolygon } from './arcGeometry';
10
11
  /**
@@ -61,6 +62,7 @@ export function cellsToGeoJSON(cells, currentZoom, baseRadius = 500, groupColorM
61
62
  techBandKey,
62
63
  techBandColor: defaultColor, // Default color from constants
63
64
  groupColor: groupColor, // Custom color (if set)
65
+ zIndex: Z_INDEX_BY_BAND[techBandKey] || 0, // Z-index for layer ordering
64
66
  // Line (border) styling from status
65
67
  lineColor: statusStyle.lineColor,
66
68
  lineWidth: statusStyle.lineWidth,
@@ -1,25 +1,12 @@
1
1
  /**
2
2
  * Z-Index ordering for repeater tech-band combinations
3
3
  *
4
+ * This uses the same Z_INDEX_BY_BAND constant as cells for consistency.
4
5
  * Higher frequency bands are rendered on top when repeaters overlap.
5
- * This follows the same pattern as cells.
6
6
  */
7
+ import type { TechnologyBandKey } from '../../cells/types';
7
8
  /**
8
- * Z-index ordering for tech:fband combinations
9
+ * Shared z-index ordering by technology-band key
9
10
  * Higher number = rendered on top
10
- *
11
- * Organized by frequency (low to high):
12
- * - Lower frequencies (700-900) at bottom
13
- * - Mid frequencies (1800-2100) in middle
14
- * - Higher frequencies (2600-3500) on top
15
- */
16
- export declare const REPEATER_TECH_BAND_Z_ORDER: Record<string, number>;
17
- /**
18
- * Get z-order index for a tech:fband combination
19
- * Used to set the sort key for layering overlapping repeaters
20
- *
21
- * @param tech - Technology (2G, 4G, 5G)
22
- * @param fband - Frequency band (e.g., "LTE1800", "GSM900")
23
- * @returns Z-order index (defaults to 0 if not found)
24
11
  */
25
- export declare function getRepeaterZOrder(tech: string, fband: string): number;
12
+ export declare const Z_INDEX_BY_BAND: Record<TechnologyBandKey, number>;
@@ -1,43 +1,26 @@
1
1
  /**
2
2
  * Z-Index ordering for repeater tech-band combinations
3
3
  *
4
+ * This uses the same Z_INDEX_BY_BAND constant as cells for consistency.
4
5
  * Higher frequency bands are rendered on top when repeaters overlap.
5
- * This follows the same pattern as cells.
6
6
  */
7
7
  /**
8
- * Z-index ordering for tech:fband combinations
8
+ * Shared z-index ordering by technology-band key
9
9
  * Higher number = rendered on top
10
- *
11
- * Organized by frequency (low to high):
12
- * - Lower frequencies (700-900) at bottom
13
- * - Mid frequencies (1800-2100) in middle
14
- * - Higher frequencies (2600-3500) on top
15
10
  */
16
- export const REPEATER_TECH_BAND_Z_ORDER = {
17
- // 2G bands (lowest)
18
- '2G:GSM900': 1,
19
- '2G:GSM1800': 2,
20
- // 4G bands (medium)
21
- '4G:LTE700': 3,
22
- '4G:LTE800': 4,
23
- '4G:LTE900': 5,
24
- '4G:LTE1800': 6,
25
- '4G:LTE2100': 7,
26
- '4G:LTE2600': 8,
27
- // 5G bands (highest)
28
- '5G:5G-700': 9,
29
- '5G:5G-2100': 10,
30
- '5G:5G-3500': 11,
11
+ export const Z_INDEX_BY_BAND = {
12
+ // 2G bands
13
+ '2G_900': 5,
14
+ '2G_1800': 4,
15
+ // 4G bands
16
+ '4G_700': 2,
17
+ '4G_800': 3,
18
+ '4G_900': 4,
19
+ '4G_1800': 8,
20
+ '4G_2100': 9,
21
+ '4G_2600': 11,
22
+ // 5G bands
23
+ '5G_700': 9,
24
+ '5G_2100': 10,
25
+ '5G_3500': 12
31
26
  };
32
- /**
33
- * Get z-order index for a tech:fband combination
34
- * Used to set the sort key for layering overlapping repeaters
35
- *
36
- * @param tech - Technology (2G, 4G, 5G)
37
- * @param fband - Frequency band (e.g., "LTE1800", "GSM900")
38
- * @returns Z-order index (defaults to 0 if not found)
39
- */
40
- export function getRepeaterZOrder(tech, fband) {
41
- const key = `${tech}:${fband}`;
42
- return REPEATER_TECH_BAND_Z_ORDER[key] ?? 0;
43
- }
@@ -13,4 +13,4 @@ export { repeatersToGeoJSON } from './utils/repeaterGeoJSON';
13
13
  export { buildRepeaterTree, getFilteredRepeaters } from './utils/repeaterTree';
14
14
  export { REPEATER_FILL_Z_INDEX, REPEATER_LINE_Z_INDEX, REPEATER_LABEL_Z_INDEX } from './constants/zIndex';
15
15
  export { REPEATER_RADIUS_MULTIPLIER, getRepeaterRadiusMultiplier } from './constants/radiusMultipliers';
16
- export { REPEATER_TECH_BAND_Z_ORDER, getRepeaterZOrder } from './constants/techBandZOrder';
16
+ export { Z_INDEX_BY_BAND } from './constants/techBandZOrder';
@@ -16,4 +16,4 @@ export { buildRepeaterTree, getFilteredRepeaters } from './utils/repeaterTree';
16
16
  // Constants
17
17
  export { REPEATER_FILL_Z_INDEX, REPEATER_LINE_Z_INDEX, REPEATER_LABEL_Z_INDEX } from './constants/zIndex';
18
18
  export { REPEATER_RADIUS_MULTIPLIER, getRepeaterRadiusMultiplier } from './constants/radiusMultipliers';
19
- export { REPEATER_TECH_BAND_Z_ORDER, getRepeaterZOrder } from './constants/techBandZOrder';
19
+ export { Z_INDEX_BY_BAND } from './constants/techBandZOrder';