-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathgeo-clock-card.ts
More file actions
1892 lines (1802 loc) · 72.5 KB
/
Copy pathgeo-clock-card.ts
File metadata and controls
1892 lines (1802 loc) · 72.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import { LitElement, html, css, svg, type TemplateResult } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { subsolarPoint, type SubsolarPoint } from './sun.js';
import { isDaylightAt, pickDayNightColor } from './marker-color.js';
import { terminatorCurve } from './terminator.js';
import { polygonToSvgPoints, latLonToPx } from './projection.js';
import { timezoneBand, BAND_H } from './timezone-band.js';
import { loadTimezones, timezonesToPolygons, type TzPolygon } from './timezones.js';
import {
loadIanaTimezones,
ianaToPolygons,
zoneNow,
findIanaZoneForLatLon,
type IanaPolygon,
} from './timezones-iana.js';
import { dayImageForDate } from './day-image.js';
import type {
GeoClockCardConfig,
HassLike,
MarkerConfig,
ResolvedConfig,
} from './types.js';
// Equirectangular working canvas. The SVG scales to fit, so this is
// just internal coordinate space for the polygon math + image extents.
const MAP_W = 2048;
const MAP_H = 1024;
const NIGHT_IMAGE = 'black-marble-2048.jpg';
const TZ_DATA = 'timezones.json';
const TZ_IANA_DATA = 'timezones-iana.json';
// Re-projection threshold for the TZ polygon overlay, in degrees of
// centerLon drift. Re-projecting all 419 IANA features (~1 MB of
// path data, then browser re-tessellation) is the heaviest periodic
// task in the card, and sun mode moves centerLon ~0.044° per map
// tick — a quarter-pixel. We only rebuild past this threshold and
// absorb the residual drift with a group translate (pixel-exact,
// because translating the projected paths is mathematically the
// same as re-projecting on an equirectangular map).
const TZ_REBUILD_DEG = 0.5;
// The terminator + imagery + TZ overlay + hour band are derived from
// the subsolar point and only meaningfully change when the subsolar
// longitude has shifted by ≥0.5 px at a 4K-wide map:
//
// 360° / 4096 px / 2 = 0.0439° per half-pixel
// ÷ 15° per hour → ~10.5 seconds
//
// Anything finer is sub-pixel even on hypothetical 4K dashboards, so
// throttling here saves polygon + path-string churn while leaving the
// wall-clock readout free to tick once a second.
const FOUR_K_WIDTH_PX = 4096;
const HALF_PX_DEG_AT_4K = 360 / FOUR_K_WIDTH_PX / 2;
const SUN_DEG_PER_MS = 15 / 3_600_000;
const MAP_UPDATE_INTERVAL_MS = HALF_PX_DEG_AT_4K / SUN_DEG_PER_MS;
// When the card is off-screen or the tab is backgrounded we cut the
// timer all the way down to one update every 30 minutes — enough to
// keep displayed values from being absurdly stale if the user
// suddenly brings the card back, but cheap enough to be invisible
// in CPU profiles. The card refreshes immediately the moment it
// becomes visible again.
const HIDDEN_INTERVAL_MS = 30 * 60 * 1000;
// HassLike is hoisted to types.ts so the editor can share the same
// minimal shape — see types.ts for the field-by-field rationale.
@customElement('geo-clock-card')
export class GeoClockCard extends LitElement {
@property({ attribute: false }) hass?: HassLike;
/** Wall-clock time used by the readout. Ticks at the configured
* updateInterval (default 1 s) so the displayed seconds advance
* smoothly. */
@state() private displayNow = new Date();
/** Time used by everything map-shaped (terminator, imagery offset,
* TZ overlay, hour band). Only advances when the subsolar point
* has moved ≥0.5 px at 4K — see MAP_UPDATE_INTERVAL_MS. */
@state() private mapNow = new Date();
/** Projected TZ overlay paths. Deliberately NOT @state: they are
* computed inside render() (from tzData/tzIanaData + centerLon)
* and read in the same pass. Making them reactive caused Lit to
* schedule a second full update cycle every time they were
* rebuilt ("update after update completed"), doubling the most
* expensive render path. The async data loads call
* requestUpdate() themselves, so reactivity here adds nothing. */
private tzPolygons: TzPolygon[] | null = null;
private tzIanaPolygons: IanaPolygon[] | null = null;
/** centerLon used the last time we built the TZ overlay paths.
* Re-projection is quantized: in sun mode centerLon drifts
* ~0.044° every map tick, and rebuilding 419 polygons (~1 MB of
* path data) for a sub-pixel shift caused a periodic jank spike.
* We rebuild only when the drift exceeds TZ_REBUILD_DEG and
* absorb the residue with a group translate (see render()). */
private tzPolygonsCenterLon: number | null = null;
private tzData: Awaited<ReturnType<typeof loadTimezones>> | null = null;
private tzIanaData: Awaited<ReturnType<typeof loadIanaTimezones>> | null = null;
private ianaTzCache = new Map<string, string | null>();
@state() private hoveredIana: IanaPolygon | null = null;
@state() private hoveredOffset: TzPolygon | null = null;
@state() private hoveredMarker: ResolvedMarker | null = null;
@state() private hoverPos: { x: number; y: number } | null = null;
/** Memoized terminator geometry. The tiled night polygon is
* ~1,085 vertices and the two SVG points strings total ~30 KB;
* their only inputs are mapNow (advances every ~10 s) and
* centerLon — but render() runs at 1 Hz for the clock readout
* and on every hover-state change. Rebuilding per render was
* the single biggest steady-state CPU cost in the card. */
private terminatorCache: {
mapNowMs: number;
centerLon: number;
points: string;
curvePoints: string;
} | null = null;
/** Pending hover position + rAF handle for the coalescer in
* updateHoverPos(). */
private hoverPosPending: { x: number; y: number } | null = null;
private hoverPosRaf = 0;
private config?: ResolvedConfig;
private timer?: ReturnType<typeof setInterval>;
/** Combined "the card is visible right now" signal: viewport
* intersection AND the document's tab visibility. When false we
* switch the timer to a 30-minute cadence. */
private isCardVisible = true;
private intersecting = true;
private intersectionObserver?: IntersectionObserver;
private onTabVisibility?: () => void;
static override styles = css`
:host {
display: block;
background: var(--ha-card-background, var(--card-background-color, #111));
border-radius: var(--ha-card-border-radius, 12px);
overflow: hidden;
color: var(--primary-text-color, #fff);
--geo-tz-bg: rgba(8, 14, 28, 0.85);
--geo-tz-hour: #d8e2f0;
--geo-tz-noon: #ffd866;
--geo-tz-mid: #6ab0ff;
--geo-tz-tick: rgba(255, 255, 255, 0.35);
--geo-tz-line: rgba(255, 255, 255, 0.18);
--geo-tz-line-width: 1;
--geo-home-marker: var(--accent-color, #ff7a3d);
--geo-marker-color: #3da9fc;
--geo-day-brightness: 1.15;
--geo-night-contrast: 1;
--geo-twilight-color: #463701;
--geo-twilight-opacity: 0.26;
}
.day-image {
filter: brightness(var(--geo-day-brightness));
}
.night-image {
filter: contrast(var(--geo-night-contrast));
}
/* Warm sunrise/sunset glow stroked along the terminator great
circle. Blurred + screen-blended so it brightens the day side
without dimming the night side. */
.twilight-glow {
fill: none;
stroke: var(--geo-twilight-color);
stroke-linecap: round;
stroke-linejoin: round;
opacity: var(--geo-twilight-opacity);
mix-blend-mode: screen;
pointer-events: none;
}
.frame {
position: relative;
width: 100%;
}
svg {
display: block;
width: 100%;
height: 100%;
}
.readout {
position: absolute;
bottom: 10px;
left: 14px;
font-family: var(--paper-font-headline_-_font-family, system-ui, sans-serif);
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9);
line-height: 1.15;
}
.local-time {
font-size: clamp(1rem, 2.4vw, 1.7rem);
font-weight: 500;
}
.utc-time {
font-size: clamp(0.75rem, 1.4vw, 1rem);
color: #ffd866;
opacity: 0.92;
}
.date {
position: absolute;
bottom: 10px;
right: 14px;
font-family: var(--paper-font-headline_-_font-family, system-ui, sans-serif);
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9);
font-size: clamp(0.85rem, 1.6vw, 1.15rem);
}
/* Hour band */
.tz-bg {
fill: var(--geo-tz-bg);
}
.tz-hour {
fill: var(--geo-tz-hour);
font-family: var(--paper-font-headline_-_font-family, system-ui, sans-serif);
font-weight: 500;
font-size: 26px;
}
.tz-hour.noon {
fill: var(--geo-tz-noon);
font-weight: 700;
}
.tz-hour.mid {
fill: var(--geo-tz-mid);
font-weight: 600;
}
.tz-tick {
stroke: var(--geo-tz-tick);
stroke-width: 1;
}
/* Time-zone boundary overlay — visible offset boundaries with a
transparent fill so the polygon interior is hit-testable.
Renders BELOW the IANA layer; IANA captures hover where it
has coverage (land), and we fall back to this layer's hover
in the gaps (open ocean, polar strips). */
.tz-region {
fill: rgba(255, 255, 255, 0);
stroke: var(--geo-tz-line);
stroke-width: var(--geo-tz-line-width);
stroke-linejoin: round;
stroke-linecap: round;
pointer-events: visiblePainted;
cursor: default;
transition: fill 120ms ease;
}
.tz-region:hover {
fill: rgba(255, 255, 255, 0.05);
}
/* Bands shown as pure chrome (showTimezoneRegions on, hover/popup
off): no hit-testing, no hover tint. This compound selector
outranks the bare .tz-region pointer-events rule above, which a
presentation attribute alone would NOT (CSS beats attributes). */
.tz-region.is-inert {
pointer-events: none;
}
.tz-region.is-inert:hover {
fill: rgba(255, 255, 255, 0);
}
/* Invisible IANA hit-test layer — tagged with each region's IANA
tzid so the popup can ask Intl.DateTimeFormat for DST-aware
local time. Faint tint on hover gives visual feedback that
the user is over an interactive region. */
.tz-iana-region {
fill: rgba(255, 255, 255, 0);
stroke: rgba(255, 255, 255, 0);
stroke-width: 0;
pointer-events: visiblePainted;
cursor: default;
transition: fill 120ms ease, stroke 120ms ease, stroke-width 120ms ease;
}
.tz-iana-region:hover,
.tz-iana-region.is-active {
fill: rgba(255, 255, 255, 0.08);
stroke: rgba(255, 255, 255, 0.65);
stroke-width: 1.5;
}
/* Home marker — overlay sibling of the SVG, same shape/CSS as
a regular marker but coloured via the home-specific theme
variable so users can restyle without touching card config.
The selector specificity (.home-marker .marker-halo /
.marker-dot) beats the bare .marker-halo / .marker-dot rules
above, so the home marker uses --geo-home-marker rather than
--geo-marker-color. The dot is non-interactive (no popup);
the label is rendered inline when showHomeMarkerLabel is true. */
.home-marker .marker-halo,
.home-marker .marker-dot {
background: var(--geo-home-marker);
}
.home-marker .marker-dot {
pointer-events: none;
cursor: default;
}
/* User-configured location markers. Rendered as HTML overlay
(not SVG) so their dot, halo, and label keep a constant CSS
pixel size regardless of the card's rendered width — SVG
<text> and circle radii live in viewBox units and shrink
linearly with the card, which made labels illegible at any
size below full-screen. The marker container itself is
positioned in percent (so it tracks the map's drift) but
its children are sized in px. */
.marker {
position: absolute;
width: 0;
height: 0;
pointer-events: none;
z-index: 3;
}
.marker-halo {
position: absolute;
width: 36px;
height: 36px;
left: -18px;
top: -18px;
border-radius: 50%;
opacity: 0.22;
pointer-events: none;
/* Default fill — themes override via --geo-marker-color, and
per-marker overrides via inline style still win because the
element-level style attribute beats a host-scope variable. */
background: var(--geo-marker-color);
}
.marker-dot {
position: absolute;
width: 14px;
height: 14px;
left: -7px;
top: -7px;
border-radius: 50%;
border: 1.2px solid rgba(0, 0, 0, 0.7);
box-sizing: border-box;
pointer-events: auto;
cursor: default;
transition: transform 120ms ease;
background: var(--geo-marker-color);
}
.marker.is-active .marker-dot {
transform: scale(1.3);
}
.marker-text {
position: absolute;
top: 9px;
left: 0;
transform: translateX(-50%);
text-align: center;
white-space: nowrap;
pointer-events: none;
font-family: var(--paper-font-headline_-_font-family, system-ui, sans-serif);
/* Multi-direction shadow gives a readable outline against
either bright daylight or dark city-lights imagery without
the cost of a true SVG paint-order stroke. */
text-shadow:
0 1px 2px rgba(0, 0, 0, 0.95),
0 0 3px rgba(0, 0, 0, 0.85),
0 0 6px rgba(0, 0, 0, 0.6);
color: #fff;
}
.marker-label {
font-size: 13px;
font-weight: 600;
line-height: 1.15;
}
.marker-time {
font-size: 12px;
font-weight: 500;
font-variant-numeric: tabular-nums;
letter-spacing: 0.02em;
line-height: 1.15;
margin-top: 1px;
}
/* Custom popup. Positioned via inline transform from JS so it
follows the cursor; ignores its own pointer events so it never
steals hover from the underlying region. */
.tz-popup {
position: absolute;
left: 0;
top: 0;
pointer-events: none;
background: rgba(8, 14, 28, 0.92);
color: var(--primary-text-color, #fff);
border-radius: 8px;
padding: 8px 12px;
font-family: var(--paper-font-headline_-_font-family, system-ui, sans-serif);
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.55);
max-width: 280px;
z-index: 5;
}
.tz-popup-time {
font-size: 1.15rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
letter-spacing: 0.02em;
}
.tz-popup-date {
font-size: 0.78rem;
opacity: 0.78;
margin-top: 1px;
}
.tz-popup-name {
font-size: 0.9rem;
color: #ffd866;
font-weight: 600;
margin-top: 2px;
}
.tz-popup-city {
font-size: 0.82rem;
opacity: 0.92;
margin-top: 1px;
}
.tz-popup-offset {
font-size: 0.72rem;
opacity: 0.62;
margin-top: 4px;
font-variant-numeric: tabular-nums;
}
.tz-popup-places {
font-size: 0.72rem;
opacity: 0.62;
margin-top: 3px;
line-height: 1.3;
}
`;
setConfig(config: GeoClockCardConfig): void {
if (!config) {
throw new Error('geo-clock-card: missing config');
}
const base = sanitizeImageryBase(config.imageryBase)
?? new URL('.', import.meta.url).href;
const frozenNow = parseFrozenNow(config.now);
this.config = {
twilightDegrees: clamp(config.twilightDegrees ?? 8, 1, 18),
updateInterval: clamp(config.updateInterval ?? 1, 1, 600),
showUTC: config.showUTC ?? true,
showTimezoneBand: config.showTimezoneBand ?? true,
showTimezoneBoundaries: config.showTimezoneBoundaries ?? true,
// Vertical offset bands default to the boundaries flag so existing
// configs (and the HA editor, which only exposes boundaries) behave
// exactly as before. The web demo sets it independently to group the
// bands with the hour band.
showTimezoneRegions:
config.showTimezoneRegions ?? config.showTimezoneBoundaries ?? true,
showTimezonePopup: config.showTimezonePopup ?? true,
timezoneLineColor:
sanitizeCssColor(config.timezoneLineColor) ??
'rgba(255, 255, 255, 0.18)',
dayBrightness: clamp(config.dayBrightness ?? 1.15, 0, 5),
nightContrast: clamp(config.nightContrast ?? 1, 0, 5),
twilightColor: sanitizeCssColor(config.twilightColor) ?? '#463701',
twilightOpacity: clamp(config.twilightOpacity ?? 0.26, 0, 1),
imageryBase: base.endsWith('/') ? base : base + '/',
center: config.center ?? 'sun',
centerLongitude:
typeof config.centerLongitude === 'number'
? clamp(config.centerLongitude, -180, 180)
: undefined,
centerEntity: config.centerEntity,
showHomeMarker: config.showHomeMarker ?? false,
showHomeMarkerLabel: config.showHomeMarkerLabel ?? false,
markers: sanitizeMarkers(config.markers),
markerLabelMode:
config.markerLabelMode === 'hover' ? 'hover' : 'always',
// markerColor stays undefined when the user hasn't explicitly
// set it so `--geo-marker-color` is the true default. The
// sanitiser drops anything that isn't a recognised CSS color.
markerColor: sanitizeCssColor(config.markerColor),
// Card-level day/night defaults. Both undefined unless the
// user sets them — that's what keeps day/night mode opt-in so
// existing single-color cards don't change.
markerDayColor: sanitizeCssColor(config.markerDayColor),
markerNightColor: sanitizeCssColor(config.markerNightColor),
markerShowDay: config.markerShowDay ?? true,
mainTimeSource: pickMainTimeSource(config.mainTimeSource),
mainTimeEntity: config.mainTimeEntity,
// Empty string would make Intl throw a RangeError, so coerce it
// (and any falsy) to undefined → browser/runtime default locale.
locale: config.locale || undefined,
frozenNow,
};
// Pin or release the clocks based on the frozen setting.
const seed = frozenNow ?? new Date();
this.displayNow = seed;
this.mapNow = seed;
this.ianaTzCache.clear();
// New config might change centerLon → invalidate cached paths.
this.tzPolygons = null;
this.tzIanaPolygons = null;
this.tzPolygonsCenterLon = null;
this.restartTimer();
this.maybeLoadTimezones();
this.maybeLoadIanaTimezones();
}
override connectedCallback(): void {
super.connectedCallback();
if (!this.config?.frozenNow) {
const now = new Date();
this.displayNow = now;
this.mapNow = now;
}
this.attachVisibilityObservers();
this.restartTimer();
this.maybeLoadTimezones();
this.maybeLoadIanaTimezones();
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this.stopTimer();
this.clearDismissTimer();
this.clearIdleTimer();
this.detachVisibilityObservers();
if (this.hoverPosRaf) {
cancelAnimationFrame(this.hoverPosRaf);
this.hoverPosRaf = 0;
}
}
/** Track whether this card is on-screen and the tab is foregrounded.
* Either signal turning off cuts updates to a 30-minute cadence. */
private attachVisibilityObservers(): void {
if (typeof IntersectionObserver !== 'undefined' && !this.intersectionObserver) {
this.intersectionObserver = new IntersectionObserver(
(entries) => {
const e = entries[entries.length - 1];
this.intersecting = e ? e.isIntersecting : true;
this.recomputeVisibility();
},
{ threshold: 0 },
);
this.intersectionObserver.observe(this);
}
if (typeof document !== 'undefined' && !this.onTabVisibility) {
this.onTabVisibility = () => this.recomputeVisibility();
document.addEventListener('visibilitychange', this.onTabVisibility);
}
}
private detachVisibilityObservers(): void {
this.intersectionObserver?.disconnect();
this.intersectionObserver = undefined;
if (this.onTabVisibility && typeof document !== 'undefined') {
document.removeEventListener('visibilitychange', this.onTabVisibility);
}
this.onTabVisibility = undefined;
}
private recomputeVisibility(): void {
const tabVisible =
typeof document === 'undefined' ||
document.visibilityState !== 'hidden';
const visible = this.intersecting && tabVisible;
if (visible === this.isCardVisible) return;
this.isCardVisible = visible;
if (visible) {
// Snap clocks forward to "now" so the user doesn't see a stale
// readout while the timer is restarting at fast cadence.
const now = new Date();
this.displayNow = now;
this.mapNow = now;
}
this.restartTimer();
}
private restartTimer(): void {
this.stopTimer();
if (!this.config || !this.isConnected) return;
if (this.config.frozenNow) return; // frozen clock — no timer
const intervalMs = this.isCardVisible
? this.config.updateInterval * 1000
: HIDDEN_INTERVAL_MS;
this.timer = setInterval(() => this.tick(), intervalMs);
}
private tick(): void {
const now = new Date();
this.displayNow = now;
if (now.getTime() - this.mapNow.getTime() >= MAP_UPDATE_INTERVAL_MS) {
this.mapNow = now;
}
}
private stopTimer(): void {
if (this.timer !== undefined) {
clearInterval(this.timer);
this.timer = undefined;
}
}
private maybeLoadTimezones(): void {
// tzData feeds the vertical offset bands, gated on showTimezoneRegions
// (the IANA hover layer loads separately in maybeLoadIanaTimezones).
if (!this.config?.showTimezoneRegions || this.tzData !== null) return;
const url = this.config.imageryBase + TZ_DATA;
loadTimezones(url)
.then((data) => {
this.tzData = data;
// Force re-render so the overlay path gets built with the
// current centerLon.
this.requestUpdate();
})
.catch((err) => {
// Don't fail the card if the file is missing — log and move on
// with a bare map. Common during dev before the asset is built.
console.warn('geo-clock-card: timezone overlay failed to load:', err);
});
}
private maybeLoadIanaTimezones(): void {
if (!this.config || this.tzIanaData !== null) return;
// We need IANA data for: (a) the on-map polygon overlay, (b)
// resolving tzid for any configured marker, (c) resolving tzid
// for `mainTimeSource: 'entity'`, and (d) the home tzid lookup
// when HA hasn't reported a `time_zone` config. Skip the fetch
// only when none of these apply.
const needForMarkers = this.config.markers.length > 0;
const needForEntityClock = this.config.mainTimeSource === 'entity';
const needForHomeClock =
this.config.mainTimeSource === 'home' &&
typeof this.hass?.config?.time_zone !== 'string';
if (
!this.config.showTimezoneBoundaries &&
!needForMarkers &&
!needForEntityClock &&
!needForHomeClock
) {
return;
}
const url = this.config.imageryBase + TZ_IANA_DATA;
loadIanaTimezones(url)
.then((data) => {
this.tzIanaData = data;
this.ianaTzCache.clear();
this.requestUpdate();
})
.catch((err) => {
// Same fallback as the offset layer: skip silently if the
// file isn't there. The offset layer alone still works.
console.warn(
'geo-clock-card: IANA timezone overlay failed to load:',
err,
);
});
}
/** Used for fallbacks below — Greenwich rather than subsolar so a
* misconfigured non-sun mode looks visibly different from sun. */
private static readonly FALLBACK_CENTER_LON = 0;
private warnedFallback = '';
/**
* Resolve the longitude (degrees, signed) that should appear at the
* center of the map. Driven by mapNow — for 'sun' mode this means
* the map shifts only when the subsolar longitude has moved enough
* to be visible (≥0.5 px at 4K).
*
* Modes that need data which isn't available (no HA longitude,
* entity missing, centerLongitude unset) fall back to GREENWICH
* (lon=0), not subsolar — that way a broken `home` config doesn't
* look identical to `sun` mode and the user sees their selection
* registered. A console warning is emitted so the cause is visible
* in DevTools.
*/
private resolveCenterLon(mapNow: Date): number {
if (!this.config) return subsolarPoint(mapNow).lon;
switch (this.config.center) {
case 'home': {
const lon = this.hass?.config?.longitude;
if (typeof lon === 'number') return lon;
this.warnFallback(
'home',
'hass.config.longitude is not set; falling back to Greenwich (0°)',
);
return GeoClockCard.FALLBACK_CENTER_LON;
}
case 'longitude':
if (typeof this.config.centerLongitude === 'number') {
return this.config.centerLongitude;
}
this.warnFallback(
'longitude',
'centerLongitude not set; falling back to Greenwich (0°)',
);
return GeoClockCard.FALLBACK_CENTER_LON;
case 'entity': {
const id = this.config.centerEntity;
const state = id ? this.hass?.states?.[id] : undefined;
const lon = state?.attributes?.longitude;
if (typeof lon === 'number') return lon;
this.warnFallback(
'entity',
id
? `entity '${id}' has no numeric longitude attribute; falling back to Greenwich (0°)`
: 'centerEntity not set; falling back to Greenwich (0°)',
);
return GeoClockCard.FALLBACK_CENTER_LON;
}
case 'sun':
default:
return subsolarPoint(mapNow).lon;
}
}
/** De-duped console warning per (mode, message) so we don't spam
* the console with the same warning every render. */
private warnFallback(mode: string, message: string): void {
const key = `${mode}|${message}`;
if (this.warnedFallback === key) return;
this.warnedFallback = key;
console.warn(`geo-clock-card: center mode '${mode}' — ${message}`);
}
/** Returns (lat, lon) for the home marker, or null if we have no
* HA-configured location. Always reads from hass.config — the
* marker represents the user's actual home regardless of the
* map's centering mode. */
private resolveHomeLatLon(): { lat: number; lon: number } | null {
const lat = this.hass?.config?.latitude;
const lon = this.hass?.config?.longitude;
if (typeof lat !== 'number' || typeof lon !== 'number') return null;
return { lat, lon };
}
private lookupIanaTz(lat: number, lon: number): string | null {
if (!this.tzIanaData) return null;
// 2-decimal key (~1 km grid) — far finer than the 1%-simplified
// polygon boundaries we hit-test against, and it keeps a moving
// device_tracker from minting a fresh cache entry on every
// position report. The size cap guards always-on dashboards
// tracking many movers: 4-decimal keys grew the map unboundedly.
const key = `${lat.toFixed(2)},${lon.toFixed(2)}`;
let val = this.ianaTzCache.get(key);
if (val === undefined) {
val = findIanaZoneForLatLon(this.tzIanaData, lat, lon);
if (this.ianaTzCache.size >= 512) this.ianaTzCache.clear();
this.ianaTzCache.set(key, val);
}
return val;
}
/**
* Resolve the IANA tzid the main clock readout should use, based on
* `mainTimeSource`. Returns undefined to mean "use the browser's
* default zone" — that's also the fallback whenever a configured
* source can't be resolved (e.g. entity missing, IANA data still
* loading).
*/
private resolveMainTimezone(): string | undefined {
if (!this.config) return undefined;
switch (this.config.mainTimeSource) {
case 'device':
return undefined;
case 'home': {
// HA almost always exposes its own zone via config.time_zone,
// and using that directly avoids an unnecessary polygon
// lookup. Only fall through to the polygon hit-test when the
// attribute is missing (rare).
const tz = this.hass?.config?.time_zone;
if (typeof tz === 'string' && tz) return tz;
const home = this.resolveHomeLatLon();
if (home && this.tzIanaData) {
return (
this.lookupIanaTz(home.lat, home.lon) ??
undefined
);
}
return undefined;
}
case 'entity': {
const id = this.config.mainTimeEntity;
const state = id ? this.hass?.states?.[id] : undefined;
const lat = state?.attributes?.latitude;
const lon = state?.attributes?.longitude;
if (
typeof lat === 'number' &&
typeof lon === 'number' &&
this.tzIanaData
) {
return (
this.lookupIanaTz(lat, lon) ?? undefined
);
}
return undefined;
}
}
}
/**
* Walk the configured markers, resolving each one's entity to a
* (lat, lon, tzid) triple. Markers whose entity is missing or has
* no numeric lat/lon are silently dropped so a stale config doesn't
* crash the render. The tzid is null until the IANA dataset has
* loaded — in that interim case we still render the dot, just with
* no time line.
*/
private resolveMarkers(): ResolvedMarker[] {
if (!this.config || this.config.markers.length === 0) return [];
const out: ResolvedMarker[] = [];
for (const m of this.config.markers) {
const state = this.hass?.states?.[m.entity];
if (!state) continue;
const lat = state.attributes?.latitude;
const lon = state.attributes?.longitude;
if (typeof lat !== 'number' || typeof lon !== 'number') continue;
const friendly =
typeof state.attributes?.friendly_name === 'string'
? (state.attributes.friendly_name as string)
: m.entity;
const label =
(typeof m.label === 'string' && m.label.trim()) || friendly;
const tzid = this.lookupIanaTz(lat, lon);
out.push({
entity: m.entity,
label,
// Per-marker > card-level > undefined (CSS variable wins).
// sanitizeCssColor returns undefined for anything that doesn't
// match the safe-color regex, so a malicious entry can't slip
// a `; url(...)` into the inline style.
color: sanitizeCssColor(m.color) ?? this.config.markerColor,
// Same precedence for the day/night colors. When either
// resolves, the renderer switches this marker to day/night
// mode (picking by sun elevation at its lat/lon).
dayColor:
sanitizeCssColor(m.dayColor) ?? this.config.markerDayColor,
nightColor:
sanitizeCssColor(m.nightColor) ?? this.config.markerNightColor,
lat,
lon,
tzid,
});
}
return out;
}
/**
* Terminator geometry as SVG points strings, tiled across
* x = [-MAP_W, 2·MAP_W] and memoized on (mapNow, centerLon).
*
* Why tiled: the day/night image pair is drawn at offsetPx and
* offsetPx-MAP_W, so when the SVG element is letterboxed inside a
* viewport wider than the viewBox aspect (21:9 ultrawides) map
* content lands in the side bars — and the night region + dusk rim
* must follow it there. One merged polygon (rather than three
* separately-feathered copies) keeps the feather filter from
* rendering visible vertical fade edges at the wrap seams: the
* polygon's only vertical closing edges sit at x=-MAP_W and
* x=+2·MAP_W, well outside the viewBox. Same for the polyline:
* one continuous stroke means no per-segment round-caps at seams.
*
* Why memoized: the inputs change every ~10 s (mapNow tick) or on
* a center change, but render() runs at 1 Hz plus on every hover
* update. Rebuilding ~1,085 vertices + ~30 KB of points strings
* per render was pure waste.
*/
private terminatorGeometry(
mapNow: Date,
centerLon: number,
): { points: string; curvePoints: string } {
const mapNowMs = mapNow.getTime();
const cached = this.terminatorCache;
if (
cached &&
cached.mapNowMs === mapNowMs &&
cached.centerLon === centerLon
) {
return cached;
}
const sub = subsolarPoint(mapNow);
const curve = terminatorCurve(sub, { centerLon });
// Dark-pole latitude, mirroring terminatorPolygon's declination
// clamp: a clamped declination is always the same sign as
// sub.lat (>= 0 maps to +MIN_ABS_DECL), so the dark pole is
// south for northern declinations and vice versa. Computing it
// directly avoids re-running the whole 361-point curve a second
// time via terminatorPolygon just to read its last vertex.
const darkPoleLat = sub.lat >= 0 ? -90 : 90;
// terminatorCurve emits lonE ∈ [0, 360] INCLUSIVE, so naive
// concatenation would duplicate the seam vertex (left copy's
// lonE=360-360=0 followed by center copy's lonE=0). Slice the
// closing vertex off the non-final copies so the tiled arrays
// stay strictly monotonic in x.
const open = curve.slice(0, -1);
// Twilight polyline: full ±1 tile so the dusk rim runs
// continuously through the letterbox bars (strokes have no
// mask interactions; this has been solid in practice).
const tiledCurve = [
...open.map(([lonE, lat]) => [lonE - 360, lat] as const),
...open,
...curve.map(([lonE, lat]) => [lonE + 360, lat] as const),
];
// Night polygon: small overhang (lonE -45..405, ≈±256 px).
// The night-mask region is exactly one viewport wide
// (x ∈ [0, MAP_W]) and the bars are covered by <use> copies
// of the whole masked night unit — so the polygon only needs
// to overhang far enough that the feather blur (3σ ≤ ~77 px
// at the max twilight setting) never samples its vertical
// closing edges from inside the region. Keep the overhang
// SMALL: WebKit silently clips filtered mask content to
// roughly one viewport's width measured from the geometry's
// left edge, so every extra degree of overhang is a degree
// of night lost on the right side of the map. (A 3-tile
// polygon lost a third of the night layer; a half-world
// overhang still lost the right ~quarter.)
const OVERHANG_DEG = 45;
const tiledPolyVertices: [number, number][] = [
...open.slice(open.length - OVERHANG_DEG).map(
([lonE, lat]) => [lonE - 360, lat] as [number, number],
),
...open.map(([lonE, lat]) => [lonE, lat] as [number, number]),
...curve.slice(0, OVERHANG_DEG + 1).map(
([lonE, lat]) => [lonE + 360, lat] as [number, number],
),
[360 + OVERHANG_DEG, darkPoleLat],
[-OVERHANG_DEG, darkPoleLat],
];
const next = {
mapNowMs,
centerLon,
points: polygonToSvgPoints(tiledPolyVertices, MAP_W, MAP_H),
curvePoints: polygonToSvgPoints(tiledCurve, MAP_W, MAP_H),
};
this.terminatorCache = next;
return next;
}
override render(): TemplateResult {
if (!this.config) return html``;
// Two clocks: mapNow drives anything tied to the planet's
// orientation (terminator, imagery, hour band, TZ overlay path);
// displayNow drives the readout. When `frozenNow` is set both
// collapse to the same value.
const mapNow = this.config.frozenNow ?? this.mapNow;
const displayNow = this.config.frozenNow ?? this.displayNow;
const centerLon = this.resolveCenterLon(mapNow);
// Subsolar point for this frame — drives day/night marker color
// selection. Computed from mapNow so a marker flips exactly as
// the rendered terminator (also mapNow-based) sweeps over it.
const sub = subsolarPoint(mapNow);
const { points, curvePoints } = this.terminatorGeometry(
mapNow,
centerLon,
);
// Twilight band → Gaussian σ in image-pixel space. Total fade
// ≈ 8σ end-to-end; mapping elevation degrees → arc degrees → px
// via the lat axis scale.
const fadePxFull = (this.config.twilightDegrees * 2 * MAP_H) / 180;
const sigma = Math.max(0.5, fadePxFull / 8);
// Twilight glow polyline: thick stroke at the curve, then
// softened with a smaller Gaussian. Together they paint a warm
// band roughly the same width as the night-mask fade, but
// additive (screen-blended) instead of subtractive — that's what
// gives the terminator an atmospheric "rim of dusk" look.
const glowStrokeWidth = Math.max(4, fadePxFull * 0.55);
const glowBlurSigma = Math.max(1, fadePxFull / 5);
const dayHref = this.config.imageryBase + dayImageForDate(mapNow);
const nightHref = this.config.imageryBase + NIGHT_IMAGE;
// Imagery offset for the configured centerLon. The source JPEGs
// are Greenwich-centered (lon=0 at source x=W/2). We render the
// source at output x = offsetPx and again at offsetPx − W so the
// wraparound is covered by exactly two <image> tags. Both share
// an href so the browser issues one HTTP request per layer.
const offsetPx = (((-centerLon / 360) * MAP_W) % MAP_W + MAP_W) % MAP_W;
// (Re)project overlays when the centerLon has drifted past the
// rebuild threshold OR a layer exists in data form but hasn't
// been projected yet (the async fetch usually arrives after the
// first render). Sub-threshold drift is absorbed by translating
// the layer groups by tzDriftPx instead of re-projecting — see
// TZ_REBUILD_DEG. Late-arriving layers are built at the lon the
// earlier layer was recorded against so both stay aligned with
// each other and with the shared drift translate.
let tzDriftPx = 0;
// Project whenever either layer is shown: the bands key on
// showTimezoneRegions, the IANA hover layer on showTimezoneBoundaries.
if (this.config.showTimezoneRegions || this.config.showTimezoneBoundaries) {
const recorded = this.tzPolygonsCenterLon;
const needsRebuild =
recorded === null || Math.abs(recorded - centerLon) > TZ_REBUILD_DEG;
const buildLon = needsRebuild ? centerLon : (recorded ?? centerLon);
if (this.tzData && (needsRebuild || this.tzPolygons === null)) {
this.tzPolygons = timezonesToPolygons(this.tzData, MAP_W, MAP_H, buildLon);
}
if (this.tzIanaData && (needsRebuild || this.tzIanaPolygons === null)) {
this.tzIanaPolygons = sortByVisualArea(
ianaToPolygons(this.tzIanaData, MAP_W, MAP_H, buildLon),
);
}
this.tzPolygonsCenterLon = buildLon;
// Paths were projected for buildLon; the map is at centerLon.
// Increasing centerLon shifts map content left, so the
// compensation is (buildLon − centerLon) in pixel space.
tzDriftPx = ((buildLon - centerLon) / 360) * MAP_W;
}