Repository navigation
Expand file tree
/
Copy pathwidget.html
More file actions
850 lines (773 loc) · 42.6 KB
/
Copy pathwidget.html
File metadata and controls
850 lines (773 loc) · 42.6 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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<title>XCTrack Windmap widget</title>
<style>
/* The body stays UNPAINTED. XCTrack renders white or absent as transparent,
which is what lets this float over its map. Never add a background. */
html,body{margin:0;padding:0;background:transparent;height:100%;overflow:hidden}
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
/* The browser applies `hidden` as a low-priority [hidden]{display:none}. Any
author rule that sets display, #zbtns{display:flex} did. Silently outranks
it and the element stays on screen. base.css restates this for the other
pages; this one cannot load base.css, because its body must stay unpainted,
so it needs its own copy. Without it the zoom buttons rendered with zbtn=0,
landed top-left for want of a position class, and did nothing because their
handlers are only bound when the feature is on. */
[hidden]{display:none !important}
#cv{position:absolute;left:0;top:0;width:100%;height:100%}
/* The badge carries its own background; nothing else does. */
#badge{position:absolute;left:0;top:0;z-index:10;padding:2px 5px;
background:rgba(10,17,22,.78);color:#E8F0F5;
font:600 11px/1.25 ui-monospace,"Roboto Mono",monospace;white-space:pre}
#badge.bad{background:rgba(126,26,20,.92)}
/* A compact chip sitting directly ABOVE our scale bar, pointing at it. A
full-width bar here covered XCTrack's own scale, which is the one thing the
pilot has to compare against, it made itself pointless. Transient: it is a
prompt, not a state, and the state lives on in the amber bar number. */
#warn{position:absolute;z-index:6;white-space:nowrap;
padding:7px 10px;background:rgba(255,99,71,.95);color:#FFF;border-radius:3px;
font:800 12px/1.15 ui-monospace,"Roboto Mono",monospace;
opacity:1;transition:opacity .5s}
#warn.fade{opacity:0;pointer-events:none}
/* Only present when ztap=1. Invisible: they exist to catch a tap, not to be
looked at. Nothing else in this widget is a hit target, deliberately.
With tapping enabled the WebView swallows every touch over its area, so a
zone here is a zone taken away from XCTrack. */
.zone{position:absolute;left:0;width:100%;height:50%}
#zin{top:0} #zout{top:50%}
/* Dedicated zoom buttons. Small and cornered, so they cost little area, the
invisible halves can stay on alongside them. */
/* Placement is the pilot's, because the widget shares its rectangle with
XCTrack's own controls and only they know what is already there. Including
a second pair for the map's zoom, which is why the row layout exists. */
#zbtns{position:absolute;display:flex;flex-direction:column;gap:4px;
padding:4px;z-index:7}
#zbtns.row{flex-direction:row}
#zbtns.p-bottom-right{right:0;bottom:0}
#zbtns.p-bottom-left{left:0;bottom:0}
#zbtns.p-top-right{right:0;top:0}
#zbtns.p-top-left{left:0;top:0}
#zbtns.p-top-centre{top:0;left:50%;transform:translateX(-50%)}
#zbtns.p-bottom-centre{bottom:0;left:50%;transform:translateX(-50%)}
#zbtns.p-left-centre{left:0;top:50%;transform:translateY(-50%)}
#zbtns.p-right-centre{right:0;top:50%;transform:translateY(-50%)}
#zbtns button{width:46px;height:46px;background:rgba(10,17,22,.82);color:#E8F0F5;
border:1px solid #2A3B48;border-radius:4px;font:700 22px ui-monospace,monospace}
/* Theme tokens, the same names and values base.css carries, restated here
because this page cannot load it. Light is the base, dark applies on a dark
phone unless ?theme=light forces otherwise, and [data-theme] wins over both.
ONLY the popup is themed. The badge, the warn chip and the scale bar sit on
XCTrack's map, where they need contrast against terrain. Grass, snow, rock.
Not against a page, so they stay dark on light at every setting. */
:root{
--pop-bg:rgba(255,255,255,.97);
--rule:#CBD6DE; --fg:#0F1A21; --dim:#5C7180; --chart:#1E6FA8;
--amber:#A96608; --red:#B3271C;
}
@media (prefers-color-scheme: dark){
:root:not([data-theme="light"]){
--pop-bg:rgba(10,17,22,.96);
--rule:#2A3B48; --fg:#E8F0F5; --dim:#7B909F; --chart:#54A6DC;
--amber:#E9A33C; --red:#D6483C;
}
}
[data-theme="dark"]{
--pop-bg:rgba(10,17,22,.96);
--rule:#2A3B48; --fg:#E8F0F5; --dim:#7B909F; --chart:#54A6DC;
--amber:#E9A33C; --red:#D6483C;
}
/* Detail popup. Carries its own background, the body must stay unpainted. */
#pop{position:absolute;left:0;right:0;bottom:0;z-index:9;max-height:74%;overflow:auto;
background:var(--pop-bg);border-top:1px solid var(--rule);
color:var(--fg);font:400 12px/1.4 system-ui,-apple-system,Roboto,sans-serif;padding:9px 10px 11px}
#pop h3{margin:0 0 1px;font-size:15px;font-weight:700}
#pop .sub{color:var(--dim);font:400 11px/1.35 ui-monospace,"Roboto Mono",monospace;margin:0 0 8px}
#pop .now{display:flex;gap:14px;align-items:baseline;margin:0 0 9px}
#pop .now b{font:700 26px ui-monospace,monospace;letter-spacing:-.02em}
#pop .now i{font-style:normal;color:var(--dim);font-size:11px}
/* Fixed height. The strip fills in a moment after the header, and without a
reserved box the whole panel would resize as it arrives. Which is what the
flash on tapping a second marker actually was. */
#pop .trend{display:flex;gap:3px;overflow-x:auto;padding-bottom:2px;
min-height:62px;align-items:center}
/* See WG.marker.trendScroll: only while the oldest samples are scrolled out. */
#pop .trend.cut{-webkit-mask-image:linear-gradient(to right,transparent 0,#000 24px);
mask-image:linear-gradient(to right,transparent 0,#000 24px)}
#pop .trend .wait{color:var(--dim);font:400 11px ui-monospace,monospace}
#pop .t{flex:none;width:44px;text-align:center}
#pop .t svg{display:block;width:34px;height:34px;margin:0 auto}
#pop .t em{display:block;font:700 11px ui-monospace,monospace;font-style:normal}
#pop .t span{display:block;color:var(--dim);font:400 9px ui-monospace,monospace}
#pop .src{color:var(--dim);font-size:10px;margin-top:8px;line-height:1.45}
/* Must out-specify "#pop .now i", which sets the muted colour. The age is the
field that says whether the reading can be trusted; it has to carry its state. */
#pop .now i.stale, #pop .stale{color:var(--red);font-weight:700}
#pop .now i.warn, #pop .warn{color:var(--amber)}
</style>
</head>
<body>
<canvas id="cv"></canvas>
<div id="zin" class="zone" hidden></div>
<div id="zout" class="zone" hidden></div>
<div id="zbtns" hidden><button id="bIn" type="button">+</button><button id="bOut" type="button">−</button></div>
<div id="warn" hidden>← Check scale!</div>
<div id="pop" hidden></div>
<div id="badge"></div>
<script src="wg/core.js"></script>
<script src="wg/marker.js"></script>
<script src="wg/windsmobi.js"></script>
<script>
"use strict";
/* ══════════════════════════════════════════════════════════════════════
widget.html, Phase 3b. Arrows only, on a transparent page, stacked ON
TOP of an XCTrack map widget covering the same rectangle.
REQUIRED XCTrack SETUP
XC map widget (below): scale per the bar, rotation NORTH-UP
This widget (above): same rectangle, refresh rate 0,
"Allow web page to access XCTrack data" ON
Refresh rate MUST be 0. With ${lat}/${lng} substitution XCTrack reloads
the whole page on its refresh interval, which would bin the canvas, the
station cache and any in-flight fetch every minute or two.
The badge states the zoom and the XCTrack scale it pairs with, so a
mis-paired map reads as a disagreement between two labels rather than as
silent drift. Arrow POSITIONS are the only thing a mis-pairing breaks;
numbers, colours and altitudes stay correct regardless.
═══════════════════════════════════════════════════════════════════ */
var C = WG.cfg(location.search);
var PROV = WG.providers.windsmobi;
var cv = document.getElementById("cv"), ctx = cv.getContext("2d");
/* Double buffered. Markers are drawn into an offscreen canvas and blitted
in ONE composite-copy operation, so the visible canvas is never caught
cleared-but-not-yet-redrawn. That is a flicker risk at 2 Hz over a moving
map, and it also made headless screenshots come out blank, the capture
landed between the clear and the redraw. */
var buf = document.createElement("canvas"), bctx = buf.getContext("2d");
var W = 0, H = 0, dpr = 1;
/* Density FIRST, before anything projects. XCTrack draws its map in device
pixels, so this is what makes the overlay agree with it on any screen; cal is
only a residual on top. Measured, see docs/findings.md. */
WG.setDpr(C.dpr || window.devicePixelRatio || WG.DPR_REF);
WG.setCal(C.cal);
/* Theme, by the same <html data-theme> convention base.css uses, WG.applyTheme
lives in fields.js, which this page does not load. Only the popup reacts;
see the CSS. */
if (C.theme !== "auto") document.documentElement.setAttribute("data-theme", C.theme);
/* The scale actually being drawn. Equals homeStep until the pilot taps a zone;
after that the widget CANNOT know whether the map followed, which is why the
badge marks it and the scale bar exists.
homeStep is resolved from C.scale on the FIRST fix and then held. Latitude moves the
answer slightly (cos lat), and re-resolving in flight would let the overlay
jump a step mid-glide while the map underneath stayed put. */
var homeStep = 0, curStep = 0;
function setHome(lat) {
if (homeStep) return;
homeStep = WG.resolveStep(C, lat);
if (!curStep) curStep = homeStep;
}
/* Left inset of both bars, matching XCTrack's. Measured off the owner's screen
at ~15 css px, so the two start at the same x and a difference in LENGTH is
the only thing left to see. */
var BAR_X = 15;
/* Height of XCTrack's own bar off the bottom, measured from the ruler runs at
two densities (~10 css px). Only used to centre the prompt in the gap
between the two bars; nothing about the geometry depends on it. */
var XCT_BAR_Y = 10;
var barLat = 47;
/* Screen positions of the markers actually drawn, so a tap can be matched to a
station. Rebuilt on every draw. */
var hits = [];
var popStation = null, popTimer = null, popHist = null, popErr = "";
/* id -> {at, list}. A re-tap inside the data's own cadence is answered from
here, so it neither flashes nor spends a call. */
var histCache = {};
var stations = [], lastFetch = 0, fetchErr = "", fetching = false;
/* How many the box actually held, whether the API truncated at its 500 cap,
and how many survived the view cull. The three numbers that say whether a
missing marker was never fetched, was capped away, or lost a collision. */
var fetchCapped = false, nRanked = 0;
/* How many markers had to be moved to fit. Reported, because a displaced
marker is a fact with an asterisk and the pilot should know how many. */
var nNudged = 0;
var drawKey = "", lastShown = 0;
/* ── sizing. Cap the backing store at DPR 2: the probe measured DPR 3, and
full-DPR costs ~9x the fill rate for no legibility gain at 100 m/px. ── */
function resize(){
W = window.innerWidth; H = window.innerHeight;
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = buf.width = Math.round(W * dpr);
cv.height = buf.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
bctx.setTransform(dpr, 0, 0, dpr, 0, 0);
drawKey = "";
}
window.addEventListener("resize", resize);
/* Marker and label come from wg/marker.js, shared with app.html's SVG so the
geometry, the band widths and the downwind rotation cannot drift between the
two renderings. Extracted from this file because it was untestable here. */
/* ── draw ─────────────────────────────────────────────────────────────
Declutter by collision, nearest first. A whole marker at a time: a number
without its arrow has lost its direction, an arrow without its number has
lost the reading, and either half alone is worse than omitting the station,
see the SeeYou decluttering bug in handover.md.
NUDGE (`nudge=1`, on) puts back the readings that would otherwise be
dropped. WG.marker.layout() does it in two phases:
1. place everything that fits, nearest first. Byte for byte what nudge=0
draws, so turning it on never MOVES a marker that was already fine
2. for each one that did not fit, ring-search a small radius around its
true position and take the angle furthest from the markers already
there; if no angle keeps the numbers readable, stack it below the
marker it collided with
Nothing fades: the leader line is the annotation. At most three markers end
up in one pile.
The case that forced all this, from Zermatt: ZFC: Landing at 2600 m and
Zermatt at 1648 m are 387 m apart on the ground. 16 px at scale 3000. So
the valley floor station was being discarded in favour of one nearly a
kilometre above it. With strong valley winds those two readings are not
near-duplicates, they are the whole question.
Why this does not break "an arrow sits on its own terrain": a nudged marker
is not claiming to be where it is drawn. The fade says it has been moved and
the leader line says where from, ending in a dot on the true point. A
silently displaced marker would be a lie; an annotated one is a fact plus a
label.
Nudged markers ALWAYS print their altitude, whatever `alt` says. Two markers
side by side are the one case where the pilot cannot tell which reading is
which, and altitude is what separates them. It is the difference between a
valley floor and a ridge 950 m up. `textH` therefore includes the altitude
line whenever nudging is on, so the layout reserves room for a line it knows
will be drawn.
TWO THINGS THE FIRST VERSION GOT WRONG, both caught by screenshotting it:
* Drawing each marker as it was placed put the next leader line straight
THROUGH the previous marker's speed number. So layout and painting are
now separate passes, and every leader line goes down before any marker
goes on top. A line that has to cross a label is hidden behind it
instead of scribbling over it.
* Fading the whole marker to 0.6 faded the NUMBER, which is the fallback
for a colour scale a lot of pilots cannot separate, and it was marginal
against dark forest. Only the arrow fades now; the number and the
altitude stay at full strength. The pale glyph is the signal, and it
costs nothing that has to be read. ─────────────────────────────── */
function draw(fix, ranked){
bctx.clearRect(0, 0, W, H);
hits = [];
var shown = 0;
nNudged = 0; /* or a no-fix tick keeps the last count */
if (fix){
var scale = 0.55 + (C.size / 100) * 0.75; /* 0.55 .. 1.30 */
var fs = Math.round(9 + (C.size / 100) * 7); /* 9 .. 16 px */
var P = WG.projector(fix, WG.zoomForStep(curStep), W, H, WG.getCal());
var box = WG.REACH * scale, i, st, x, y;
/* The altitude line grows each marker downwards, so the vertical spacing
has to grow with it. By exactly the line's height, taken from the
renderer rather than guessed, or one marker's altitude lands on the
arrow of the station below it. Nudged markers print it unconditionally,
so nudging reserves the room even when `alt` is off. */
var altH = WG.marker.altSize(fs) + 2;
/* Rectangles a marker may not cover. The scale bar is the check that the
pairing is right, and the badge is how a mis-pairing announces itself;
a marker drawn over either removes the thing it was there to reveal. */
var keepOut = [];
if (C.bar){
var barPx = WG.scaleMetres(curStep, fix.lat) / P.res;
keepOut.push([0, H - C.barY - 26, 25 + (barPx > 0 ? barPx : 0), H]);
}
/* Measured, not assumed: the status line is much shorter than the 190 px
constant that was here, and it appears even with badge=0 when something
is wrong, so the rectangle has to follow the element, not the setting. */
var bEl = document.getElementById("badge");
if (!bEl.hidden) keepOut.push([0, 0, bEl.offsetWidth || 190,
bEl.offsetHeight || 26]);
function blocked(px, py){
for (var q = 0; q < keepOut.length; q++){
var r = keepOut[q];
if (px > r[0] - box * 0.7 && px < r[2] + box * 0.7 &&
py > r[1] - box * 0.7 && py < r[3] + box * 0.7) return true;
}
return false;
}
/* Everything on screen and not sitting on a keep-out rectangle, in
nearest-first order, the layout takes it from there. */
var cand = [];
for (i = 0; i < ranked.length; i++){
st = ranked[i];
x = P.x(st.lon); y = P.y(st.lat);
if (x < -box || x > W + box || y < -box || y > H + box) continue;
if (blocked(x, y)) continue;
cand.push({ x: x, y: y, alt: st.alt, stale: st.stale.cls, st: st });
}
/* Clustering, priority and placement all live in wg/marker.js: it is pure
arithmetic, so tools/test-core.js exercises the rules directly and
tools/nudge.html draws exactly what this draws. */
/* Text width MEASURED PER MARKER. WG.marker.layout treats the text as its
own rectangle and never lets one be covered, so it has to know how wide
each really is, and "3/7" is barely half of "14/22". Using the widest
possible label for everyone cost about 10 px of needless displacement on
every short one. The altitude line is measured too, because it is drawn
in a smaller font but can still be the wider of the pair ("1648m"). */
bctx.font = "700 " + fs + "px ui-monospace, 'Roboto Mono', monospace";
var labelW = bctx.measureText("00/00").width + 5;
var afs = WG.marker.altSize(fs);
for (i = 0; i < cand.length; i++){
st = cand[i].st;
bctx.font = "700 " + fs + "px ui-monospace, 'Roboto Mono', monospace";
var wLab = bctx.measureText(WG.marker.labelText(st)).width;
var wAlt = 0;
if (C.alt || C.nudge){
bctx.font = "600 " + afs + "px ui-monospace, 'Roboto Mono', monospace";
wAlt = bctx.measureText(WG.marker.altText(st)).width;
}
cand[i].tw = (Math.max(wLab, wAlt) + 5) / 2;
}
var lay = WG.marker.layout(cand, {
box: box, labelW: labelW,
textH: fs + ((C.alt || C.nudge) ? altH : 0),
nudge: !!C.nudge, maxPerCluster: 3,
blocked: blocked,
inBounds: function (px, py){
return px > box * 0.5 && px < W - box * 0.5 &&
py > box * 0.5 && py < H - box * 0.6;
}
});
var ly, nMoved = 0, L;
/* Every leader line first, under every marker. Painting each marker as it
was placed put the next line straight through the previous marker's
speed number. Caught by screenshotting it, not by reading it. The
selection ring goes in the same pass, for the same reason. */
for (i = 0; i < lay.length; i++){
L = lay[i];
if (L.moved){
nMoved++;
WG.marker.leader(bctx, L.tx, L.ty, L.x, L.y, box);
}
/* Which arrow the open popup is describing. Compared by id, not by
object: prepare() hands back the same station objects, but a refetch
replaces them wholesale. */
if (popStation && cand[L.i].st.id === popStation.id)
WG.marker.ring(bctx, L.x, L.y, box);
}
/* REVERSE order, so the highest-priority member of a cluster paints LAST
and therefore on top. The exclusion test keeps the arrows apart, but a
label and an altitude line hang below the box and can still touch. And
when they do, the anchor is the one that should win. */
for (i = lay.length - 1; i >= 0; i--){
L = lay[i];
st = cand[L.i].st;
/* The tap target follows the DRAWN marker, not the true position, the
pilot taps what they can see. */
hits.push({ st: st, x: L.x, y: L.y, r: box });
/* Only the ARROW fades. The number is the fallback for a colour scale a
lot of pilots cannot separate, so it never pays for the annotation. */
/* No fade. The leader line is the annotation and it is unambiguous;
opacity tied first to displacement and then to real overlap both made
markers paler for reasons a pilot could not read off the screen. */
WG.marker.canvas(bctx, st, L.x, L.y, scale);
ly = WG.marker.label(bctx, st, L.x, L.y, scale, fs);
if (C.alt || L.moved) WG.marker.alt(bctx, st, L.x, ly, fs);
}
nNudged = nMoved;
shown = lay.length;
if (C.bar) { barLat = fix.lat; drawScaleBar(bctx, P.res); }
}
/* one atomic replace of the visible canvas */
ctx.save();
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.globalCompositeOperation = "copy";
ctx.drawImage(buf, 0, 0);
ctx.restore();
return shown;
}
/* ── scale bar ────────────────────────────────────────────────────────
Drawn at the SAME ground distance XCTrack's own bar is labelled with, and
placed just above it, so the two can be compared by length instead of by
text. That is the check that matters: equal labels with unequal lengths
means the calibration is wrong for this device or latitude, and no amount
of reading the settings would reveal it.
Bottom-left, mirroring XCTrack's placement. Ours sits higher so both stay
visible, the body is transparent, so theirs shows through. ───────── */
function drawScaleBar(g, res){
/* The bar names what the pilot's own XCTrack names, computed from THIS
screen's density and latitude, not from a table copied off one device. */
var m = WG.scaleMetres(curStep, barLat);
if (!m || !res) return;
var px = m / res;
if (px < 24 || px > W - 20) return; /* would not be comparable */
/* barY tunes the vertical gap: the bars are compared by eye, so the closer
together the better, but XCTrack's own label sits above its bar and the
exact height is device-specific. Hence a parameter rather than a guess
baked in. */
var x = BAR_X, y = H - C.barY, t = WG.fmtScale(m);
g.lineCap = "butt"; g.lineJoin = "miter";
/* TICKS POINT DOWN. XCTrack's point up, so the two bars close on each other
like facing brackets and their ends line up in a single vertical glance,
which is the whole comparison, done without reading either number. */
function trace(){
g.beginPath();
g.moveTo(x, y + 7); g.lineTo(x, y); g.lineTo(x + px, y); g.lineTo(x + px, y + 7);
}
g.strokeStyle = "#FFFFFF"; g.lineWidth = 5; trace(); g.stroke();
g.strokeStyle = "#0A1116"; g.lineWidth = 2; trace(); g.stroke();
/* Number above the line: below it would land in the gap that has to stay
clear for the comparison. Centred and at XCTrack's size, so the pair reads
as one control rather than two things that happen to be near each other. */
g.font = "700 17px ui-monospace, 'Roboto Mono', monospace";
g.textAlign = "center"; g.textBaseline = "bottom";
g.lineWidth = 5; g.strokeStyle = "#FFFFFF"; g.lineJoin = "round";
g.strokeText(t, x + px / 2, y - 4);
/* Amber while the scale is offset: a permanent, quiet marker that outlives
the prompt, sitting on the very number that needs comparing. */
g.fillStyle = (curStep !== homeStep) ? "#8A5A00" : "#0A1116";
g.fillText(t, x + px / 2, y - 4);
}
/* ── fetch ────────────────────────────────────────────────────────── */
function refresh(fix){
if (fetching || !fix) return;
fetching = true;
var bbox = WG.bboxAround(fix, WG.zoomForStep(curStep), W || 448, H || 978, C.pad);
PROV.fetchBBox(bbox, { maxAgeSec: C.stale * 60 * 4, limit: 500 }, function (err, list, meta){
fetching = false;
if (err) fetchErr = err; /* keep the last good data on screen */
else {
fetchErr = ""; stations = list; lastFetch = Date.now();
fetchCapped = !!(meta && meta.capped);
}
drawKey = ""; tick();
});
}
/* ── status line ───────────────────────────────────────────────────────
What is on screen, and whether it can be trusted. It used to lead with the
assumed scale and "N↑" permanently; both are redundant now. The scale bar
shows the scale against XCTrack's OWN, which is the comparison that actually
catches a mis-pairing, and north-up is required setup rather than news. They
moved behind ?debug=1.
Goes red on the two things that make the display untrustworthy: the last
fetch failed, or every visible reading is stale. Deliberately NOT driven
by the single oldest station in the box. One distant instrument that
stopped reporting is not a reason to alarm the whole widget, and
per-marker staleness already shows in red numbers. ──────────────── */
function paintBadge(fix, shown, nStale){
var b = document.getElementById("badge"), s, bad = false, fetchAge;
if (!fix){
s = "no position"; bad = true;
} else if (!lastFetch){
s = fetchErr ? ("no data: " + fetchErr) : "loading…";
bad = !!fetchErr;
} else {
fetchAge = Math.round((Date.now() - lastFetch) / 60000);
/* "9 of 12" whenever decluttering dropped something, never a bare "9".
A bare count implies the map is showing everything it has, and it is
not: markers that would overlap are evicted whole, nearest kept. In
Zermatt at scale 3000 that silently removed Zermatt, Gornergrat and
Schwarzsee: pairs 390 m to 1050 m apart, which is 16 to 42 px at that
resolution against a marker needing 29 x 49 px of clearance. The owner
reasonably read the absence as a fetch bug and went looking for one.
The number now says there is more, so the answer is "zoom in", not
"the data is broken". */
s = shown + (nRanked > shown ? " of " + nRanked : "") +
(nRanked === 1 ? " station" : " stations");
/* A moved marker is a fact with an asterisk, so the count says how many
carry one. The arrow points at the leader lines. */
if (nNudged) s += " ↓" + nNudged;
/* All-stale replaces the count of stale ones rather than adding to it:
"12 stations (12 stale)" states the same fact twice and buries it. */
if (shown && nStale === shown){ s += " ALL STALE"; bad = true; }
else if (nStale) s += " (" + nStale + " stale)";
if (fetchErr){ s += " OFFLINE " + fetchAge + "m"; bad = true; }
else if (fetchAge >= Math.ceil(C.poll / 60) * 2){ s += " " + fetchAge + "m"; bad = true; }
/* Truncation is not a debug detail: the box held more than the API will
return, so the map is missing stations it never heard about. Say it
whatever the badge setting, like the other admissions of doubt. */
if (fetchCapped){ s += " BOX FULL"; bad = true; }
if (C.debug) s = WG.scaleLabel(curStep, fix.lat) + " N↑ " + s +
" [" + nRanked + " in view / " + stations.length + " got]";
}
/* Off by default: the count is a nicety and the map wants the pixels. Three
ways it appears anyway, asked for, debugging, or something is wrong. That
last one is not negotiable: no position, OFFLINE and ALL STALE are the
display admitting it cannot be trusted, and no display setting may switch
that off. (Before this, badge=0 hid nothing at all, it was inert.) */
var want = (C.badge || C.debug) ? true : bad;
if (b.hidden === want) b.hidden = !want;
if (!want) return;
if (b.firstChild){ if (b.firstChild.nodeValue !== s) b.firstChild.nodeValue = s; }
else b.appendChild(document.createTextNode(s));
var cls = bad ? "bad" : "";
if (b.className !== cls) b.className = cls;
/* Separate element, separate concern: the badge reports the data, this
reports that the pilot still has to act. Amber, not red. Red means the
readings are stale, and conflating the two would blunt both. */
if (curStep === homeStep) hideWarn();
}
/* ── tick ──────────────────────────────────────────────────────────────
THE KEY IS COMPUTED BEFORE ANY WORK, and everything expensive sits behind
it. That ordering is the whole optimisation, and it used to be the other way
round: the old loop ran WG.prepare(). Distance, bearing, two rating lookups
and a staleness test per station, then a sort. Plus a localStorage write,
TWICE A SECOND, and only then asked whether anything had moved a pixel. The
comment claiming it was "nearly free" was true of the canvas repaint and of
nothing else.
What the key covers, and therefore what can change the display:
position, quantised to a whole DEVICE PIXEL, a glider at 40 km/h moves
0.21 px/s at z11, so this fires about every 5 s of flight
the widget's size, the ladder step, and the last fetch
the MINUTE, which is the granularity at which staleness can change
Nothing else can alter a marker, so nothing else needs recomputing. At 1 Hz
the common tick is now one bridge read and a string compare.
1 Hz, not 2: getLocation() is a call across the JS bridge into native and
XCTrack's own GPS is 1 Hz, so the second poll each second could only ever
return the same answer. Interaction does not wait for the tick, the zoom
handlers call it directly. ─────────────────────────────────────────── */
var nStaleShown = 0;
function tick(){
var p = WG.position(C), fix = p.fix;
/* The pilot asked for a ground scale; which ladder step that is depends on
this screen's density and on where they are, so it can only be answered
here. Before the first fix there is nothing to draw anyway. */
if (fix) setHome(fix.lat);
if (!fix){
/* Clearing an already-clear canvas is still a full-surface copy. */
if (drawKey !== "nofix"){ drawKey = "nofix"; draw(null, []); lastShown = 0; }
nStaleShown = 0;
paintBadge(null, 0, 0);
return;
}
if (!lastFetch || Date.now() - lastFetch > C.poll * 1000) refresh(fix);
var now = Date.now();
var z = WG.zoomForStep(curStep);
var key = Math.round(WG.lonToPx(fix.lon, z) * WG.getCal()) + "/" +
Math.round(WG.latToPx(fix.lat, z) * WG.getCal()) + "/" +
W + "x" + H + "/" + curStep + "/" + lastFetch + "/" + Math.round(now / 60000) +
"/" + (popStation ? popStation.id : "");
if (key !== drawKey){
drawKey = key;
/* Cull to the view BEFORE C.max, or the cap is spent on stations that are
off the sides of a tall screen. A marker's width of slack so one at the
very edge is still a candidate, draw() does the exact clipping. */
var view = WG.bboxAround(fix, z, (W || 448) + 60, (H || 978) + 60, 0);
var ranked = WG.prepare(stations, fix, C, now, view), i;
nRanked = ranked.length;
lastShown = draw(fix, ranked);
/* Counted over the markers ACTUALLY DRAWN, not over everything ranked.
Comparing a count from one population against a total from another made
the badge claim ALL STALE whenever the stale count in the ranked list
happened to equal the number drawn. Which trains a pilot to ignore the
warning, the worst possible outcome for it. Cached alongside the draw,
because hits[] and their staleness only change when prepare() runs, and
the minute in the key is what guarantees prepare() runs when it can. */
nStaleShown = 0;
for (i = 0; i < hits.length; i++) if (hits[i].st.stale.cls === "stale") nStaleShown++;
}
paintBadge(fix, lastShown, nStaleShown);
}
/* ── detail popup ─────────────────────────────────────────────────────
Tap a marker for its recent trend. Purely descriptive. The same measured
values, over time. So it breaks no rule, and it answers the question a
single reading cannot: is this building or easing?
History is fetched ON DEMAND, one small call (~0.5-1 KB for a few hours).
Prefetching it for every marker would be one call per station for data
nobody asked to see, which is what winds.mobi's "do not overload" forbids.
─────────────────────────────────────────────────────────────────── */
function closePop(){
if (popTimer){ clearTimeout(popTimer); popTimer = null; }
popStation = null; popHist = null; popErr = "";
var p = document.getElementById("pop");
p.hidden = true;
p.innerHTML = "";
/* `data-for` says WHICH STATION THE SHELL IN THE DOM BELONGS TO, so it has to
die with the shell. Leaving it set meant reopening the SAME station hit
paintPop's "already built, only repaint the trend" path, and paintTrend
then found no `.trend` to write into because innerHTML had just been
emptied: an empty panel, open, with nothing in it. Only for the same
station, because any other id failed the check and rebuilt properly. */
p.removeAttribute("data-for");
tick(); /* and it has to come off again */
}
function openPop(st){
var c = histCache[st.id];
popStation = st; popErr = "";
/* Reuse anything fetched within one data cycle: the upstream only updates on
that cadence, so a fresher call would return the same samples. */
popHist = (c && Date.now() - c.at < C.poll * 1000) ? c.list : null;
paintPop();
/* The selection ring lives on the canvas, and the draw key carries the open
station's id, so this is what puts the ring on. After paintPop, so the
panel and the ring appear in the same frame. */
tick();
if (popTimer) clearTimeout(popTimer);
/* 0 means keep it until dismissed. Otherwise it closes itself, because a
popup left open over a moving map hides the thing it sits on. */
if (C.popup > 0) popTimer = setTimeout(closePop, C.popup * 1000);
if (popHist) return; /* served from cache; nothing to fetch */
PROV.fetchHistoric(st.id, { duration: C.hours * 3600 }, function (err, list){
if (!popStation || popStation.id !== st.id) return; /* dismissed meanwhile */
if (err) popErr = err;
else { popHist = list; histCache[st.id] = { at: Date.now(), list: list }; }
paintTrend(); /* only the strip changed */
});
}
function esc(s){
return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
}
/* Two paints, deliberately separate. The shell is written once when a marker is
tapped; the trend replaces only its own container when the history lands.
Rewriting the whole panel twice in quick succession is what made tapping a
second marker flash. */
function paintPop(){
var p = document.getElementById("pop"), st = popStation, i, s, h = "";
if (!st){ p.hidden = true; return; }
p.hidden = false;
if (p.getAttribute("data-for") === st.id){ paintTrend(); return; }
p.setAttribute("data-for", st.id);
h += '<h3>' + esc(st.name) + (st.peak ? ' <span style="color:var(--chart)">▲</span>' : '') + '</h3>';
/* The geocoded municipality, when it is not just the name again. It is the
word a pilot who does not know the site has a chance of recognising, and
the reason a station can look absent when it is right there, see the
name/short swap in windsmobi.js. */
h += '<div class="sub">' +
(st.place ? esc(st.place) + ' · ' : '') +
(isFinite(st.alt) ? Math.round(st.alt) + ' m · ' : '') +
(Math.round(st.dist / 100) / 10) + ' km · ' + esc(st.provider) + '</div>';
h += '<div class="now">';
h += '<b>' + WG.marker.labelText(st) + '</b><i>km/h avg/gust</i>';
if (st.dir === st.dir) h += '<b style="font-size:19px">' + Math.round(st.dir) + '°</b>';
h += '<i class="' + (st.stale.cls === "fresh" ? "" : st.stale.cls) + '">' +
(st.stale.age === st.stale.age ? Math.round(st.stale.age) + ' min old' : 'no timestamp') +
'</i></div>';
h += '<div class="trend"></div>';
h += '<div class="src">Measured values from ' + esc(st.provider) +
' via winds.mobi. Not a forecast and not a safety verdict. ' +
'Tap anywhere to close' + (C.popup > 0 ? ', or wait ' + C.popup + ' s.' : '.') +
'</div>';
p.innerHTML = h;
paintTrend();
}
/* Oldest left, newest right, same marker geometry at small size so a change of
colour reads the way it does on the map. The strip itself is built in
wg/marker.js, because app.html draws the same thing and two copies of it
would be two chances to disagree about a colour. */
function paintTrend(){
var tr = document.querySelector("#pop .trend");
if (!tr) return;
tr.innerHTML = WG.marker.trendHtml(popHist, { size:34, err:popErr });
WG.marker.trendScroll(tr);
}
/* Taps inside the popup must not fall through and re-close it or zoom. */
document.getElementById("pop").addEventListener("click", function(ev){
ev.stopPropagation();
});
function hitTest(x, y){
/* Nearest marker first, so overlapping ones resolve predictably. */
var best = null, bd = 1e9, i, d;
for (i = 0; i < hits.length; i++){
d = Math.abs(x - hits[i].x) + Math.abs(y - hits[i].y);
if (d < hits[i].r * 1.6 && d < bd){ bd = d; best = hits[i]; }
}
return best;
}
/* ── optional tap zones ───────────────────────────────────────────────
Off by default, and that default matters: with tapping enabled the WebView
swallows every touch over its area, so any zone here takes one away from
XCTrack. Measured 2026-08-11: a tap never reaches both. See findings.md.
For this to be useful the pilot must move XCTrack's own zoom buttons
OUTSIDE the widget rectangle, then re-zoom the map by hand to match. The
widget cannot verify that happened, so it says SET MAP until reset and the
scale bar is there to be compared against XCTrack's. ─────────────── */
/* ── the off-scale prompt ─────────────────────────────────────────────
Shown when the pilot changes our scale, because the map was not told and
cannot be. It is a PROMPT and it fades: having been told once is enough, and
a permanent banner down here would sit on XCTrack's own scale bar, which is
the thing it is asking to be compared against.
The lasting signal is elsewhere and quieter. The bar's number goes amber
while offset, right on the value that needs checking. Reset stays on the
badge, which never fades. ───────────────────────────────────────────── */
var WARN_MS = 15000;
var warnTimer = null;
function showWarn(){
var wn = document.getElementById("warn");
wn.hidden = false;
wn.className = "";
/* FIXED horizontally. It used to start at the bar's right edge, which moves
with the scale. So the prompt slid left and right every time the pilot
zoomed, movement that reads as a fault rather than as a pointer. BAR_MAX_DP
is the bar's ceiling by construction, so clearing it clears the bar at
EVERY scale, ours and XCTrack's alike, without ever covering either. */
var wx = BAR_X + WG.BAR_MAX_DP + 8;
/* On a widget too narrow to hold both, being on screen beats being clear of
the bar, a prompt hanging off the edge says nothing at all. */
if (wx + wn.offsetWidth > W) wx = Math.max(0, W - wn.offsetWidth);
wn.style.left = Math.round(wx) + "px";
/* Centred on the GAP between the two bars, so the arrow points at the pair
rather than at one of them, the instruction is to compare, not to look at
ours. Measured from the element so it stays centred if the text changes. */
wn.style.bottom = Math.max(0, Math.round(
(C.barY + XCT_BAR_Y) / 2 - wn.offsetHeight / 2)) + "px";
if (warnTimer) clearTimeout(warnTimer);
warnTimer = setTimeout(function(){ wn.className = "fade"; }, WARN_MS);
}
function hideWarn(){
if (warnTimer){ clearTimeout(warnTimer); warnTimer = null; }
var wn = document.getElementById("warn");
wn.hidden = true; wn.className = "";
}
function stepBy(d){
var n = curStep + d;
if (n < WG.XCT_STEP_MIN || n > WG.XCT_STEP_MAX) return;
curStep = n;
drawKey = ""; lastFetch = 0; /* the box moved with the zoom */
showWarn();
tick();
}
/* Precedence, and it matters: a marker tap must never be eaten by a zoom zone
that happens to cover the same pixels. Marker, then dismiss, then zoom. */
function zoneTap(ev, d){
var r = cv.getBoundingClientRect();
var x = ev.clientX - r.left, y = ev.clientY - r.top;
var hit = C.popup !== null && hitTest(x, y);
if (hit){ openPop(hit.st); return; }
if (popStation){ closePop(); return; } /* tap outside dismisses */
if (d) stepBy(d);
}
if (C.ztap){
var zi = document.getElementById("zin"), zo = document.getElementById("zout");
zi.hidden = zo.hidden = false;
zi.addEventListener("click", function(ev){ zoneTap(ev, +1); }); /* finer */
zo.addEventListener("click", function(ev){ zoneTap(ev, -1); }); /* coarser */
} else {
/* No zones, but marker taps still need somewhere to land. The canvas itself
is only a hit target when there is something to tap. */
cv.addEventListener("click", function(ev){ zoneTap(ev, 0); });
}
if (C.zbtn){
var zb = document.getElementById("zbtns");
zb.className = "p-" + C.zpos + (C.zrow ? " row" : "");
zb.hidden = false;
document.getElementById("bIn").addEventListener("click", function(ev){
ev.stopPropagation(); stepBy(+1);
});
document.getElementById("bOut").addEventListener("click", function(ev){
ev.stopPropagation(); stepBy(-1);
});
}
function resetScale(ev){
if (ev) ev.stopPropagation();
if (curStep === homeStep) return;
curStep = homeStep; drawKey = ""; lastFetch = 0; hideWarn(); tick();
}
/* Two ways back, because the prompt fades but the need to reset does not. */
document.getElementById("warn").addEventListener("click", resetScale);
document.getElementById("badge").addEventListener("click", resetScale);
resize();
tick();
/* 1 Hz. See tick(): the expensive work is behind the key, so most wakeups are a
bridge read and a string compare. Paused while the page is hidden. Android
may or may not throttle a background WebView, and this tool has to leave the
battery something for the rest of the flight. */
var timer = setInterval(tick, 1000);
document.addEventListener("visibilitychange", function(){
if (document.hidden){ if (timer){ clearInterval(timer); timer = 0; } }
else if (!timer){ timer = setInterval(tick, 1000); tick(); }
});
</script>
</body>
</html>