@smartnet360/svelte-components 0.0.71 → 0.0.73
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/dist/map-v2/demo/DemoMap.svelte +29 -1
- package/dist/map-v2/demo/demo-repeaters.d.ts +13 -0
- package/dist/map-v2/demo/demo-repeaters.js +74 -0
- package/dist/map-v2/demo/index.d.ts +1 -0
- package/dist/map-v2/demo/index.js +1 -0
- package/dist/map-v2/features/repeaters/constants/radiusMultipliers.d.ts +26 -0
- package/dist/map-v2/features/repeaters/constants/radiusMultipliers.js +40 -0
- package/dist/map-v2/features/repeaters/constants/techBandZOrder.d.ts +25 -0
- package/dist/map-v2/features/repeaters/constants/techBandZOrder.js +43 -0
- package/dist/map-v2/features/repeaters/constants/zIndex.d.ts +11 -0
- package/dist/map-v2/features/repeaters/constants/zIndex.js +11 -0
- package/dist/map-v2/features/repeaters/controls/RepeaterFilterControl.svelte +172 -0
- package/dist/map-v2/features/repeaters/controls/RepeaterFilterControl.svelte.d.ts +18 -0
- package/dist/map-v2/features/repeaters/index.d.ts +16 -0
- package/dist/map-v2/features/repeaters/index.js +19 -0
- package/dist/map-v2/features/repeaters/layers/RepeaterLabelsLayer.svelte +301 -0
- package/dist/map-v2/features/repeaters/layers/RepeaterLabelsLayer.svelte.d.ts +10 -0
- package/dist/map-v2/features/repeaters/layers/RepeatersLayer.svelte +259 -0
- package/dist/map-v2/features/repeaters/layers/RepeatersLayer.svelte.d.ts +10 -0
- package/dist/map-v2/features/repeaters/stores/repeaterStoreContext.svelte.d.ts +69 -0
- package/dist/map-v2/features/repeaters/stores/repeaterStoreContext.svelte.js +222 -0
- package/dist/map-v2/features/repeaters/types.d.ts +59 -0
- package/dist/map-v2/features/repeaters/types.js +4 -0
- package/dist/map-v2/features/repeaters/utils/repeaterGeoJSON.d.ts +20 -0
- package/dist/map-v2/features/repeaters/utils/repeaterGeoJSON.js +90 -0
- package/dist/map-v2/features/repeaters/utils/repeaterTree.d.ts +23 -0
- package/dist/map-v2/features/repeaters/utils/repeaterTree.js +111 -0
- package/dist/map-v2/index.d.ts +2 -1
- package/dist/map-v2/index.js +5 -1
- package/dist/map-v2/shared/controls/FeatureSettingsControl.svelte +27 -21
- package/dist/map-v2/shared/controls/FeatureSettingsControl.svelte.d.ts +3 -0
- package/dist/map-v2/shared/controls/panels/RepeaterSettingsPanel.svelte +280 -5
- package/dist/map-v2/shared/controls/panels/RepeaterSettingsPanel.svelte.d.ts +17 -16
- package/package.json +1 -1
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* RepeaterLabelsLayer - Renders repeater labels positioned along azimuth
|
|
4
|
+
*
|
|
5
|
+
* Features:
|
|
6
|
+
* - Projects labels along azimuth from repeater location
|
|
7
|
+
* - Configurable label offset (% of arc radius)
|
|
8
|
+
* - Primary + optional secondary field display
|
|
9
|
+
* - Zoom-reactive positioning
|
|
10
|
+
* - Viewport filtering for performance
|
|
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 { RepeaterStoreContext } from '../stores/repeaterStoreContext.svelte';
|
|
17
|
+
import type { Repeater } from '../types';
|
|
18
|
+
import * as turf from '@turf/turf';
|
|
19
|
+
|
|
20
|
+
interface Props {
|
|
21
|
+
/** Repeater store context */
|
|
22
|
+
store: RepeaterStoreContext;
|
|
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, 'repeater-labels');
|
|
31
|
+
const layerId = generateLayerId(namespace, 'repeater-labels');
|
|
32
|
+
|
|
33
|
+
let map = $state<mapboxgl.Map | null>(null);
|
|
34
|
+
let unsubscribe: (() => void) | null = null;
|
|
35
|
+
let viewportUpdateTimer: ReturnType<typeof setTimeout> | null = null;
|
|
36
|
+
let viewportVersion = $state(0); // Increment to force $effect re-run on viewport changes
|
|
37
|
+
|
|
38
|
+
// Viewport change handler (pan/zoom/move) with debouncing
|
|
39
|
+
function handleViewportChange() {
|
|
40
|
+
if (!map) return;
|
|
41
|
+
|
|
42
|
+
// Debounce viewport updates to avoid excessive re-renders
|
|
43
|
+
if (viewportUpdateTimer) {
|
|
44
|
+
clearTimeout(viewportUpdateTimer);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Wait 150ms after last pan/zoom before updating
|
|
48
|
+
viewportUpdateTimer = setTimeout(() => {
|
|
49
|
+
viewportVersion++;
|
|
50
|
+
}, 150);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Get label text for a repeater field
|
|
55
|
+
*/
|
|
56
|
+
function getRepeaterLabelText(repeater: Repeater, field: keyof Repeater | 'none'): string {
|
|
57
|
+
if (field === 'none') return '';
|
|
58
|
+
|
|
59
|
+
const value = repeater[field];
|
|
60
|
+
return value == null ? '' : String(value);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Build GeoJSON features for repeater labels
|
|
65
|
+
*/
|
|
66
|
+
function buildLabelFeatures(): GeoJSON.FeatureCollection {
|
|
67
|
+
const features: GeoJSON.Feature[] = [];
|
|
68
|
+
|
|
69
|
+
console.log('RepeaterLabelsLayer: Building label features', {
|
|
70
|
+
showLabels: store.showLabels,
|
|
71
|
+
filteredRepeatersCount: store.filteredRepeaters.length,
|
|
72
|
+
hasMap: !!map
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
if (!store.showLabels || store.filteredRepeaters.length === 0 || !map) {
|
|
76
|
+
console.log('RepeaterLabelsLayer: Skipping labels -', {
|
|
77
|
+
showLabels: store.showLabels,
|
|
78
|
+
hasRepeaters: store.filteredRepeaters.length > 0,
|
|
79
|
+
hasMap: !!map
|
|
80
|
+
});
|
|
81
|
+
return { type: 'FeatureCollection', features: [] };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Get current zoom level
|
|
85
|
+
const currentZoom = map.getZoom();
|
|
86
|
+
|
|
87
|
+
// Filter repeaters by viewport bounds
|
|
88
|
+
const bounds = map.getBounds();
|
|
89
|
+
if (!bounds) {
|
|
90
|
+
return { type: 'FeatureCollection', features: [] };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const visibleRepeaters = store.filteredRepeaters.filter(repeater =>
|
|
94
|
+
bounds.contains([repeater.longitude, repeater.latitude])
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
visibleRepeaters.forEach((repeater) => {
|
|
98
|
+
// Get label text
|
|
99
|
+
const primaryText = getRepeaterLabelText(repeater, store.primaryLabelField);
|
|
100
|
+
const secondaryText = getRepeaterLabelText(repeater, store.secondaryLabelField);
|
|
101
|
+
|
|
102
|
+
// Build combined label (single line with separator)
|
|
103
|
+
let labelText = primaryText;
|
|
104
|
+
if (secondaryText) {
|
|
105
|
+
labelText += ` | ${secondaryText}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (!labelText.trim()) return; // Skip if no text
|
|
109
|
+
|
|
110
|
+
// Calculate zoom-aware radius
|
|
111
|
+
const zoomMultiplier = Math.pow(1.2, currentZoom - 12);
|
|
112
|
+
const repeaterRadius = store.baseRadius * zoomMultiplier;
|
|
113
|
+
|
|
114
|
+
// Position label at a percentage of the arc radius (configurable via labelOffset)
|
|
115
|
+
// labelOffset is treated as a percentage (e.g., 300 = 300% = beyond arc)
|
|
116
|
+
const labelDistance = (repeaterRadius * store.labelOffset) / 100;
|
|
117
|
+
|
|
118
|
+
// Project label position along azimuth from the repeater's location
|
|
119
|
+
const origin = turf.point([repeater.longitude, repeater.latitude]);
|
|
120
|
+
const labelPosition = turf.destination(
|
|
121
|
+
origin,
|
|
122
|
+
labelDistance / 1000, // Convert meters to kilometers
|
|
123
|
+
repeater.azimuth,
|
|
124
|
+
{ units: 'kilometers' }
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
features.push({
|
|
128
|
+
type: 'Feature',
|
|
129
|
+
geometry: labelPosition.geometry,
|
|
130
|
+
properties: {
|
|
131
|
+
text: labelText,
|
|
132
|
+
repeaterId: repeater.repeaterId
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
console.log('RepeaterLabelsLayer: Built features', {
|
|
138
|
+
totalRepeaters: store.filteredRepeaters.length,
|
|
139
|
+
visibleInViewport: visibleRepeaters.length,
|
|
140
|
+
featuresGenerated: features.length
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
return { type: 'FeatureCollection', features };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Update label layer on map
|
|
148
|
+
*/
|
|
149
|
+
function updateLabels() {
|
|
150
|
+
if (!map || !map.getSource(sourceId)) return;
|
|
151
|
+
|
|
152
|
+
const geojson = buildLabelFeatures();
|
|
153
|
+
|
|
154
|
+
console.log('RepeaterLabelsLayer: Updating labels', {
|
|
155
|
+
sourceExists: !!map.getSource(sourceId),
|
|
156
|
+
layerExists: !!map.getLayer(layerId),
|
|
157
|
+
featureCount: geojson.features.length,
|
|
158
|
+
currentZoom: map.getZoom(),
|
|
159
|
+
minLabelZoom: store.minLabelZoom,
|
|
160
|
+
labelSize: store.labelSize,
|
|
161
|
+
labelColor: store.labelColor
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const source = map.getSource(sourceId) as mapboxgl.GeoJSONSource;
|
|
165
|
+
source.setData(geojson);
|
|
166
|
+
|
|
167
|
+
// Also update paint/layout properties when labels are refreshed
|
|
168
|
+
if (map.getLayer(layerId)) {
|
|
169
|
+
map.setLayoutProperty(layerId, 'text-size', store.labelSize);
|
|
170
|
+
map.setPaintProperty(layerId, 'text-color', store.labelColor);
|
|
171
|
+
map.setPaintProperty(layerId, 'text-halo-color', store.labelHaloColor);
|
|
172
|
+
map.setPaintProperty(layerId, 'text-halo-width', store.labelHaloWidth);
|
|
173
|
+
map.setLayerZoomRange(layerId, store.minLabelZoom, 24);
|
|
174
|
+
|
|
175
|
+
console.log('RepeaterLabelsLayer: Updated layer properties');
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Initialize or reinitialize the label layer
|
|
181
|
+
* Called on mount and when map style changes
|
|
182
|
+
*/
|
|
183
|
+
async function initializeLayer() {
|
|
184
|
+
if (!map) return;
|
|
185
|
+
|
|
186
|
+
// Wait for style to load
|
|
187
|
+
await waitForStyleLoad(map);
|
|
188
|
+
|
|
189
|
+
// Add source if missing
|
|
190
|
+
if (!map.getSource(sourceId)) {
|
|
191
|
+
map.addSource(sourceId, {
|
|
192
|
+
type: 'geojson',
|
|
193
|
+
data: { type: 'FeatureCollection', features: [] }
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Add label layer if missing
|
|
198
|
+
if (!map.getLayer(layerId)) {
|
|
199
|
+
map.addLayer({
|
|
200
|
+
id: layerId,
|
|
201
|
+
type: 'symbol',
|
|
202
|
+
source: sourceId,
|
|
203
|
+
minzoom: store.minLabelZoom,
|
|
204
|
+
layout: {
|
|
205
|
+
'text-field': ['get', 'text'],
|
|
206
|
+
'text-font': ['Open Sans Regular', 'Arial Unicode MS Regular'],
|
|
207
|
+
'text-size': store.labelSize,
|
|
208
|
+
'text-anchor': 'center',
|
|
209
|
+
'text-allow-overlap': true,
|
|
210
|
+
'text-ignore-placement': false,
|
|
211
|
+
'text-max-width': 999,
|
|
212
|
+
'text-justify': 'center'
|
|
213
|
+
},
|
|
214
|
+
paint: {
|
|
215
|
+
'text-color': store.labelColor,
|
|
216
|
+
'text-halo-color': store.labelHaloColor,
|
|
217
|
+
'text-halo-width': store.labelHaloWidth
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Add event listeners (remove first to avoid duplicates)
|
|
223
|
+
map.off('zoom', updateLabels);
|
|
224
|
+
map.off('moveend', handleViewportChange);
|
|
225
|
+
map.off('zoomend', handleViewportChange);
|
|
226
|
+
|
|
227
|
+
map.on('zoom', updateLabels);
|
|
228
|
+
map.on('moveend', handleViewportChange);
|
|
229
|
+
map.on('zoomend', handleViewportChange);
|
|
230
|
+
|
|
231
|
+
// Initial render
|
|
232
|
+
updateLabels();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
onMount(async () => {
|
|
236
|
+
unsubscribe = mapStore.subscribe(async (m) => {
|
|
237
|
+
if (!m) {
|
|
238
|
+
map = null;
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
map = m;
|
|
243
|
+
|
|
244
|
+
// Initial layer setup
|
|
245
|
+
await initializeLayer();
|
|
246
|
+
|
|
247
|
+
// Re-initialize layer when map style changes
|
|
248
|
+
// Mapbox removes all custom layers/sources when setStyle() is called
|
|
249
|
+
map.on('style.load', initializeLayer);
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// Watch for changes in store properties and update labels
|
|
254
|
+
$effect(() => {
|
|
255
|
+
// Track viewportVersion to force re-run on pan
|
|
256
|
+
viewportVersion;
|
|
257
|
+
|
|
258
|
+
// Dependencies that should trigger label refresh
|
|
259
|
+
store.filteredRepeaters;
|
|
260
|
+
store.showLabels;
|
|
261
|
+
store.primaryLabelField;
|
|
262
|
+
store.secondaryLabelField;
|
|
263
|
+
store.labelOffset;
|
|
264
|
+
store.baseRadius;
|
|
265
|
+
|
|
266
|
+
// Style properties
|
|
267
|
+
store.labelSize;
|
|
268
|
+
store.labelColor;
|
|
269
|
+
store.labelHaloColor;
|
|
270
|
+
store.labelHaloWidth;
|
|
271
|
+
store.minLabelZoom;
|
|
272
|
+
|
|
273
|
+
updateLabels();
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
onDestroy(() => {
|
|
277
|
+
unsubscribe?.();
|
|
278
|
+
|
|
279
|
+
// Clear any pending viewport update timer
|
|
280
|
+
if (viewportUpdateTimer) {
|
|
281
|
+
clearTimeout(viewportUpdateTimer);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (map) {
|
|
285
|
+
// Remove style.load listener
|
|
286
|
+
map.off('style.load', initializeLayer);
|
|
287
|
+
|
|
288
|
+
// Remove all event listeners
|
|
289
|
+
map.off('zoom', updateLabels);
|
|
290
|
+
map.off('moveend', handleViewportChange);
|
|
291
|
+
map.off('zoomend', handleViewportChange);
|
|
292
|
+
|
|
293
|
+
if (map.getLayer(layerId)) {
|
|
294
|
+
map.removeLayer(layerId);
|
|
295
|
+
}
|
|
296
|
+
if (map.getSource(sourceId)) {
|
|
297
|
+
map.removeSource(sourceId);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
</script>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { RepeaterStoreContext } from '../stores/repeaterStoreContext.svelte';
|
|
2
|
+
interface Props {
|
|
3
|
+
/** Repeater store context */
|
|
4
|
+
store: RepeaterStoreContext;
|
|
5
|
+
/** Unique namespace for layer/source IDs */
|
|
6
|
+
namespace: string;
|
|
7
|
+
}
|
|
8
|
+
declare const RepeaterLabelsLayer: import("svelte").Component<Props, {}, "">;
|
|
9
|
+
type RepeaterLabelsLayer = ReturnType<typeof RepeaterLabelsLayer>;
|
|
10
|
+
export default RepeaterLabelsLayer;
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* RepeatersLayer - Renders repeater sectors as arc polygons
|
|
4
|
+
*
|
|
5
|
+
* Features:
|
|
6
|
+
* - Fixed 30° beamwidth for all repeaters
|
|
7
|
+
* - Mapbox fill layer (colored by tech:fband)
|
|
8
|
+
* - Mapbox line layer (borders)
|
|
9
|
+
* - Zoom-reactive: Regenerates arcs on zoom
|
|
10
|
+
* - Viewport filtering: Only renders visible repeaters
|
|
11
|
+
* - Style-change resilient: Reinitializes on map style load
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { onDestroy, onMount } from 'svelte';
|
|
15
|
+
import type { Map as MapboxMap } from 'mapbox-gl';
|
|
16
|
+
import type { RepeaterStoreContext } from '../stores/repeaterStoreContext.svelte';
|
|
17
|
+
import { useMapbox } from '../../../core/hooks/useMapbox';
|
|
18
|
+
import { repeatersToGeoJSON } from '../utils/repeaterGeoJSON';
|
|
19
|
+
import { REPEATER_FILL_Z_INDEX, REPEATER_LINE_Z_INDEX } from '../constants/zIndex';
|
|
20
|
+
|
|
21
|
+
interface Props {
|
|
22
|
+
/** Repeater store context */
|
|
23
|
+
store: RepeaterStoreContext;
|
|
24
|
+
/** Unique namespace for layer IDs */
|
|
25
|
+
namespace: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let { store, namespace }: Props = $props();
|
|
29
|
+
|
|
30
|
+
const FILL_LAYER_ID = `${namespace}-repeaters-fill`;
|
|
31
|
+
const LINE_LAYER_ID = `${namespace}-repeaters-line`;
|
|
32
|
+
const SOURCE_ID = `${namespace}-repeaters`;
|
|
33
|
+
|
|
34
|
+
// Get map from mapbox hook
|
|
35
|
+
const mapStore = useMapbox();
|
|
36
|
+
|
|
37
|
+
let map = $state<MapboxMap | null>(null);
|
|
38
|
+
let mounted = $state(false);
|
|
39
|
+
let viewportUpdateTimer: ReturnType<typeof setTimeout> | null = null;
|
|
40
|
+
let viewportVersion = $state(0); // Increment to force $effect re-run on viewport changes
|
|
41
|
+
|
|
42
|
+
// Viewport change handler with debouncing
|
|
43
|
+
function handleMoveEnd() {
|
|
44
|
+
if (!map) return;
|
|
45
|
+
|
|
46
|
+
// Clear any existing timer
|
|
47
|
+
if (viewportUpdateTimer) {
|
|
48
|
+
clearTimeout(viewportUpdateTimer);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Debounce: wait 200ms after last move event before re-rendering
|
|
52
|
+
viewportUpdateTimer = setTimeout(() => {
|
|
53
|
+
if (!map) return;
|
|
54
|
+
const newZoom = map.getZoom();
|
|
55
|
+
console.log('RepeatersLayer: Viewport changed, updating to zoom:', newZoom);
|
|
56
|
+
// Update zoom
|
|
57
|
+
store.setCurrentZoom(newZoom);
|
|
58
|
+
// Force $effect to re-run
|
|
59
|
+
viewportVersion++;
|
|
60
|
+
}, 200);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Initialize or reinitialize the repeater layers
|
|
65
|
+
* Called on mount and when map style changes
|
|
66
|
+
*/
|
|
67
|
+
function initializeLayer() {
|
|
68
|
+
if (!map) return;
|
|
69
|
+
|
|
70
|
+
console.log('RepeatersLayer: initializeLayer called');
|
|
71
|
+
|
|
72
|
+
// Set initial zoom
|
|
73
|
+
store.setCurrentZoom(map.getZoom());
|
|
74
|
+
|
|
75
|
+
// Add moveend listener (remove first to avoid duplicates)
|
|
76
|
+
map.off('moveend', handleMoveEnd);
|
|
77
|
+
map.on('moveend', handleMoveEnd);
|
|
78
|
+
|
|
79
|
+
// Mark as mounted to trigger $effect
|
|
80
|
+
mounted = true;
|
|
81
|
+
|
|
82
|
+
// Force $effect to re-run
|
|
83
|
+
viewportVersion++;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
onMount(() => {
|
|
87
|
+
console.log('RepeatersLayer: onMount, waiting for map...');
|
|
88
|
+
|
|
89
|
+
// Subscribe to map store
|
|
90
|
+
const unsubscribe = mapStore.subscribe((mapInstance) => {
|
|
91
|
+
if (mapInstance && !map) {
|
|
92
|
+
console.log('RepeatersLayer: Map available, initializing...');
|
|
93
|
+
map = mapInstance;
|
|
94
|
+
|
|
95
|
+
// Initial layer setup
|
|
96
|
+
initializeLayer();
|
|
97
|
+
|
|
98
|
+
// Re-initialize layer when map style changes
|
|
99
|
+
// Mapbox removes all custom layers/sources when setStyle() is called
|
|
100
|
+
map.on('style.load', initializeLayer);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
return () => {
|
|
105
|
+
unsubscribe();
|
|
106
|
+
};
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
onDestroy(() => {
|
|
110
|
+
if (!map) return;
|
|
111
|
+
|
|
112
|
+
// Clean up timer
|
|
113
|
+
if (viewportUpdateTimer) {
|
|
114
|
+
clearTimeout(viewportUpdateTimer);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Remove style.load listener
|
|
118
|
+
map.off('style.load', initializeLayer);
|
|
119
|
+
|
|
120
|
+
map.off('moveend', handleMoveEnd);
|
|
121
|
+
|
|
122
|
+
// Clean up layers and source
|
|
123
|
+
if (map.getLayer(LINE_LAYER_ID)) {
|
|
124
|
+
map.removeLayer(LINE_LAYER_ID);
|
|
125
|
+
}
|
|
126
|
+
if (map.getLayer(FILL_LAYER_ID)) {
|
|
127
|
+
map.removeLayer(FILL_LAYER_ID);
|
|
128
|
+
}
|
|
129
|
+
if (map.getSource(SOURCE_ID)) {
|
|
130
|
+
map.removeSource(SOURCE_ID);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Reactive: Update GeoJSON when repeaters/zoom/settings change
|
|
135
|
+
$effect(() => {
|
|
136
|
+
// Track viewportVersion to force re-run on pan
|
|
137
|
+
viewportVersion;
|
|
138
|
+
|
|
139
|
+
console.log('RepeatersLayer $effect triggered:', {
|
|
140
|
+
mounted,
|
|
141
|
+
hasMap: !!map,
|
|
142
|
+
showRepeaters: store.showRepeaters,
|
|
143
|
+
filteredRepeatersCount: store.filteredRepeaters.length,
|
|
144
|
+
currentZoom: store.currentZoom,
|
|
145
|
+
baseRadius: store.baseRadius,
|
|
146
|
+
viewportVersion
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
if (!mounted || !map || !store.showRepeaters) {
|
|
150
|
+
// Remove layers if showRepeaters is false
|
|
151
|
+
if (mounted && map) {
|
|
152
|
+
console.log('RepeatersLayer: Removing layers (showRepeaters=false or not mounted)');
|
|
153
|
+
if (map.getLayer(LINE_LAYER_ID)) {
|
|
154
|
+
map.removeLayer(LINE_LAYER_ID);
|
|
155
|
+
}
|
|
156
|
+
if (map.getLayer(FILL_LAYER_ID)) {
|
|
157
|
+
map.removeLayer(FILL_LAYER_ID);
|
|
158
|
+
}
|
|
159
|
+
if (map.getSource(SOURCE_ID)) {
|
|
160
|
+
map.removeSource(SOURCE_ID);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Filter repeaters by viewport bounds (only render visible repeaters)
|
|
167
|
+
const bounds = map.getBounds();
|
|
168
|
+
if (!bounds) {
|
|
169
|
+
console.warn('RepeatersLayer: Cannot get map bounds, skipping viewport filter');
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const visibleRepeaters = store.filteredRepeaters.filter(repeater =>
|
|
174
|
+
bounds.contains([repeater.longitude, repeater.latitude])
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
console.log('RepeatersLayer: Viewport filtering:', {
|
|
178
|
+
totalFiltered: store.filteredRepeaters.length,
|
|
179
|
+
visibleInViewport: visibleRepeaters.length
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// Generate GeoJSON from visible repeaters only
|
|
183
|
+
const geoJSON = repeatersToGeoJSON(
|
|
184
|
+
visibleRepeaters,
|
|
185
|
+
store.currentZoom,
|
|
186
|
+
store.baseRadius,
|
|
187
|
+
store.techBandColorMap
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
console.log('RepeatersLayer: Generated GeoJSON:', {
|
|
191
|
+
featureCount: geoJSON.features.length,
|
|
192
|
+
firstFeature: geoJSON.features[0]
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// Update or create source
|
|
196
|
+
const source = map.getSource(SOURCE_ID);
|
|
197
|
+
if (source && source.type === 'geojson') {
|
|
198
|
+
console.log('RepeatersLayer: Updating existing source');
|
|
199
|
+
source.setData(geoJSON);
|
|
200
|
+
} else {
|
|
201
|
+
console.log('RepeatersLayer: Creating new source');
|
|
202
|
+
map.addSource(SOURCE_ID, {
|
|
203
|
+
type: 'geojson',
|
|
204
|
+
data: geoJSON
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Add fill layer if not exists
|
|
209
|
+
if (!map.getLayer(FILL_LAYER_ID)) {
|
|
210
|
+
console.log('RepeatersLayer: Creating fill layer');
|
|
211
|
+
map.addLayer({
|
|
212
|
+
id: FILL_LAYER_ID,
|
|
213
|
+
type: 'fill',
|
|
214
|
+
source: SOURCE_ID,
|
|
215
|
+
layout: {
|
|
216
|
+
'fill-sort-key': ['get', 'sortKey'] // Use sortKey for z-ordering
|
|
217
|
+
},
|
|
218
|
+
paint: {
|
|
219
|
+
'fill-color': ['get', 'techBandColor'],
|
|
220
|
+
'fill-opacity': store.fillOpacity
|
|
221
|
+
},
|
|
222
|
+
metadata: {
|
|
223
|
+
zIndex: REPEATER_FILL_Z_INDEX
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
} else {
|
|
227
|
+
// Update fill opacity
|
|
228
|
+
console.log('RepeatersLayer: Updating fill opacity:', store.fillOpacity);
|
|
229
|
+
map.setPaintProperty(FILL_LAYER_ID, 'fill-opacity', store.fillOpacity);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Add line layer if not exists
|
|
233
|
+
if (!map.getLayer(LINE_LAYER_ID)) {
|
|
234
|
+
console.log('RepeatersLayer: Creating line layer');
|
|
235
|
+
map.addLayer({
|
|
236
|
+
id: LINE_LAYER_ID,
|
|
237
|
+
type: 'line',
|
|
238
|
+
source: SOURCE_ID,
|
|
239
|
+
layout: {
|
|
240
|
+
'line-sort-key': ['get', 'sortKey'] // Use sortKey for z-ordering
|
|
241
|
+
},
|
|
242
|
+
paint: {
|
|
243
|
+
'line-color': ['get', 'lineColor'],
|
|
244
|
+
'line-width': store.lineWidth,
|
|
245
|
+
'line-opacity': ['get', 'lineOpacity']
|
|
246
|
+
},
|
|
247
|
+
metadata: {
|
|
248
|
+
zIndex: REPEATER_LINE_Z_INDEX
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
} else {
|
|
252
|
+
// Update line width
|
|
253
|
+
console.log('RepeatersLayer: Updating line width:', store.lineWidth);
|
|
254
|
+
map.setPaintProperty(LINE_LAYER_ID, 'line-width', store.lineWidth);
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
</script>
|
|
258
|
+
|
|
259
|
+
<!-- This component doesn't render DOM, only Mapbox layers -->
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { RepeaterStoreContext } from '../stores/repeaterStoreContext.svelte';
|
|
2
|
+
interface Props {
|
|
3
|
+
/** Repeater store context */
|
|
4
|
+
store: RepeaterStoreContext;
|
|
5
|
+
/** Unique namespace for layer IDs */
|
|
6
|
+
namespace: string;
|
|
7
|
+
}
|
|
8
|
+
declare const RepeatersLayer: import("svelte").Component<Props, {}, "">;
|
|
9
|
+
type RepeatersLayer = ReturnType<typeof RepeatersLayer>;
|
|
10
|
+
export default RepeatersLayer;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repeater Store Context - Reactive state for repeater feature
|
|
3
|
+
*
|
|
4
|
+
* Simplified store with fixed Tech → FBand tree structure
|
|
5
|
+
* Supports leaf-level coloring for each tech:fband combination
|
|
6
|
+
*/
|
|
7
|
+
import type { Repeater } from '../types';
|
|
8
|
+
export interface RepeaterStoreValue {
|
|
9
|
+
repeaters: Repeater[];
|
|
10
|
+
visibleTechBands: Set<string>;
|
|
11
|
+
techBandColorMap: Map<string, string>;
|
|
12
|
+
showRepeaters: boolean;
|
|
13
|
+
baseRadius: number;
|
|
14
|
+
fillOpacity: number;
|
|
15
|
+
lineWidth: number;
|
|
16
|
+
showLabels: boolean;
|
|
17
|
+
primaryLabelField: keyof Repeater;
|
|
18
|
+
secondaryLabelField: keyof Repeater | 'none';
|
|
19
|
+
labelSize: number;
|
|
20
|
+
labelColor: string;
|
|
21
|
+
labelOffset: number;
|
|
22
|
+
labelHaloColor: string;
|
|
23
|
+
labelHaloWidth: number;
|
|
24
|
+
minLabelZoom: number;
|
|
25
|
+
currentZoom: number;
|
|
26
|
+
}
|
|
27
|
+
export interface RepeaterStoreContext {
|
|
28
|
+
readonly repeaters: Repeater[];
|
|
29
|
+
readonly filteredRepeaters: Repeater[];
|
|
30
|
+
readonly visibleTechBands: Set<string>;
|
|
31
|
+
readonly techBandColorMap: Map<string, string>;
|
|
32
|
+
readonly showRepeaters: boolean;
|
|
33
|
+
readonly baseRadius: number;
|
|
34
|
+
readonly fillOpacity: number;
|
|
35
|
+
readonly lineWidth: number;
|
|
36
|
+
readonly currentZoom: number;
|
|
37
|
+
readonly showLabels: boolean;
|
|
38
|
+
readonly primaryLabelField: keyof Repeater;
|
|
39
|
+
readonly secondaryLabelField: keyof Repeater | 'none';
|
|
40
|
+
readonly labelSize: number;
|
|
41
|
+
readonly labelColor: string;
|
|
42
|
+
readonly labelOffset: number;
|
|
43
|
+
readonly labelHaloColor: string;
|
|
44
|
+
readonly labelHaloWidth: number;
|
|
45
|
+
readonly minLabelZoom: number;
|
|
46
|
+
setRepeaters(repeaters: Repeater[]): void;
|
|
47
|
+
toggleTechBand(tech: string, fband: string): void;
|
|
48
|
+
isTechBandVisible(tech: string, fband: string): boolean;
|
|
49
|
+
getTechBandColor(tech: string, fband: string): string;
|
|
50
|
+
setTechBandColor(tech: string, fband: string, color: string): void;
|
|
51
|
+
setShowRepeaters(value: boolean): void;
|
|
52
|
+
setBaseRadius(value: number): void;
|
|
53
|
+
setFillOpacity(value: number): void;
|
|
54
|
+
setLineWidth(value: number): void;
|
|
55
|
+
setCurrentZoom(value: number): void;
|
|
56
|
+
setShowLabels(value: boolean): void;
|
|
57
|
+
setPrimaryLabelField(field: keyof Repeater): void;
|
|
58
|
+
setSecondaryLabelField(field: keyof Repeater | 'none'): void;
|
|
59
|
+
setLabelSize(value: number): void;
|
|
60
|
+
setLabelColor(value: string): void;
|
|
61
|
+
setLabelOffset(value: number): void;
|
|
62
|
+
setLabelHaloColor(value: string): void;
|
|
63
|
+
setLabelHaloWidth(value: number): void;
|
|
64
|
+
setMinLabelZoom(value: number): void;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Create a repeater store context with reactive state
|
|
68
|
+
*/
|
|
69
|
+
export declare function createRepeaterStoreContext(repeaters: Repeater[]): RepeaterStoreContext;
|