@zendir/ui 0.2.0 → 0.2.2

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/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # @zendir/ui
2
2
 
3
+ ## 0.2.2
4
+
5
+ ### Features
6
+
7
+ #### GroundTrackMap — Night Visualization
8
+
9
+ - **Seamless night polygon** — completely rewritten terminator geometry: sunset/sunrise curves are now computed as continuous longitude pairs, producing a single closed polygon with no antimeridian artifacts. Eliminates all "jerk" lines and rendering glitches at the poles.
10
+ - **`nightTileUrl` prop** — optional second tile layer (e.g. NASA Black Marble, VIIRS city lights) rendered beneath the terminator overlay. The graduated twilight gradient naturally masks the day-to-night tile transition. Leaflet backend only.
11
+ - **`lightSources` prop** — array of point light sources (`LightSource` interface) rendered as soft glow dots on the night side of the map. Each light is masked by the terminator: invisible in daylight, fading in through twilight, full brightness at night. Supports custom colors, radii, intensity, and labels.
12
+ - **Solar elevation masking** — light source visibility is computed per-point using the exact solar elevation angle (not polygon containment), giving a smooth and physically correct twilight fade for each individual light.
13
+
14
+ ### Documentation
15
+
16
+ - **City Lights at Night** story — 35+ world cities as `lightSources`, demonstrating realistic nighttime glow.
17
+ - **City Lights Time Travel** story — interactive UTC hour slider showing lights switching on/off as the terminator sweeps.
18
+ - **Custom Light Sources** story — mission scenario with colored RF emitters, bases, and relay nodes.
19
+ - **Night Tile Layer** story — demonstrates `nightTileUrl` combined with `lightSources`.
20
+ - Updated component description with Night Tile Layer and Dynamic Light Sources sections.
21
+
22
+ ### Bug Fixes
23
+
24
+ - Fixed terminator night polygon antimeridian wrapping — sunset and sunrise curves are now generated as continuous coordinate pairs (no `lon ± 180` mirroring), preventing the polygon from crossing the day side.
25
+ - Removed unused `buildBandPolygon` function from Leaflet backend.
26
+
27
+ ---
28
+
3
29
  ## 0.2.0
4
30
 
5
31
  ### Features
@@ -83,6 +83,29 @@ export interface MapPin {
83
83
  /** Who placed the pin (display-only; not enforced) */
84
84
  createdBy?: string;
85
85
  }
86
+ /**
87
+ * A point light source rendered on the night side of the map.
88
+ * Use this to display city lights, base locations, RF emitters,
89
+ * or any glow that should only be visible after sunset.
90
+ *
91
+ * Light sources are masked by the terminator — they fade in
92
+ * through the twilight gradient and reach full brightness in
93
+ * deep night, matching real-world light-pollution behavior.
94
+ */
95
+ export interface LightSource {
96
+ /** Latitude in degrees (−90 … 90) */
97
+ latitude: number;
98
+ /** Longitude in degrees (−180 … 180) */
99
+ longitude: number;
100
+ /** Glow radius in pixels (default 4). Larger values create wider halos. */
101
+ radius?: number;
102
+ /** Glow intensity 0–1 (default 0.8). Controls the peak alpha of the light dot. */
103
+ intensity?: number;
104
+ /** Light color — any CSS color string (default '#ffeedd', warm white). */
105
+ color?: string;
106
+ /** Optional label shown on hover (Leaflet) or in tooltip (Canvas). */
107
+ label?: string;
108
+ }
86
109
  export interface GroundTrackMapProps {
87
110
  /** Single satellite ground track (legacy API) */
88
111
  groundTrack?: GroundTrackPoint[];
@@ -132,6 +155,25 @@ export interface GroundTrackMapProps {
132
155
  mapProvider?: 'leaflet' | 'canvas';
133
156
  /** Tile URL for Leaflet (default: CartoDB dark). Ignored when mapProvider is 'canvas'. */
134
157
  tileUrl?: string;
158
+ /**
159
+ * URL template for a "night" tile layer (e.g. NASA Black Marble, VIIRS city lights).
160
+ * When provided, this layer is rendered beneath the terminator overlay so that it
161
+ * appears only on the night side of the map — creating a realistic day/night transition.
162
+ * The day tiles remain visible on the sunlit side.
163
+ *
164
+ * Requires `showTerminator` to be enabled. Leaflet backend only.
165
+ *
166
+ * Example: `'https://map1.vis.earthdata.nasa.gov/wmts-webmerc/VIIRS_CityLights_2012/...'`
167
+ */
168
+ nightTileUrl?: string;
169
+ /**
170
+ * Array of point light sources rendered on the night side of the map.
171
+ * Each light appears as a soft glow dot that is masked by the terminator —
172
+ * invisible in daylight, fading in through twilight, full brightness at night.
173
+ *
174
+ * Use cases: city lights, base indicators, RF emitters, targets, population centers.
175
+ */
176
+ lightSources?: LightSource[];
135
177
  /** Array of user-placed pins displayed on the map */
136
178
  pins?: MapPin[];
137
179
  /**
@@ -147,5 +189,5 @@ export interface GroundTrackMapProps {
147
189
  /** Called when the user removes a pin */
148
190
  onPinRemove?: (pinId: string) => void;
149
191
  }
150
- export declare function GroundTrackMap({ groundTrack, satellites, groundStations, accessMask, teamPaths, showTerminator, terminatorTime, showGrid, showLegend, showEquator, showRecenterButton, isLoading, emptyMessage, width, height, minHeight, defaultCenter, defaultZoom, className, onSatelliteClick, onStationClick, mapProvider, tileUrl, pins, pinsEditable, onPinAdd, onPinUpdate, onPinRemove, }: GroundTrackMapProps): React.ReactElement;
192
+ export declare function GroundTrackMap({ groundTrack, satellites, groundStations, accessMask, teamPaths, showTerminator, terminatorTime, showGrid, showLegend, showEquator, showRecenterButton, isLoading, emptyMessage, width, height, minHeight, defaultCenter, defaultZoom, className, onSatelliteClick, onStationClick, mapProvider, tileUrl, nightTileUrl, lightSources, pins, pinsEditable, onPinAdd, onPinUpdate, onPinRemove, }: GroundTrackMapProps): React.ReactElement;
151
193
  export default GroundTrackMap;
@@ -68,25 +68,47 @@ function drawCoverageEllipse(ctx, centerLat, centerLon, radiusDeg, W, H, lonToX,
68
68
  }
69
69
  ctx.closePath();
70
70
  }
