@zendir/ui 0.1.13 → 0.1.15

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,571 @@
1
+ import { jsxs, jsx } from "react/jsx-runtime";
2
+ import { useRef, useState, useCallback, useEffect } from "react";
3
+ import L from "leaflet";
4
+ import "leaflet/dist/leaflet.css";
5
+ /* empty css */
6
+ import { geodesicCirclePoints, splitRingAtAntimeridian, segmentsWithWorldCopies, splitPolylineAtAntimeridian } from "./groundTrackMapLeafletUtils.js";
7
+ import { DEFAULT_TILE, CARTO_ATTRIBUTION, OSM_ATTRIBUTION, FALLBACK_TILE } from "./groundTrackMapLeafletTiles.js";
8
+ import { useTheme } from "../theme/ThemeProvider.js";
9
+ const STATUS_COLOR_MAP = {
10
+ normal: "#56f000",
11
+ standby: "#2dccff",
12
+ caution: "#fce83a",
13
+ serious: "#ffb302",
14
+ critical: "#ff3838",
15
+ off: "#a4abb6"
16
+ };
17
+ const DEFAULT_TRACK_COLORS = [
18
+ "#2dccff",
19
+ "#3E3CFF",
20
+ "#9D70FF",
21
+ "#56f000",
22
+ "#fce83a",
23
+ "#ff7849",
24
+ "#2dd4bf",
25
+ "#ff3838"
26
+ ];
27
+ function createSatDivIcon(statusColor, trackColor) {
28
+ const badge = statusColor === "#56f000" ? `<circle cx="7" cy="7" r="3" fill="${statusColor}"/>` : statusColor === "#2dccff" ? `<circle cx="7" cy="7" r="2.5" fill="none" stroke="${statusColor}" stroke-width="1.5"/>` : statusColor === "#fce83a" ? `<rect x="4.5" y="4.5" width="5" height="5" fill="${statusColor}"/>` : statusColor === "#ffb302" ? `<polygon points="7,3.5 10.5,7 7,10.5 3.5,7" fill="${statusColor}"/>` : statusColor === "#ff3838" ? `<polygon points="7,10 4,4 10,4" fill="${statusColor}"/>` : `<circle cx="7" cy="7" r="2" fill="${statusColor}"/>`;
29
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="34" height="34" viewBox="0 0 34 34">
30
+ <!-- Glow ring -->
31
+ <circle cx="17" cy="17" r="15" fill="${statusColor}" fill-opacity="0.12"/>
32
+ <!-- Main body circle -->
33
+ <circle cx="17" cy="17" r="11" fill="#0d1323" stroke="${trackColor}" stroke-width="1.5"/>
34
+ <!-- Left solar panel -->
35
+ <rect x="2" y="15" width="9" height="4" rx="0.5" fill="${trackColor}" fill-opacity="0.75"/>
36
+ <line x1="5.5" y1="15" x2="5.5" y2="19" stroke="${trackColor}" stroke-width="0.5" stroke-opacity="0.45"/>
37
+ <!-- Connector left -->
38
+ <line x1="11" y1="17" x2="13" y2="17" stroke="${trackColor}" stroke-width="1.2"/>
39
+ <!-- Satellite bus body -->
40
+ <rect x="13" y="14.5" width="8" height="5" rx="1" fill="${statusColor}"/>
41
+ <!-- Connector right -->
42
+ <line x1="21" y1="17" x2="23" y2="17" stroke="${trackColor}" stroke-width="1.2"/>
43
+ <!-- Right solar panel -->
44
+ <rect x="23" y="15" width="9" height="4" rx="0.5" fill="${trackColor}" fill-opacity="0.75"/>
45
+ <line x1="26.5" y1="15" x2="26.5" y2="19" stroke="${trackColor}" stroke-width="0.5" stroke-opacity="0.45"/>
46
+ <!-- Status badge (top-left) -->
47
+ <circle cx="7" cy="7" r="5.5" fill="#0d1323"/>
48
+ ${badge}
49
+ </svg>`;
50
+ return L.divIcon({
51
+ html: svg,
52
+ className: "zendir-sat-icon",
53
+ iconSize: [34, 34],
54
+ iconAnchor: [17, 17],
55
+ tooltipAnchor: [0, -18]
56
+ });
57
+ }
58
+ function createStationDivIcon(statusColor, type, status) {
59
+ const badge = status === "normal" ? `<circle cx="5" cy="5" r="2.5" fill="${statusColor}"/>` : status === "standby" ? `<circle cx="5" cy="5" r="2" fill="none" stroke="${statusColor}" stroke-width="1.2"/>` : status === "caution" ? `<rect x="3" y="3" width="4" height="4" fill="${statusColor}"/>` : status === "serious" ? `<polygon points="5,2.5 7.5,5 5,7.5 2.5,5" fill="${statusColor}"/>` : status === "critical" ? `<polygon points="5,7.5 2.5,2.5 7.5,2.5" fill="${statusColor}"/>` : `<circle cx="5" cy="5" r="1.5" fill="${statusColor}"/>`;
60
+ let iconInner = "";
61
+ if (type === "phased-array") {
62
+ iconInner = `
63
+ <rect x="9" y="7" width="10" height="8" rx="0.5" fill="none" stroke="${statusColor}" stroke-width="1.2"/>
64
+ <line x1="12" y1="7" x2="12" y2="15" stroke="${statusColor}" stroke-width="0.7"/>
65
+ <line x1="15" y1="7" x2="15" y2="15" stroke="${statusColor}" stroke-width="0.7"/>
66
+ <line x1="9" y1="10" x2="19" y2="10" stroke="${statusColor}" stroke-width="0.7"/>
67
+ <line x1="9" y1="13" x2="19" y2="13" stroke="${statusColor}" stroke-width="0.7"/>
68
+ <line x1="14" y1="15" x2="14" y2="20" stroke="${statusColor}" stroke-width="1.2"/>
69
+ <line x1="11" y1="20" x2="17" y2="20" stroke="${statusColor}" stroke-width="1.2"/>`;
70
+ } else if (type === "relay") {
71
+ iconInner = `
72
+ <path d="M 8 16 a 7 7 0 0 1 7 -7" fill="none" stroke="${statusColor}" stroke-width="1.5" stroke-linecap="round"/>
73
+ <path d="M 8 16 m 2 -2 a 5 5 0 0 1 5 -5" fill="none" stroke="${statusColor}" stroke-width="1.2" stroke-linecap="round"/>
74
+ <line x1="14" y1="16" x2="14" y2="21" stroke="${statusColor}" stroke-width="1.2"/>
75
+ <line x1="11" y1="21" x2="17" y2="21" stroke="${statusColor}" stroke-width="1.2"/>
76
+ <path d="M 16 8 a 3 3 0 0 1 0 4" fill="none" stroke="${statusColor}" stroke-width="1.2" stroke-linecap="round"/>
77
+ <path d="M 17.5 6.5 a 5 5 0 0 1 0 7" fill="none" stroke="${statusColor}" stroke-width="1" stroke-linecap="round"/>`;
78
+ } else {
79
+ iconInner = `
80
+ <path d="M 14 14 m -7 0 a 7 7 0 0 1 7 -7" fill="none" stroke="${statusColor}" stroke-width="1.5" stroke-linecap="round"/>
81
+ <path d="M 14 14 m -5 0 a 5 5 0 0 1 5 -5" fill="none" stroke="${statusColor}" stroke-width="1.5" stroke-linecap="round"/>
82
+ <path d="M 14 14 m -3 0 a 3 3 0 0 1 3 -3" fill="none" stroke="${statusColor}" stroke-width="1.5" stroke-linecap="round"/>
83
+ <circle cx="14" cy="14" r="1.5" fill="${statusColor}"/>
84
+ <line x1="14" y1="15" x2="14" y2="21" stroke="${statusColor}" stroke-width="1.2"/>
85
+ <line x1="11" y1="21" x2="17" y2="21" stroke="${statusColor}" stroke-width="1.2"/>`;
86
+ }
87
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28">
88
+ <!-- Glow ring -->
89
+ <circle cx="14" cy="14" r="13" fill="${statusColor}" fill-opacity="0.1"/>
90
+ <!-- Background -->
91
+ <circle cx="14" cy="14" r="11" fill="#0d1323" stroke="${statusColor}" stroke-width="1.2" stroke-opacity="0.7"/>
92
+ ${iconInner}
93
+ <!-- Status badge (top-left) -->
94
+ <circle cx="5" cy="5" r="5" fill="#0d1323"/>
95
+ ${badge}
96
+ </svg>`;
97
+ return L.divIcon({
98
+ html: svg,
99
+ className: "zendir-station-icon",
100
+ iconSize: [28, 28],
101
+ iconAnchor: [14, 14],
102
+ tooltipAnchor: [0, -14]
103
+ });
104
+ }
105
+ function calculateTerminatorContinuous(date, numPoints = 72) {
106
+ const dayOfYear = Math.floor((date.getTime() - new Date(date.getFullYear(), 0, 0).getTime()) / 864e5);
107
+ const declination = -23.44 * Math.cos(2 * Math.PI / 365 * (dayOfYear + 10));
108
+ const decRad = declination * Math.PI / 180;
109
+ const hourAngle = (date.getUTCHours() + date.getUTCMinutes() / 60) / 24 * 360 - 180;
110
+ const points = [];
111
+ let prevLon = null;
112
+ for (let i = 0; i <= numPoints; i++) {
113
+ const latRad = (i / numPoints * 180 - 90) * (Math.PI / 180);
114
+ const cosH = -Math.tan(latRad) * Math.tan(decRad);
115
+ let lon;
116
+ if (cosH < -1) lon = hourAngle + 180;
117
+ else if (cosH > 1) lon = hourAngle;
118
+ else lon = hourAngle + Math.acos(cosH) * 180 / Math.PI;
119
+ if (prevLon != null) {
120
+ while (lon - prevLon > 180) lon -= 360;
121
+ while (lon - prevLon < -180) lon += 360;
122
+ }
123
+ prevLon = lon;
124
+ points.push([i / numPoints * 180 - 90, lon]);
125
+ }
126
+ return points;
127
+ }
128
+ function buildNightPolygon(sunsetTerminator) {
129
+ if (!sunsetTerminator.length) return [];
130
+ const sunrise = sunsetTerminator.map(([lat, lon]) => [lat, lon - 180]);
131
+ return [...sunsetTerminator, ...[...sunrise].reverse()];
132
+ }
133
+ function GroundTrackMapLeaflet({
134
+ allSatellites,
135
+ groundStations,
136
+ showTerminator = true,
137
+ showGrid = false,
138
+ showLegend = true,
139
+ showEquator = false,
140
+ showRecenterButton = true,
141
+ defaultCenter = [20, 0],
142
+ defaultZoom = 2,
143
+ height = "100%",
144
+ width = "100%",
145
+ minHeight = "400px",
146
+ emptyMessage = "No orbital data available",
147
+ tileUrl = DEFAULT_TILE,
148
+ className = "",
149
+ onSatelliteClick,
150
+ onStationClick
151
+ }) {
152
+ const { tokens } = useTheme();
153
+ const containerRef = useRef(null);
154
+ const mapRef = useRef(null);
155
+ const tileLayerRef = useRef(null);
156
+ const overlayGroupRef = useRef(null);
157
+ const controlsRef = useRef([]);
158
+ const [ready, setReady] = useState(false);
159
+ const clearLayers = useCallback(() => {
160
+ var _a;
161
+ const map = mapRef.current;
162
+ if (!map) return;
163
+ (_a = overlayGroupRef.current) == null ? void 0 : _a.clearLayers();
164
+ controlsRef.current.forEach((ctrl) => map.removeControl(ctrl));
165
+ controlsRef.current = [];
166
+ }, []);
167
+ const addLayer = useCallback((layer) => {
168
+ var _a;
169
+ (_a = overlayGroupRef.current) == null ? void 0 : _a.addLayer(layer);
170
+ }, []);
171
+ useEffect(() => {
172
+ const el = containerRef.current;
173
+ if (!el) return;
174
+ const center = defaultCenter ?? [20, 0];
175
+ const zoom = defaultZoom ?? 2;
176
+ const map = L.map(el, {
177
+ center,
178
+ zoom,
179
+ zoomControl: false,
180
+ scrollWheelZoom: true,
181
+ doubleClickZoom: true,
182
+ touchZoom: true,
183
+ boxZoom: true,
184
+ keyboard: true,
185
+ dragging: true,
186
+ attributionControl: true,
187
+ maxBounds: [[-90, -540], [90, 540]],
188
+ maxBoundsViscosity: 1
189
+ });
190
+ L.control.zoom({ position: "topright" }).addTo(map);
191
+ map.attributionControl.setPrefix("");
192
+ const isCartoTiles = tileUrl.includes("cartocdn");
193
+ if (isCartoTiles) {
194
+ map.attributionControl.addAttribution(CARTO_ATTRIBUTION);
195
+ }
196
+ const tileOptions = {
197
+ maxZoom: 19,
198
+ subdomains: isCartoTiles ? "abcd" : "abc",
199
+ // crossOrigin avoids tainted-canvas errors when Leaflet tries to read tile pixels
200
+ crossOrigin: true
201
+ };
202
+ const tile = L.tileLayer(tileUrl, tileOptions);
203
+ tile.addTo(map);
204
+ tileLayerRef.current = tile;
205
+ let hasSwitchedToFallback = false;
206
+ const onTileError = () => {
207
+ if (hasSwitchedToFallback) return;
208
+ hasSwitchedToFallback = true;
209
+ tile.off("tileerror", onTileError);
210
+ tile.remove();
211
+ if (isCartoTiles) {
212
+ map.attributionControl.removeAttribution(CARTO_ATTRIBUTION);
213
+ map.attributionControl.addAttribution(OSM_ATTRIBUTION);
214
+ }
215
+ const fallback = L.tileLayer(FALLBACK_TILE, {
216
+ maxZoom: 19,
217
+ subdomains: "abc",
218
+ crossOrigin: true
219
+ });
220
+ fallback.addTo(map);
221
+ tileLayerRef.current = fallback;
222
+ };
223
+ tile.on("tileerror", onTileError);
224
+ const overlayGroup = L.layerGroup();
225
+ overlayGroup.addTo(map);
226
+ overlayGroupRef.current = overlayGroup;
227
+ mapRef.current = map;
228
+ setReady(true);
229
+ const onSize = () => {
230
+ map.invalidateSize({ animate: false });
231
+ };
232
+ const raf = requestAnimationFrame(onSize);
233
+ const t1 = setTimeout(onSize, 150);
234
+ const t2 = setTimeout(onSize, 500);
235
+ return () => {
236
+ clearTimeout(t1);
237
+ clearTimeout(t2);
238
+ cancelAnimationFrame(raf);
239
+ clearLayers();
240
+ tileLayerRef.current = null;
241
+ map.remove();
242
+ mapRef.current = null;
243
+ overlayGroupRef.current = null;
244
+ };
245
+ }, [tileUrl, clearLayers]);
246
+ useEffect(() => {
247
+ if (!ready || !mapRef.current) return;
248
+ const map = mapRef.current;
249
+ clearLayers();
250
+ if (showTerminator) {
251
+ const sunsetTerminator = calculateTerminatorContinuous(/* @__PURE__ */ new Date());
252
+ const nightPoly = buildNightPolygon(sunsetTerminator);
253
+ if (nightPoly.length > 2) {
254
+ [0, 360, -360].forEach((offset) => {
255
+ const shifted = nightPoly.map(([lat, lon]) => [lat, lon + offset]);
256
+ addLayer(L.polygon(shifted, {
257
+ color: "transparent",
258
+ fillColor: "#00060f",
259
+ fillOpacity: 0.35,
260
+ interactive: false
261
+ }));
262
+ });
263
+ }
264
+ const sunriseTerminator = sunsetTerminator.map(([lat, lon]) => [lat, lon - 180]);
265
+ [sunsetTerminator, sunriseTerminator].forEach((line) => {
266
+ [0, 360, -360].forEach((offset) => {
267
+ const shifted = line.map(([lat, lon]) => [lat, lon + offset]);
268
+ addLayer(L.polyline(shifted, {
269
+ color: "rgba(88, 166, 255, 0.22)",
270
+ weight: 1,
271
+ interactive: false
272
+ }));
273
+ });
274
+ });
275
+ }
276
+ if (showGrid) {
277
+ const gridStyle = { color: "rgba(157, 112, 255, 0.12)", weight: 0.5, interactive: false };
278
+ [-360, 0, 360].forEach((offset) => {
279
+ for (let lon = -180; lon <= 180; lon += 30) {
280
+ const l = lon + offset;
281
+ addLayer(L.polyline([[90, l], [-90, l]], gridStyle));
282
+ }
283
+ for (let lat = -90; lat <= 90; lat += 30) {
284
+ addLayer(L.polyline([[lat, -180 + offset], [lat, 180 + offset]], gridStyle));
285
+ }
286
+ });
287
+ }
288
+ if (showEquator) {
289
+ [-360, 0, 360].forEach((offset) => {
290
+ addLayer(L.polyline([[0, -180 + offset], [0, 180 + offset]], {
291
+ color: "rgba(88, 166, 255, 0.25)",
292
+ weight: 1,
293
+ dashArray: "6, 4",
294
+ interactive: false
295
+ }));
296
+ });
297
+ }
298
+ groundStations.forEach((gs) => {
299
+ const status = ("status" in gs ? gs.status : void 0) ?? "standby";
300
+ const statusColor = STATUS_COLOR_MAP[status] || STATUS_COLOR_MAP.standby;
301
+ const stationType = ("type" in gs ? gs.type : void 0) ?? "dish";
302
+ const radius = "coverageRadius" in gs ? gs.coverageRadius : void 0;
303
+ const showCoverage = "showCoverage" in gs ? gs.showCoverage : false;
304
+ if (radius != null && radius > 0 && showCoverage) {
305
+ const ring = geodesicCirclePoints(gs.latitude, gs.longitude, radius, 64);
306
+ const rings = splitRingAtAntimeridian(ring);
307
+ [0, 360, -360].forEach((offset) => {
308
+ rings.forEach((r) => {
309
+ const shifted = r.map(([lat, lon]) => [lat, lon + offset]);
310
+ addLayer(L.polygon(shifted, {
311
+ color: `${statusColor}55`,
312
+ fillColor: statusColor,
313
+ fillOpacity: 0.1,
314
+ weight: 1,
315
+ dashArray: "4, 3",
316
+ interactive: false
317
+ }));
318
+ });
319
+ });
320
+ }
321
+ const gsIcon = createStationDivIcon(statusColor, stationType, status);
322
+ const marker = L.marker([gs.latitude, gs.longitude], { icon: gsIcon });
323
+ addLayer(marker);
324
+ const statusLabel = status !== "standby" ? ` · ${status}` : "";
325
+ marker.bindTooltip(
326
+ `<strong>${gs.name}</strong>${statusLabel}${"network" in gs && gs.network ? `<br/><span style="opacity:0.7">${gs.network}</span>` : ""}`,
327
+ { permanent: false, direction: "top", className: "zendir-leaflet-tooltip" }
328
+ );
329
+ marker.on("click", () => {
330
+ onStationClick == null ? void 0 : onStationClick("id" in gs ? String(gs.id) : gs.name);
331
+ });
332
+ });
333
+ allSatellites.forEach((sat, satIdx) => {
334
+ const track = sat.groundTrack;
335
+ if (!track || track.length === 0) return;
336
+ const color = sat.color || DEFAULT_TRACK_COLORS[satIdx % DEFAULT_TRACK_COLORS.length];
337
+ const futureIdx = sat.futureTrackIndex ?? track.length;
338
+ const pastLatLngs = track.slice(0, futureIdx).map((p) => [p.latitude, p.longitude]);
339
+ segmentsWithWorldCopies(splitPolylineAtAntimeridian(pastLatLngs)).forEach((seg) => {
340
+ if (seg.length >= 2) {
341
+ addLayer(L.polyline(seg, { color, weight: 2.5, opacity: 1 }));
342
+ }
343
+ });
344
+ if (futureIdx < track.length) {
345
+ const futureLatLngs = track.slice(futureIdx).map((p) => [p.latitude, p.longitude]);
346
+ segmentsWithWorldCopies(splitPolylineAtAntimeridian(futureLatLngs)).forEach((seg) => {
347
+ if (seg.length >= 2) {
348
+ addLayer(L.polyline(seg, { color, weight: 1.5, opacity: 0.5, dashArray: "6, 4" }));
349
+ }
350
+ });
351
+ }
352
+ if (sat.accessMask && sat.accessMask.some(Boolean)) {
353
+ let segment = [];
354
+ for (let i = 0; i < track.length; i++) {
355
+ if (sat.accessMask[i]) {
356
+ segment.push([track[i].latitude, track[i].longitude]);
357
+ } else if (segment.length >= 2) {
358
+ segmentsWithWorldCopies(splitPolylineAtAntimeridian(segment)).forEach((seg) => {
359
+ if (seg.length >= 2) {
360
+ addLayer(L.polyline(seg, { color: STATUS_COLOR_MAP.normal, weight: 4, opacity: 0.9 }));
361
+ }
362
+ });
363
+ segment = [];
364
+ } else {
365
+ segment = [];
366
+ }
367
+ }
368
+ if (segment.length >= 2) {
369
+ segmentsWithWorldCopies(splitPolylineAtAntimeridian(segment)).forEach((seg) => {
370
+ if (seg.length >= 2) {
371
+ addLayer(L.polyline(seg, { color: STATUS_COLOR_MAP.normal, weight: 4, opacity: 0.9 }));
372
+ }
373
+ });
374
+ }
375
+ }
376
+ if (sat.passMarkers && sat.passMarkers.length > 0) {
377
+ sat.passMarkers.forEach((pm) => {
378
+ const isAos = pm.type === "aos";
379
+ const pmColor = isAos ? "#56f000" : "#ff3838";
380
+ const pmMarker = L.circleMarker([pm.latitude, pm.longitude], {
381
+ radius: 5,
382
+ fillColor: pmColor,
383
+ color: `${pmColor}cc`,
384
+ weight: 1.5,
385
+ fillOpacity: 0.9
386
+ });
387
+ addLayer(pmMarker);
388
+ pmMarker.bindTooltip(`${pm.type.toUpperCase()}${pm.label ? ` – ${pm.label}` : ""}`, {
389
+ permanent: false,
390
+ direction: "top",
391
+ className: "zendir-leaflet-tooltip"
392
+ });
393
+ });
394
+ }
395
+ if (sat.showFootprint && sat.footprintRadius) {
396
+ const lastIdx = futureIdx > 0 ? Math.min(futureIdx - 1, track.length - 1) : track.length - 1;
397
+ const last = track[lastIdx];
398
+ const ring = geodesicCirclePoints(last.latitude, last.longitude, sat.footprintRadius, 64);
399
+ const rings = splitRingAtAntimeridian(ring);
400
+ [0, 360, -360].forEach((offset) => {
401
+ rings.forEach((r) => {
402
+ const shifted = r.map(([lat, lon]) => [lat, lon + offset]);
403
+ addLayer(L.polygon(shifted, {
404
+ color: `${color}70`,
405
+ fillColor: color,
406
+ fillOpacity: 0.1,
407
+ weight: 1,
408
+ dashArray: "3, 3",
409
+ interactive: false
410
+ }));
411
+ });
412
+ });
413
+ }
414
+ const currentIdx = futureIdx > 0 ? Math.min(futureIdx - 1, track.length - 1) : track.length - 1;
415
+ const current = track[currentIdx];
416
+ const statusColor = STATUS_COLOR_MAP[sat.status || "normal"] || STATUS_COLOR_MAP.normal;
417
+ const satIcon = createSatDivIcon(statusColor, color);
418
+ const satMarker = L.marker([current.latitude, current.longitude], { icon: satIcon });
419
+ addLayer(satMarker);
420
+ satMarker.bindTooltip(
421
+ `<strong style="color:${color}">${sat.name}</strong><br/>Lat ${current.latitude.toFixed(3)}° &nbsp; Lon ${current.longitude.toFixed(3)}°` + (current.altitude != null ? `<br/>Alt ${current.altitude.toFixed(1)} km` : "") + (sat.status ? `<br/><span style="color:${statusColor}">${sat.status.toUpperCase()}</span>` : ""),
422
+ { permanent: false, direction: "top", className: "zendir-leaflet-tooltip" }
423
+ );
424
+ satMarker.on("click", () => onSatelliteClick == null ? void 0 : onSatelliteClick(sat.id));
425
+ });
426
+ if (showLegend && (allSatellites.length > 0 || groundStations.length > 0)) {
427
+ const Legend = L.Control.extend({
428
+ onAdd() {
429
+ const div = L.DomUtil.create("div", "leaflet-legend-zendir");
430
+ div.style.cssText = `
431
+ padding: 8px 12px; background: rgba(15,21,32,0.9); border: 1px solid rgba(157,112,255,0.2);
432
+ border-radius: 8px; color: #e4e0f0; font-size: 11px; font-family: Roboto, sans-serif;
433
+ `;
434
+ allSatellites.forEach((s, i) => {
435
+ const c = s.color || DEFAULT_TRACK_COLORS[i % DEFAULT_TRACK_COLORS.length];
436
+ const row = document.createElement("div");
437
+ row.style.cssText = "display: flex; align-items: center; gap: 6px; margin: 2px 0;";
438
+ const swatch = document.createElement("span");
439
+ swatch.style.cssText = `width:8px;height:8px;border-radius:50%;background:${c};`;
440
+ const label = document.createElement("span");
441
+ label.textContent = s.name;
442
+ row.appendChild(swatch);
443
+ row.appendChild(label);
444
+ div.appendChild(row);
445
+ });
446
+ if (groundStations.length > 0) {
447
+ const row = document.createElement("div");
448
+ row.style.cssText = "display: flex; align-items: center; gap: 6px; margin: 2px 0; margin-top: 6px;";
449
+ const swatch = document.createElement("span");
450
+ swatch.style.cssText = "width:8px;height:8px;border-radius:50%;background:#2dccff;";
451
+ const label = document.createElement("span");
452
+ label.textContent = `${groundStations.length} Ground Station${groundStations.length > 1 ? "s" : ""}`;
453
+ row.appendChild(swatch);
454
+ row.appendChild(label);
455
+ div.appendChild(row);
456
+ }
457
+ return div;
458
+ }
459
+ });
460
+ const legend = new Legend({ position: "topleft" });
461
+ legend.addTo(map);
462
+ controlsRef.current.push(legend);
463
+ }
464
+ }, [
465
+ ready,
466
+ allSatellites,
467
+ groundStations,
468
+ showTerminator,
469
+ showGrid,
470
+ showEquator,
471
+ showLegend,
472
+ tokens.colors.text.secondary,
473
+ addLayer,
474
+ clearLayers,
475
+ onSatelliteClick,
476
+ onStationClick
477
+ ]);
478
+ const handleRecenter = useCallback(() => {
479
+ const map = mapRef.current;
480
+ if (!map) return;
481
+ const points = [];
482
+ allSatellites.forEach((s) => {
483
+ s.groundTrack.forEach((p) => points.push([p.latitude, p.longitude]));
484
+ });
485
+ groundStations.forEach((gs) => points.push([gs.latitude, gs.longitude]));
486
+ if (points.length > 0) {
487
+ const bounds = L.latLngBounds(points);
488
+ map.fitBounds(bounds, { padding: [40, 40], maxZoom: 8 });
489
+ } else {
490
+ map.setView(defaultCenter, defaultZoom);
491
+ }
492
+ }, [allSatellites, groundStations, defaultCenter, defaultZoom]);
493
+ const resolvedMinHeight = minHeight || (typeof height === "number" ? `${height}px` : "400px");
494
+ const isEmpty = allSatellites.length === 0 && groundStations.length === 0;
495
+ return /* @__PURE__ */ jsxs(
496
+ "div",
497
+ {
498
+ className: `zendir-ground-track-map zendir-ground-track-map-leaflet ${className}`,
499
+ "data-map-backend": "leaflet",
500
+ style: {
501
+ width,
502
+ height,
503
+ minHeight: resolvedMinHeight,
504
+ backgroundColor: tokens.colors.background.base,
505
+ borderRadius: 8,
506
+ overflow: "hidden",
507
+ position: "relative"
508
+ },
509
+ children: [
510
+ /* @__PURE__ */ jsx(
511
+ "div",
512
+ {
513
+ ref: containerRef,
514
+ style: { width: "100%", height: "100%", minHeight: resolvedMinHeight }
515
+ }
516
+ ),
517
+ isEmpty && /* @__PURE__ */ jsx(
518
+ "div",
519
+ {
520
+ style: {
521
+ position: "absolute",
522
+ left: "50%",
523
+ top: "50%",
524
+ transform: "translate(-50%, -50%)",
525
+ color: tokens.colors.text.secondary,
526
+ fontSize: 14,
527
+ pointerEvents: "none"
528
+ },
529
+ children: emptyMessage
530
+ }
531
+ ),
532
+ showRecenterButton && /* @__PURE__ */ jsxs(
533
+ "button",
534
+ {
535
+ type: "button",
536
+ onClick: handleRecenter,
537
+ title: "Recenter map",
538
+ "aria-label": "Recenter map",
539
+ style: {
540
+ position: "absolute",
541
+ top: 8,
542
+ right: 50,
543
+ zIndex: 1e3,
544
+ background: "rgba(24, 29, 46, 0.9)",
545
+ border: "1px solid rgba(157, 112, 255, 0.25)",
546
+ borderRadius: 6,
547
+ color: "#e4e0f0",
548
+ cursor: "pointer",
549
+ padding: "6px 10px",
550
+ fontSize: 12,
551
+ display: "flex",
552
+ alignItems: "center",
553
+ gap: 4
554
+ },
555
+ children: [
556
+ /* @__PURE__ */ jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", children: [
557
+ /* @__PURE__ */ jsx("circle", { cx: "12", cy: "12", r: "3" }),
558
+ /* @__PURE__ */ jsx("path", { d: "M12 2v4M12 18v4M2 12h4M18 12h4" })
559
+ ] }),
560
+ "Recenter"
561
+ ]
562
+ }
563
+ )
564
+ ]
565
+ }
566
+ );
567
+ }
568
+ export {
569
+ GroundTrackMapLeaflet
570
+ };
571
+ //# sourceMappingURL=GroundTrackMapLeaflet.js.map