71
- function calculateTerminator(date, depressionDeg = 0, numPoints = 72) {
71
+ function hexToRgb(hex) {
72
+ const h = hex.replace("#", "");
73
+ if (h.length === 3) {
74
+ return [parseInt(h[0] + h[0], 16), parseInt(h[1] + h[1], 16), parseInt(h[2] + h[2], 16)];
75
+ }
76
+ if (h.length >= 6) {
77
+ return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
78
+ }
79
+ return [255, 238, 221];
80
+ }
81
+ function calculateTerminatorContinuous(date, depressionDeg = 0, numPoints = 360) {
72
82
  const dayOfYear = Math.floor((date.getTime() - new Date(date.getFullYear(), 0, 0).getTime()) / 864e5);
73
83
  const declination = -23.44 * Math.cos(2 * Math.PI / 365 * (dayOfYear + 10));
74
84
  const decRad = declination * Math.PI / 180;
75
85
  const hourAngle = (date.getUTCHours() + date.getUTCMinutes() / 60) / 24 * 360 - 180;
76
86
  const depRad = depressionDeg * Math.PI / 180;
77
- const points = [];
87
+ const sunset = [];
88
+ const sunrise = [];
78
89
  for (let i = 0; i <= numPoints; i++) {
79
- const latRad = (i / numPoints * 180 - 90) * (Math.PI / 180);
90
+ const lat = -89.999 + i / numPoints * 179.998;
91
+ const latRad = lat * (Math.PI / 180);
80
92
  const cosH = -(Math.sin(depRad) + Math.sin(latRad) * Math.sin(decRad)) / (Math.cos(latRad) * Math.cos(decRad));
81
- let lon;
82
- if (cosH < -1) lon = hourAngle + 180;
83
- else if (cosH > 1) lon = hourAngle;
84
- else lon = hourAngle + Math.acos(cosH) * 180 / Math.PI;
85
- while (lon > 180) lon -= 360;
86
- while (lon < -180) lon += 360;
87
- points.push({ lat: i / numPoints * 180 - 90, lon });
93
+ if (cosH < -1) ;
94
+ else if (cosH > 1) {
95
+ sunset.push([lat, hourAngle]);
96
+ sunrise.push([lat, hourAngle + 360]);
97
+ } else {
98
+ const H = Math.acos(cosH) * 180 / Math.PI;
99
+ sunset.push([lat, hourAngle + H]);
100
+ sunrise.push([lat, hourAngle + 360 - H]);
101
+ }
88
102
  }
89
- return points;
103
+ return { sunset, sunrise };
104
+ }
105
+ function buildNightPolygon(terminator) {
106
+ const { sunset, sunrise } = terminator;
107
+ if (!sunset.length || !sunrise.length) return [];
108
+ const poly = [];
109
+ poly.push(...sunset);
110
+ poly.push(...[...sunrise].reverse());
111
+ return poly;
90
112
  }
91
113
  function GroundTrackMap({
92
114
  groundTrack,
@@ -112,6 +134,8 @@ function GroundTrackMap({
112
134
  onStationClick,
113
135
  mapProvider = "leaflet",
114
136
  tileUrl,
137
+ nightTileUrl,
138
+ lightSources,
115
139
  pins,
116
140
  pinsEditable = false,
117
141
  onPinAdd,
@@ -202,63 +226,52 @@ function GroundTrackMap({
202
226
  ctx.fillRect(0, 0, W, H);
203
227
  if (showTerminator) {
204
228
  const now = terminatorTime ?? /* @__PURE__ */ new Date();
205
- const twilightZones = [
206
- { depression: 6, alpha: 0.08 },
207
- // civil twilight
208
- { depression: 12, alpha: 0.1 },
209
- // nautical twilight
210
- { depression: 18, alpha: 0.12 },
211
- // astronomical twilight
212
- { depression: 90, alpha: 0.22 }
213
- // full night (beyond 18°)
214
- ];
215
- const normLon = (l) => {
216
- let r = l;
217
- while (r > 180) r -= 360;
218
- while (r < -180) r += 360;
219
- return r;
220
- };
221
- const fillNightSide = (terminator, isSunrise) => {
222
- ctx.beginPath();
223
- if (isSunrise) {
224
- ctx.moveTo(0, 0);
225
- ctx.lineTo(0, H);
226
- } else {
227
- ctx.moveTo(W, 0);
228
- ctx.lineTo(W, H);
229
+ const BAND_STEP = 2;
230
+ const MAX_DEP = 24;
231
+ const NIGHT_ALPHA = 0.55;
232
+ const bandSteps = [];
233
+ for (let d = 0; d <= MAX_DEP; d += BAND_STEP) {
234
+ const t = Math.min(d / 18, 1);
235
+ const alpha = t * t * (NIGHT_ALPHA * 0.82) + (d > 18 ? (d - 18) / 6 * (NIGHT_ALPHA * 0.18) : 0);
236
+ bandSteps.push({ depression: d, alpha: Math.min(alpha, NIGHT_ALPHA) });
237
+ }
238
+ bandSteps.push({ depression: 90, alpha: NIGHT_ALPHA });
239
+ let prevAlpha = 0;
240
+ for (const zone of bandSteps) {
241
+ const dep = Math.min(zone.depression, 89);
242
+ const bandAlpha = zone.alpha - prevAlpha;
243
+ if (bandAlpha < 2e-3) {
244
+ prevAlpha = zone.alpha;
245
+ continue;
229
246
  }
230
- let prevX = isSunrise ? 0 : W;
231
- for (let i = terminator.length - 1; i >= 0; i--) {
232
- const tLon = isSunrise ? normLon(terminator[i].lon - 180) : terminator[i].lon;
233
- const x = lonToX(tLon, W);
234
- const y = latToY(terminator[i].lat, H);
235
- if (Math.abs(x - prevX) > W * 0.5 && i < terminator.length - 1) {
236
- const edgeX = isSunrise ? prevX < W / 2 ? 0 : W : prevX > W / 2 ? W : 0;
237
- ctx.lineTo(edgeX, y);
238
- ctx.closePath();
239
- ctx.fill();
240
- ctx.beginPath();
241
- ctx.moveTo(isSunrise ? 0 : W, y);
242
- }
243
- ctx.lineTo(x, y);
244
- prevX = x;
247
+ const terminator = calculateTerminatorContinuous(now, dep);
248
+ if (terminator.sunset.length === 0) {
249
+ prevAlpha = zone.alpha;
250
+ continue;
245
251
  }
246
- ctx.closePath();
247
- ctx.fill();
248
- };
249
- let prevTerminator = null;
250
- for (const zone of twilightZones) {
251
- const dep = Math.min(zone.depression, 89);
252
- const terminator = calculateTerminator(now, dep);
253
- ctx.fillStyle = `rgba(0, 6, 24, ${zone.alpha})`;
254
- if (prevTerminator) {
255
- fillNightSide(terminator, false);
256
- fillNightSide(terminator, true);
257
- } else {
258
- fillNightSide(terminator, false);
259
- fillNightSide(terminator, true);
252
+ const poly = buildNightPolygon(terminator);
253
+ if (poly.length < 3) {
254
+ prevAlpha = zone.alpha;
255
+ continue;
256
+ }
257
+ ctx.fillStyle = `rgba(0, 6, 24, ${bandAlpha.toFixed(4)})`;
258
+ for (const offset of [-360, 0, 360]) {
259
+ ctx.beginPath();
260
+ for (let i = 0; i < poly.length; i++) {
261
+ const lat = poly[i][0];
262
+ const lon = poly[i][1] + offset;
263
+ const x = lonToX(lon, W);
264
+ const y = latToY(lat, H);
265
+ if (i === 0) {
266
+ ctx.moveTo(x, y);
267
+ } else {
268
+ ctx.lineTo(x, y);
269
+ }
270
+ }
271
+ ctx.closePath();
272
+ ctx.fill();
260
273
  }
261
- prevTerminator = terminator;
274
+ prevAlpha = zone.alpha;
262
275
  }
263
276
  }
264
277
  ctx.fillStyle = COLORS.land;
@@ -482,6 +495,42 @@ function GroundTrackMap({
482
495
  ctx.textAlign = "center";
483
496
  ctx.fillText(gs.name.length > 14 ? gs.name.slice(0, 14) + "…" : gs.name, x, y + halfIcon + 12);
484
497
  }
498
+ if (lightSources && lightSources.length > 0 && showTerminator) {
499
+ const now = terminatorTime ?? /* @__PURE__ */ new Date();
500
+ for (const light of lightSources) {
501
+ const lx = lonToX(light.longitude, W);
502
+ const ly = latToY(light.latitude, H);
503
+ const r = light.radius ?? 4;
504
+ const intensity = light.intensity ?? 0.8;
505
+ const color = light.color ?? "#ffeedd";
506
+ const dayOfYear = Math.floor((now.getTime() - new Date(now.getFullYear(), 0, 0).getTime()) / 864e5);
507
+ const declination = -23.44 * Math.cos(2 * Math.PI / 365 * (dayOfYear + 10));
508
+ const decRad = declination * Math.PI / 180;
509
+ const hourAngle = (now.getUTCHours() + now.getUTCMinutes() / 60) / 24 * 360 - 180;
510
+ const latRad = light.latitude * Math.PI / 180;
511
+ const sinAlt = Math.sin(latRad) * Math.sin(decRad) + Math.cos(latRad) * Math.cos(decRad) * Math.cos((light.longitude - hourAngle) * Math.PI / 180);
512
+ const solarAltDeg = Math.asin(sinAlt) * 180 / Math.PI;
513
+ let nightFactor;
514
+ if (solarAltDeg >= 0) nightFactor = 0;
515
+ else if (solarAltDeg <= -18) nightFactor = 1;
516
+ else nightFactor = Math.min(1, -solarAltDeg / 18);
517
+ if (nightFactor < 0.01) continue;
518
+ const alpha = intensity * nightFactor;
519
+ const [cr, cg, cb] = hexToRgb(color);
520
+ const grad = ctx.createRadialGradient(lx, ly, 0, lx, ly, r * 2);
521
+ grad.addColorStop(0, `rgba(${cr}, ${cg}, ${cb}, ${alpha})`);
522
+ grad.addColorStop(0.4, `rgba(${cr}, ${cg}, ${cb}, ${alpha * 0.6})`);
523
+ grad.addColorStop(1, `rgba(${cr}, ${cg}, ${cb}, 0)`);
524
+ ctx.fillStyle = grad;
525
+ ctx.beginPath();
526
+ ctx.arc(lx, ly, r * 2, 0, Math.PI * 2);
527
+ ctx.fill();
528
+ ctx.fillStyle = `rgba(${cr}, ${cg}, ${cb}, ${Math.min(1, alpha * 1.3)})`;
529
+ ctx.beginPath();
530
+ ctx.arc(lx, ly, Math.max(1, r * 0.4), 0, Math.PI * 2);
531
+ ctx.fill();
532
+ }
533
+ }
485
534
  allSatellites.forEach((sat, satIdx) => {
486
535
  const track = sat.groundTrack;
487
536
  if (!track || track.length === 0) return;
@@ -790,7 +839,7 @@ function GroundTrackMap({
790
839
  ctx.textAlign = "center";
791
840
  ctx.fillText(emptyMessage, W / 2, H / 2);
792
841
  }
793
- }, [allSatellites, groundStations, height, showTerminator, terminatorTime, showGrid, showLegend, showEquator, lonToX, latToY, COLORS, emptyMessage, pins, tokens.colors.accent.primary]);
842
+ }, [allSatellites, groundStations, height, showTerminator, terminatorTime, showGrid, showLegend, showEquator, lonToX, latToY, COLORS, emptyMessage, pins, lightSources, tokens.colors.accent.primary]);
794
843
  const handleMouseMove = useCallback((e) => {
795
844
  const canvas = canvasRef.current;
796
845
  if (!canvas) return;
@@ -918,6 +967,8 @@ function GroundTrackMap({
918
967
  minHeight,
919
968
  emptyMessage,
920
969
  tileUrl,
970
+ nightTileUrl,
971
+ lightSources,
921
972
  className,
922
973
  onSatelliteClick,
923
974
  onStationClick,