microsegments 0.1.0__py3-none-any.whl
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.
- microsegments/__init__.py +42 -0
- microsegments/_version.py +24 -0
- microsegments/aggregate.py +101 -0
- microsegments/cli.py +201 -0
- microsegments/config.py +142 -0
- microsegments/contract.py +42 -0
- microsegments/hotspots.py +337 -0
- microsegments/html/__init__.py +6 -0
- microsegments/html/export.py +96 -0
- microsegments/html/leaflet.min.css +2 -0
- microsegments/html/template.html +863 -0
- microsegments/io/__init__.py +9 -0
- microsegments/io/coverage.py +114 -0
- microsegments/io/events.py +98 -0
- microsegments/io/gtfsrt.py +168 -0
- microsegments/io/ids.py +34 -0
- microsegments/io/stib.py +217 -0
- microsegments/io/tabular.py +188 -0
- microsegments/io/timeutil.py +84 -0
- microsegments/locate/__init__.py +47 -0
- microsegments/locate/common.py +149 -0
- microsegments/locate/linear.py +317 -0
- microsegments/locate/mapmatch.py +390 -0
- microsegments/locate/resample.py +71 -0
- microsegments/metrics.py +520 -0
- microsegments/network/__init__.py +10 -0
- microsegments/network/calendar.py +218 -0
- microsegments/network/geometry.py +224 -0
- microsegments/network/keys.py +97 -0
- microsegments/network/patterns.py +328 -0
- microsegments/pipeline.py +308 -0
- microsegments/plot.py +476 -0
- microsegments/py.typed +0 -0
- microsegments/report.py +258 -0
- microsegments/schema.py +257 -0
- microsegments/segments.py +253 -0
- microsegments/simulate.py +612 -0
- microsegments/tracks.py +391 -0
- microsegments/tune.py +518 -0
- microsegments-0.1.0.dist-info/METADATA +184 -0
- microsegments-0.1.0.dist-info/RECORD +44 -0
- microsegments-0.1.0.dist-info/WHEEL +4 -0
- microsegments-0.1.0.dist-info/entry_points.txt +2 -0
- microsegments-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,863 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="__LANG__">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>__TITLE__</title>
|
|
7
|
+
<meta name="description" content="__DESC__">
|
|
8
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
9
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
10
|
+
<link href="https://fonts.googleapis.com/css2?family=Archivo:wght@400;500;600;700&family=Fraunces:opsz,wght@9..144,400;9..144,600;9..144,800&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
11
|
+
<style>__LEAFLETCSS__</style>
|
|
12
|
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"></script>
|
|
13
|
+
<style>
|
|
14
|
+
/* microsegments report: one line, map + hotspots + hour x microsegment matrix share one state
|
|
15
|
+
(direction, version, weekdays, hour or band, metric, stop zones). */
|
|
16
|
+
:root{
|
|
17
|
+
color-scheme: dark;
|
|
18
|
+
--paper:#091124; --surface:#101a30; --surface-2:#16223d; --line:#24324f;
|
|
19
|
+
--ink:#eef1f6; --ink-2:#b4bdd0; --ink-3:#7f8aa3; --acc:#F3C300; --hot:#ffe600;
|
|
20
|
+
--null:#3a4560; --masked:#26324d; --deep:#0b1224;
|
|
21
|
+
--serif:"Fraunces", Georgia, serif; --sans:"Archivo", system-ui, sans-serif; --mono:"IBM Plex Mono", ui-monospace, monospace;
|
|
22
|
+
}
|
|
23
|
+
*{box-sizing:border-box}
|
|
24
|
+
html,body{margin:0;background:var(--paper);color:var(--ink);font:16px/1.6 var(--sans)}
|
|
25
|
+
a{color:var(--acc)}
|
|
26
|
+
.wrap{max-width:1180px;margin:0 auto;padding-left:24px;padding-right:24px}
|
|
27
|
+
header{padding-top:40px;padding-bottom:8px}
|
|
28
|
+
.top{display:flex;justify-content:space-between;align-items:center;gap:12px}
|
|
29
|
+
.kicker{font:500 12px/1.4 var(--mono);letter-spacing:.14em;text-transform:uppercase;color:var(--ink-3)}
|
|
30
|
+
h1{font:800 clamp(32px,5vw,56px)/1.04 var(--serif);letter-spacing:-.02em;margin:14px 0 16px;max-width:24ch;hyphens:manual}
|
|
31
|
+
h1 .badge{display:inline-block;background:var(--acc);color:#111;font:700 .62em/1 var(--sans);padding:.14em .32em;border-radius:.18em;vertical-align:.12em;margin-right:.22em}
|
|
32
|
+
h2{font:700 24px/1.2 var(--serif);margin:0 0 6px}
|
|
33
|
+
p.lede{font-size:clamp(17px,1.6vw,19px);color:var(--ink-2);max-width:68ch;margin:0}
|
|
34
|
+
.tiles{display:grid;grid-template-columns:repeat(4,1fr);gap:1px;background:var(--line);border:1px solid var(--line);border-radius:14px;overflow:hidden;margin-top:28px}
|
|
35
|
+
.tile{background:var(--surface);padding:18px 18px 16px;min-width:0}
|
|
36
|
+
.tile b{display:block;font:700 clamp(24px,3vw,32px)/1 var(--serif);font-variant-numeric:tabular-nums}
|
|
37
|
+
.tile span{display:block;margin-top:8px;color:var(--ink-2);font-size:14px;line-height:1.4}
|
|
38
|
+
.controls{display:flex;align-items:center;gap:12px 18px;flex-wrap:wrap;margin-top:24px;padding:12px 14px;background:var(--surface);border:1px solid var(--line);border-radius:14px}
|
|
39
|
+
.clock{font:600 30px/1 var(--mono);min-width:110px;font-variant-numeric:tabular-nums}
|
|
40
|
+
.clock small{display:block;font:500 11px var(--mono);letter-spacing:.1em;color:var(--ink-3);text-transform:uppercase;margin-top:4px}
|
|
41
|
+
#play{font:inherit;min-width:120px;background:var(--acc);border:0;border-radius:10px;color:#111;font-weight:700;padding:9px 14px;cursor:pointer}
|
|
42
|
+
#play:focus-visible{outline:2px solid #fff;outline-offset:2px}
|
|
43
|
+
.slider{flex:1 1 240px;display:flex;flex-direction:column;gap:2px;min-width:0}
|
|
44
|
+
input[type=range]{width:100%;accent-color:var(--acc)}
|
|
45
|
+
.ticks{display:flex;justify-content:space-between;font:11px var(--mono);color:var(--ink-3)}
|
|
46
|
+
.grp{display:inline-flex;align-items:center;gap:10px;flex-wrap:nowrap}
|
|
47
|
+
.grp[hidden]{display:none}
|
|
48
|
+
@media (max-width:600px){.grp{flex-wrap:wrap;gap:6px}.grp>label{flex-basis:100%}}
|
|
49
|
+
.row2{flex-basis:100%;display:flex;align-items:center;gap:12px 18px;flex-wrap:wrap;border-top:1px solid var(--line);padding-top:12px}
|
|
50
|
+
.controls label,.lbl{font:500 11px var(--mono);letter-spacing:.1em;text-transform:uppercase;color:var(--ink-3)}
|
|
51
|
+
.seg{display:flex;border:1px solid var(--line);border-radius:10px;overflow:hidden;flex-wrap:wrap}
|
|
52
|
+
.seg button{font:inherit;font-size:14px;border:0;background:transparent;color:var(--ink-2);padding:7px 11px;cursor:pointer}
|
|
53
|
+
.seg button[aria-pressed=true]{background:var(--surface-2);color:var(--ink);box-shadow:inset 0 -2px 0 var(--acc)}
|
|
54
|
+
.seg button:focus-visible{outline:2px solid var(--acc);outline-offset:-2px}
|
|
55
|
+
.seg button:disabled{opacity:.35;cursor:default}
|
|
56
|
+
.grid{display:grid;grid-template-columns:minmax(0,1.45fr) minmax(0,1fr);gap:20px;margin-top:20px;align-items:start}
|
|
57
|
+
.grid>*{min-width:0}
|
|
58
|
+
.panel{background:var(--surface);border:1px solid var(--line);border-radius:14px;overflow:hidden}
|
|
59
|
+
#map{height:600px;background:var(--deep)}
|
|
60
|
+
#map .nomap{padding:24px;color:var(--ink-3)}
|
|
61
|
+
@media (min-width:900px){.mapcol{position:sticky;top:16px}}
|
|
62
|
+
.leaflet-container{background:var(--deep);font-family:var(--sans)}
|
|
63
|
+
.leaflet-control-attribution{background:rgba(9,17,36,.75)!important;color:var(--ink-3)!important;font-size:10px}
|
|
64
|
+
.leaflet-control-attribution a{color:var(--ink-2)!important}
|
|
65
|
+
.leaflet-tooltip{background:#0f1830;color:var(--ink);border:1px solid var(--line);box-shadow:0 4px 16px rgba(0,0,0,.4);font:13px/1.45 var(--sans)}
|
|
66
|
+
.leaflet-tooltip-top:before,.leaflet-tooltip-bottom:before,.leaflet-tooltip-left:before,.leaflet-tooltip-right:before{display:none}
|
|
67
|
+
.lab{font:600 11px/1 var(--sans);color:var(--ink-2);white-space:nowrap;text-shadow:0 0 4px var(--deep),0 0 8px var(--deep);pointer-events:none}
|
|
68
|
+
.lab.u{transform:translate(-50%,-100%) translateY(-8px)}
|
|
69
|
+
.lab.r{transform:translate(9px,-50%)}
|
|
70
|
+
.lab.hide{display:none}
|
|
71
|
+
.hsm{width:22px;height:22px;border-radius:50%;background:var(--hot);color:#111;font:700 12px/22px var(--sans);text-align:center;box-shadow:0 0 0 2px var(--deep),0 1px 4px rgba(0,0,0,.6)}
|
|
72
|
+
.legend{display:flex;gap:6px 14px;flex-wrap:wrap;align-items:center;font-size:12px;color:var(--ink-2);padding:10px 14px;border-top:1px solid var(--line)}
|
|
73
|
+
.legend .ramp{flex:1 1 260px;min-width:200px;max-width:420px}
|
|
74
|
+
.legend .ramp .bar{height:8px;border-radius:4px}
|
|
75
|
+
.legend .ramp .tk{position:relative;height:14px;font:10px var(--mono);color:var(--ink-3)}
|
|
76
|
+
.legend .ramp .tk span{position:absolute;transform:translateX(-50%);top:2px;white-space:nowrap}
|
|
77
|
+
.legend .ramp .tk span:last-child{transform:translateX(-100%)}
|
|
78
|
+
.legend .txt{flex:1 1 260px}
|
|
79
|
+
.sw{display:inline-block;width:14px;height:8px;border-radius:2px;vertical-align:middle;margin-right:5px}
|
|
80
|
+
.legend .hk{display:inline-block;width:16px;height:8px;border:2px solid var(--hot);border-radius:2px;vertical-align:middle;margin-right:5px}
|
|
81
|
+
.side{padding:16px 16px 10px}
|
|
82
|
+
.side .note{font-size:13px;color:var(--ink-3);margin:0 0 10px}
|
|
83
|
+
.hs{display:grid;grid-template-columns:28px minmax(0,1fr) auto;gap:10px;align-items:start;padding:9px 8px;border-radius:8px;cursor:pointer;border:0;background:transparent;color:inherit;font:inherit;text-align:left;width:100%}
|
|
84
|
+
.hs:hover,.hs.on{background:var(--surface-2)}
|
|
85
|
+
.hs:focus-visible{outline:2px solid var(--acc);outline-offset:-2px}
|
|
86
|
+
.hs .nm{font-weight:600;font-size:14px;line-height:1.3}
|
|
87
|
+
.hs .nm .ar{color:var(--ink-3);font-weight:400}
|
|
88
|
+
.hs .meta{font:12px/1.4 var(--mono);color:var(--ink-3);margin-top:3px}
|
|
89
|
+
.hs .v{font:700 19px/1 var(--serif);font-variant-numeric:tabular-nums;text-align:right;white-space:nowrap}
|
|
90
|
+
.hs .v small{display:block;font:500 11px var(--mono);color:var(--ink-3);margin-top:4px}
|
|
91
|
+
.hs .n{display:inline-block;width:22px;height:22px;border-radius:50%;background:var(--hot);color:#111;font:700 12px/22px var(--sans);text-align:center}
|
|
92
|
+
.empty{color:var(--ink-3);font-size:14px;padding:8px}
|
|
93
|
+
section.card{background:var(--surface);border:1px solid var(--line);border-radius:14px;padding:20px;margin-top:20px}
|
|
94
|
+
.sub{color:var(--ink-3);font-size:13px;margin:0 0 12px;max-width:110ch}
|
|
95
|
+
.axis text{fill:var(--ink-3);font:11px var(--mono)}
|
|
96
|
+
.axis line{stroke:var(--line)}
|
|
97
|
+
.method{color:var(--ink-2);font-size:14px}
|
|
98
|
+
.method ul{max-width:84ch;padding-left:20px}
|
|
99
|
+
.method li{margin:6px 0}
|
|
100
|
+
footer{color:var(--ink-3);font-size:12px;padding:28px 0 48px}
|
|
101
|
+
.mxhead{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap}
|
|
102
|
+
.btn{font:inherit;font-size:14px;color:var(--ink);background:var(--surface-2);border:1px solid var(--line);border-radius:10px;padding:7px 12px;cursor:pointer}
|
|
103
|
+
.btn:hover{border-color:var(--ink-3)}
|
|
104
|
+
.mxleg{border-top:0;padding:0 0 10px}
|
|
105
|
+
#mxcard.full{position:fixed;inset:0;z-index:2000;margin:0;border-radius:0;overflow:auto;padding:16px 20px;background:var(--surface)}
|
|
106
|
+
#mxcard.full .sub{margin-bottom:6px}
|
|
107
|
+
body.mxopen{overflow:hidden}
|
|
108
|
+
.svgwrap{overflow:hidden}
|
|
109
|
+
svg{display:block}
|
|
110
|
+
#tip{position:fixed;z-index:3000;pointer-events:none;background:#0f1830;color:var(--ink);border:1px solid var(--line);border-radius:6px;box-shadow:0 4px 16px rgba(0,0,0,.4);font:13px/1.45 var(--sans);padding:6px 9px;max-width:320px;display:none}
|
|
111
|
+
.cal{display:flex;gap:18px;flex-wrap:wrap;align-items:flex-start}
|
|
112
|
+
.cal svg text{fill:var(--ink-3);font:10px var(--mono)}
|
|
113
|
+
.cal .calleg{display:flex;gap:6px 14px;flex-wrap:wrap;font-size:12px;color:var(--ink-2)}
|
|
114
|
+
.calwrap{overflow-x:auto;max-width:100%}
|
|
115
|
+
.vtag{font:500 10px var(--mono);letter-spacing:.06em;text-transform:uppercase;color:#c9b36a;border:1px solid #6b5d2e;border-radius:4px;padding:1px 5px;white-space:nowrap;margin-left:6px}
|
|
116
|
+
@media (max-width:900px){.grid{grid-template-columns:minmax(0,1fr)}#map{height:440px}.tiles{grid-template-columns:repeat(2,1fr)}}
|
|
117
|
+
@media (max-width:600px){.wrap{padding-left:16px;padding-right:16px}header{padding-top:24px}.clock{font-size:24px;min-width:90px}}
|
|
118
|
+
</style>
|
|
119
|
+
</head>
|
|
120
|
+
<body>
|
|
121
|
+
<header class="wrap">
|
|
122
|
+
<div class="top"><div class="kicker" id="kicker"></div><div class="seg" id="langs" role="group" aria-label="Language"></div></div>
|
|
123
|
+
<h1 id="h1"></h1>
|
|
124
|
+
<p class="lede" id="lede"></p>
|
|
125
|
+
<div class="tiles" id="tiles"></div>
|
|
126
|
+
</header>
|
|
127
|
+
<main class="wrap">
|
|
128
|
+
<div class="controls">
|
|
129
|
+
<div class="clock" id="clock"></div>
|
|
130
|
+
<button id="play" type="button" aria-pressed="false"></button>
|
|
131
|
+
<div class="slider">
|
|
132
|
+
<input type="range" id="hour" step="1">
|
|
133
|
+
<div class="ticks" id="hticks"></div>
|
|
134
|
+
</div>
|
|
135
|
+
<div class="row2">
|
|
136
|
+
<span class="grp"><label id="l_days"></label><div class="seg" id="days"></div></span>
|
|
137
|
+
<span class="grp"><label id="l_dir"></label><div class="seg" id="dirs"></div></span>
|
|
138
|
+
<span class="grp" id="g_ver" hidden><label id="l_ver"></label><div class="seg" id="vers"></div></span>
|
|
139
|
+
<span class="grp"><label id="l_bands"></label><div class="seg" id="bands"></div></span>
|
|
140
|
+
<span class="grp"><label id="l_metric"></label><div class="seg" id="metric"></div></span>
|
|
141
|
+
<span class="grp" id="g_unit"><label id="l_unit"></label><div class="seg" id="unit"></div></span>
|
|
142
|
+
<span class="grp"><label id="l_stops"></label><div class="seg" id="stopsT"></div></span>
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
<div class="grid">
|
|
146
|
+
<div class="mapcol"><div class="panel">
|
|
147
|
+
<div id="map" role="img"></div>
|
|
148
|
+
<div class="legend" id="legend"></div>
|
|
149
|
+
</div></div>
|
|
150
|
+
<div class="panel side">
|
|
151
|
+
<h2 id="hstitle"></h2>
|
|
152
|
+
<p class="note" id="hsnote"></p>
|
|
153
|
+
<div id="hslist"></div>
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
<section class="card" id="mxcard">
|
|
157
|
+
<div class="mxhead"><h2 id="mxtitle"></h2><span class="grp"><div class="seg" id="mxdirs"></div><button id="mxfull" class="btn" type="button" aria-pressed="false"></button></span></div>
|
|
158
|
+
<p class="sub" id="mxsub"></p>
|
|
159
|
+
<div class="legend mxleg" id="mxlegend"></div>
|
|
160
|
+
<div class="svgwrap"><svg id="mx" width="100%" role="img"></svg></div>
|
|
161
|
+
</section>
|
|
162
|
+
<section class="card">
|
|
163
|
+
<h2 id="proftitle"></h2>
|
|
164
|
+
<p class="sub" id="profsub"></p>
|
|
165
|
+
<div class="svgwrap"><svg id="prof" width="100%" height="300" role="img"></svg></div>
|
|
166
|
+
</section>
|
|
167
|
+
<section class="card">
|
|
168
|
+
<h2 id="covtitle"></h2>
|
|
169
|
+
<p class="sub" id="covsub"></p>
|
|
170
|
+
<div class="cal"><div class="calwrap"><svg id="cal" role="img"></svg></div><div class="calleg" id="calleg"></div></div>
|
|
171
|
+
</section>
|
|
172
|
+
<section class="card method">
|
|
173
|
+
<h2 id="mtitle"></h2>
|
|
174
|
+
<ul id="method"></ul>
|
|
175
|
+
</section>
|
|
176
|
+
</main>
|
|
177
|
+
<footer class="wrap" id="footer"></footer>
|
|
178
|
+
<div id="tip" role="tooltip"></div>
|
|
179
|
+
<script id="ms-data" type="application/json">__DATA__</script>
|
|
180
|
+
<script>
|
|
181
|
+
"use strict";
|
|
182
|
+
const D = JSON.parse(document.getElementById('ms-data').textContent);
|
|
183
|
+
const P = D.params || {};
|
|
184
|
+
const TICK = P.tick_s || 20;
|
|
185
|
+
const REFH = P.reference_hours || [20, 23];
|
|
186
|
+
const DAYH = D.day_hours || [6, 21];
|
|
187
|
+
const BANDDEF = D.bands || {am: [7, 10], pm: [16, 19], day: [6, 21], evening: [20, 23]};
|
|
188
|
+
const LOC = {fr: 'fr-BE', en: 'en-GB'};
|
|
189
|
+
|
|
190
|
+
// ---------------------------------------------------------------- words (fr default, en)
|
|
191
|
+
const NOUN = {
|
|
192
|
+
fr: {tram: ['tram', 'trams'], bus: ['bus', 'bus'], trolleybus: ['trolleybus', 'trolleybus'], metro: ['métro', 'métros'], rail: ['train', 'trains'], ferry: ['bateau', 'bateaux'], _: ['véhicule', 'véhicules']},
|
|
193
|
+
en: {tram: ['tram', 'trams'], bus: ['bus', 'buses'], trolleybus: ['trolleybus', 'trolleybuses'], metro: ['metro train', 'metro trains'], rail: ['train', 'trains'], ferry: ['boat', 'boats'], _: ['vehicle', 'vehicles']},
|
|
194
|
+
};
|
|
195
|
+
const MODE = {fr: {tram: 'Tram', bus: 'Bus', trolleybus: 'Trolleybus', metro: 'Métro', rail: 'Train', ferry: 'Bateau', _: 'Ligne'},
|
|
196
|
+
en: {tram: 'Tram', bus: 'Bus', trolleybus: 'Trolleybus', metro: 'Metro', rail: 'Train', ferry: 'Ferry', _: 'Line'}};
|
|
197
|
+
const T = {
|
|
198
|
+
fr: {
|
|
199
|
+
title: (m, l) => `Où les ${m} s'attardent-ils\u00a0?`,
|
|
200
|
+
lede: (L, tk, m1, m) => `La ligne est découpée en microsegments d'environ ${L} m. Toutes les ${tk} s environ, on relève la position de chaque ${m1} et on compte une observation dans le microsegment où il se trouve. Plus un microsegment cumule d'observations, plus les ${m} y restent longtemps : arrêts, feux, carrefours, files. Une observation correspond à peu près à ${tk} secondes de présence.`,
|
|
201
|
+
days: 'Jours', dir: 'Sens', ver: 'Version du tracé', bands: 'Moyennes', metric: 'Mesure', unit: 'Par', stops: 'Arrêts',
|
|
202
|
+
dow: ['Lu', 'Ma', 'Me', 'Je', 'Ve', 'Sa', 'Di'], dowl: ['lundis', 'mardis', 'mercredis', 'jeudis', 'vendredis', 'samedis', 'dimanches'],
|
|
203
|
+
dowd: ['lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi', 'dimanche'],
|
|
204
|
+
alldays: 'tous les jours retenus', weekdays: 'jours ouvrables',
|
|
205
|
+
bandl: {am: 'Pointe du matin', pm: 'Pointe du soir', day: 'Journée', evening: 'Soirée'},
|
|
206
|
+
metrics: {oph: 'Observations par heure', opp: 'Observations par passage', ex: 'Écart avec le soir'},
|
|
207
|
+
unitl: {seg: 'microsegment', m10: '10 m'},
|
|
208
|
+
stopsl: {true: 'Inclus', false: 'Exclus'},
|
|
209
|
+
stopsTitle: (a, b) => `Zone d'arrêt : de ${a} m avant à ${b} m après chaque arrêt`,
|
|
210
|
+
towards: t => `vers ${t}`,
|
|
211
|
+
play: '▶ Journée', pause: '❚❚ Pause', mean: 'moyenne',
|
|
212
|
+
tiles: {
|
|
213
|
+
days: (n, x) => `jours analysés${x ? `, ${x} écartés faute de données fiables` : ''}`,
|
|
214
|
+
segs: (n, L) => `microsegments d'environ ${L} m, dans les deux sens`,
|
|
215
|
+
obs: 'positions comptées sur la période',
|
|
216
|
+
hs: (n) => D.hotspots_status === 'not_computed' ? 'points chauds : pas assez de jours pour les repérer' : n ? 'points chauds : des retards répétés d\'un jour à l\'autre' : 'aucun point chaud net sur la période',
|
|
217
|
+
},
|
|
218
|
+
hstitle: 'Points chauds', hsnote: (m1, tk) => `Tronçons où chaque ${m1} est observé nettement plus souvent qu'entre ${REFH[0]} et ${REFH[1]} h, sur la plupart des jours. La valeur : observations en plus par ${m1} sur tout le tronçon (≈ secondes perdues, à raison de ${tk} s par observation). Cliquez pour le voir sur la carte.`,
|
|
219
|
+
hsnone: 'Aucun point chaud ne ressort dans ce sens : les écarts avec le soir restent faibles ou varient trop d\'un jour à l\'autre.',
|
|
220
|
+
hsalt: 'Les points chauds sont calculés sur la version principale du tracé.',
|
|
221
|
+
hsnot: 'Points chauds non calculés : il faut au moins 5 jours pour juger si un retard se répète.',
|
|
222
|
+
hsmore: n => `Voir les ${n} points chauds`, hsless: 'N\'afficher que les principaux',
|
|
223
|
+
kind: {infrastructure: 'toute la journée', congestion: 'aux heures de pointe', mixed: 'mixte'},
|
|
224
|
+
zone: {stop: 'à l\'arrêt', running: 'entre les arrêts'},
|
|
225
|
+
perveh: m1 => `obs. par ${m1}`, ci: (a, b) => `IC 95 % : ${a} à ${b}`, daysPct: p => `${p} % des jours`, secs: s => `≈ ${s} s`,
|
|
226
|
+
legend: {
|
|
227
|
+
oph: (m, per10) => `Observations par heure de relevés${per10 ? ', pour 10 m de voie' : ' dans le microsegment'} : en moyenne, combien de fois un ${m} y est vu en une heure. 1 observation ≈ ${TICK} s de présence.`,
|
|
228
|
+
opp: (m1) => `Observations par passage : combien de relevés chaque ${m1} laisse dans le microsegment. × ${TICK} s ≈ temps passé par ${m1}.`,
|
|
229
|
+
ex: (m1) => `Observations en plus par ${m1} par rapport au soir (${REFH[0]}–${REFH[1]} h, circulation fluide). +1 ≈ ${TICK} s perdues.`,
|
|
230
|
+
masked: 'zone d\'arrêt masquée', nodata: 'pas assez de données', hot: 'point chaud', evening: ' Choisissez une autre heure : le soir est la référence.',
|
|
231
|
+
},
|
|
232
|
+
tip: {
|
|
233
|
+
after: (a, b, s) => `${a} à ${b} m après ${s}`, towards: t => `vers ${t}`,
|
|
234
|
+
oph: v => `${v} observations par heure`, oph10: v => `${v} par heure pour 10 m`,
|
|
235
|
+
opp: (v, s) => `${v} observations par passage (≈ ${s} s)`, ex: (v, s) => `${v} par rapport au soir (≈ ${s} s)`,
|
|
236
|
+
same: 'comme le soir', stop: 'Zone d\'arrêt', nodata: 'pas assez de données', days: n => `${n} jours`, partial: 'tronçon absent certains jours',
|
|
237
|
+
},
|
|
238
|
+
mxtitle: 'Toute la journée d\'un coup d\'œil',
|
|
239
|
+
mxsub: (f, t, d, what, stops) => `De ${f} à ${t}, ${d}. En colonnes, la ligne découpée en microsegments (traits sombres : arrêts) ; en lignes, les heures. Couleur : ${what}, même échelle que la carte${stops ? '' : ', zones d\'arrêt masquées'}. Contours jaunes : points chauds. Cliquez une heure pour l'afficher sur la carte.`,
|
|
240
|
+
full: '⛶ Plein écran', unfull: '✕ Quitter le plein écran',
|
|
241
|
+
proftitle: 'Le long de la ligne, microsegment par microsegment',
|
|
242
|
+
profsub: (f, t, w, ref) => `De ${f} à ${t}, ${w}. Hauteur : la mesure choisie dans chaque microsegment. Traits verticaux : arrêts.${ref ? ` Trait blanc : le soir (${REFH[0]}–${REFH[1]} h).` : ''}`,
|
|
243
|
+
covtitle: 'Les jours retenus',
|
|
244
|
+
covsub: (n, x) => `Une case par jour. Un jour est écarté quand le flux de positions manque ou se fige sur plus d'un quart des heures de ${DAYH[0]} à ${DAYH[1]} h ; une heure est écartée sous 80 % de relevés. ${n} jours retenus, ${x} écartés. Survolez une case pour le détail.`,
|
|
245
|
+
cal: {inc: 'retenu', exc: 'écarté', off: 'hors sélection', none: 'pas de données'},
|
|
246
|
+
reasons: {low_coverage: 'trop peu de relevés', frozen: 'flux figé', line_absent: 'ligne absente du flux', deviation: 'trajets hors itinéraire', vehicle_ratio: 'nombre de véhicules anormal', no_data: 'aucune donnée', excluded: 'écarté à la demande', not_selected: 'hors sélection'},
|
|
247
|
+
covday: (c) => `relevés : ${c} % des heures ${DAYH[0]}–${DAYH[1]} h`,
|
|
248
|
+
vers: (a, b, n) => `${a} – ${b} · ${n} j`, verMain: 'principale', verNote: (r, a) => `${r} tronçon(s) en moins, ${a} en plus que la version principale`,
|
|
249
|
+
mtitle: 'Méthode',
|
|
250
|
+
method: (m, m1) => [
|
|
251
|
+
`<b>Observations.</b> Chaque relevé de position (environ toutes les ${TICK} s) d'un ${m1} sur la ligne compte pour une observation dans le microsegment où il se trouve. Les ${m} qui attendent au terminus ne comptent pas. Un flux plus dense (GTFS-RT) est ramené à une position par ${m1} toutes les ${TICK} s.`,
|
|
252
|
+
`<b>Microsegments.</b> Chaque tronçon entre deux arrêts est coupé en parts égales d'environ ${P.segment_m} m${P.phase_m ? ` (décalage ${P.phase_m} m)` : ''}.`,
|
|
253
|
+
`<b>Observations par heure.</b> Le total des observations divisé par le nombre d'heures où le flux était actif. Une heure à 50 % de relevés compte pour une demi-heure : les trous dans les données ne font pas baisser les valeurs.`,
|
|
254
|
+
`<b>Observations par passage.</b> Le total divisé par le nombre de ${m} passés : multiplié par ${TICK} s, il donne à peu près le temps que chaque ${m1} passe dans le microsegment.`,
|
|
255
|
+
`<b>Écart avec le soir.</b> Observations par passage à l'heure choisie, moins celles de ${REFH[0]} à ${REFH[1]} h, sur les mêmes jours.`,
|
|
256
|
+
`<b>Zones d'arrêt.</b> De ${(P.stop_zone || [30, 60])[0]} m avant à ${(P.stop_zone || [30, 60])[1]} m après chaque arrêt. Les masquer fait ressortir les feux et les carrefours ; l'échelle des couleurs est alors calculée sur le reste.`,
|
|
257
|
+
`<b>Échelle.</b> Fixe pour chaque mesure, quelles que soient l'heure et les jours choisis : le haut de l'échelle est le 98e centile des valeurs de ${DAYH[0]} à ${DAYH[1]} h.`,
|
|
258
|
+
`<b>Jours.</b> Chaque valeur est un rapport de sommes sur les jours choisis. Moins il y a de jours, plus les valeurs sont bruitées.`,
|
|
259
|
+
`<b>Changements de tracé.</b> Un microsegment n'est compté que les jours où il fait partie de l'itinéraire principal. La carte suit la version la plus fréquente ; les autres versions s'affichent avec le sélecteur.`,
|
|
260
|
+
],
|
|
261
|
+
footer: s => `${s ? 'Données : ' + s + '. ' : ''}Tracé et arrêts : GTFS. Calculs : microsegments.`,
|
|
262
|
+
when: h => `${h} h – ${h + 1} h`, nodir: 'Aucune donnée pour cette ligne.',
|
|
263
|
+
nomap: 'La carte a besoin d\'une connexion internet (Leaflet).',
|
|
264
|
+
},
|
|
265
|
+
en: {
|
|
266
|
+
title: (m, l) => `Where do ${m} linger?`,
|
|
267
|
+
lede: (L, tk, m1, m) => `The line is cut into micro-segments of about ${L} m. Roughly every ${tk} s, each ${m1}'s position is recorded and counts as one observation in the micro-segment it is in. The more observations a micro-segment collects, the longer ${m} stay there: stops, traffic lights, junctions, queues. One observation is about ${tk} seconds of presence.`,
|
|
268
|
+
days: 'Days', dir: 'Direction', ver: 'Route version', bands: 'Averages', metric: 'Measure', unit: 'Per', stops: 'Stops',
|
|
269
|
+
dow: ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'], dowl: ['Mondays', 'Tuesdays', 'Wednesdays', 'Thursdays', 'Fridays', 'Saturdays', 'Sundays'],
|
|
270
|
+
dowd: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'],
|
|
271
|
+
alldays: 'all selected days', weekdays: 'weekdays',
|
|
272
|
+
bandl: {am: 'Morning peak', pm: 'Evening peak', day: 'Daytime', evening: 'Evening'},
|
|
273
|
+
metrics: {oph: 'Observations per hour', opp: 'Observations per passage', ex: 'Excess over evening'},
|
|
274
|
+
unitl: {seg: 'micro-segment', m10: '10 m'},
|
|
275
|
+
stopsl: {true: 'Shown', false: 'Hidden'},
|
|
276
|
+
stopsTitle: (a, b) => `Stop zone: ${a} m before to ${b} m after each stop`,
|
|
277
|
+
towards: t => `to ${t}`,
|
|
278
|
+
play: '▶ Day', pause: '❚❚ Pause', mean: 'average',
|
|
279
|
+
tiles: {
|
|
280
|
+
days: (n, x) => `days analysed${x ? `, ${x} dropped for unreliable data` : ''}`,
|
|
281
|
+
segs: (n, L) => `micro-segments of about ${L} m, both directions`,
|
|
282
|
+
obs: 'positions counted over the period',
|
|
283
|
+
hs: (n) => D.hotspots_status === 'not_computed' ? 'hotspots: too few days to find them' : n ? 'hotspots: delays that recur day after day' : 'no clear hotspot over the period',
|
|
284
|
+
},
|
|
285
|
+
hstitle: 'Hotspots', hsnote: (m1, tk) => `Stretches where each ${m1} is observed clearly more often than between ${REFH[0]} and ${REFH[1]} h, on most days. Value: extra observations per ${m1} over the whole stretch (≈ seconds lost, at ${tk} s per observation). Click one to see it on the map.`,
|
|
286
|
+
hsnone: 'No hotspot stands out in this direction: the excess over the evening stays small or varies too much from day to day.',
|
|
287
|
+
hsalt: 'Hotspots are computed on the main route version.',
|
|
288
|
+
hsnot: 'Hotspots not computed: at least 5 days are needed to tell whether a delay recurs.',
|
|
289
|
+
hsmore: n => `Show all ${n} hotspots`, hsless: 'Show the main ones only',
|
|
290
|
+
kind: {infrastructure: 'all day', congestion: 'at peak hours', mixed: 'mixed'},
|
|
291
|
+
zone: {stop: 'at a stop', running: 'between stops'},
|
|
292
|
+
perveh: m1 => `obs. per ${m1}`, ci: (a, b) => `95 % CI: ${a} to ${b}`, daysPct: p => `${p} % of days`, secs: s => `≈ ${s} s`,
|
|
293
|
+
legend: {
|
|
294
|
+
oph: (m, per10) => `Observations per hour of polling${per10 ? ', per 10 m of track' : ' in the micro-segment'}: how many times a ${m} is seen there in an hour, on average. 1 observation ≈ ${TICK} s of presence.`,
|
|
295
|
+
opp: (m1) => `Observations per passage: how many fixes each ${m1} leaves in the micro-segment. × ${TICK} s ≈ time spent per ${m1}.`,
|
|
296
|
+
ex: (m1) => `Extra observations per ${m1} compared with the evening (${REFH[0]}–${REFH[1]} h, free-flowing traffic). +1 ≈ ${TICK} s lost.`,
|
|
297
|
+
masked: 'stop zone hidden', nodata: 'not enough data', hot: 'hotspot', evening: ' Pick another hour: the evening is the reference.',
|
|
298
|
+
},
|
|
299
|
+
tip: {
|
|
300
|
+
after: (a, b, s) => `${a} to ${b} m after ${s}`, towards: t => `to ${t}`,
|
|
301
|
+
oph: v => `${v} observations per hour`, oph10: v => `${v} per hour per 10 m`,
|
|
302
|
+
opp: (v, s) => `${v} observations per passage (≈ ${s} s)`, ex: (v, s) => `${v} vs the evening (≈ ${s} s)`,
|
|
303
|
+
same: 'same as the evening', stop: 'Stop zone', nodata: 'not enough data', days: n => `${n} days`, partial: 'stretch missing on some days',
|
|
304
|
+
},
|
|
305
|
+
mxtitle: 'The whole day at a glance',
|
|
306
|
+
mxsub: (f, t, d, what, stops) => `From ${f} to ${t}, ${d}. Columns: the line cut into micro-segments (dark lines: stops); rows: hours. Colour: ${what}, same scale as the map${stops ? '' : ', stop zones hidden'}. Yellow outlines: hotspots. Click an hour to show it on the map.`,
|
|
307
|
+
full: '⛶ Full screen', unfull: '✕ Exit full screen',
|
|
308
|
+
proftitle: 'Along the line, micro-segment by micro-segment',
|
|
309
|
+
profsub: (f, t, w, ref) => `From ${f} to ${t}, ${w}. Height: the chosen measure in each micro-segment. Vertical lines: stops.${ref ? ` White line: the evening (${REFH[0]}–${REFH[1]} h).` : ''}`,
|
|
310
|
+
covtitle: 'Days used',
|
|
311
|
+
covsub: (n, x) => `One square per day. A day is dropped when the position feed is missing or frozen for more than a quarter of the hours from ${DAYH[0]} to ${DAYH[1]} h; an hour is dropped below 80 % of polls. ${n} days used, ${x} dropped. Hover a square for details.`,
|
|
312
|
+
cal: {inc: 'used', exc: 'dropped', off: 'not selected', none: 'no data'},
|
|
313
|
+
reasons: {low_coverage: 'too few polls', frozen: 'frozen feed', line_absent: 'line missing from the feed', deviation: 'off-route trips', vehicle_ratio: 'abnormal vehicle count', no_data: 'no data', excluded: 'excluded on request', not_selected: 'not selected'},
|
|
314
|
+
covday: (c) => `polls: ${c} % of ${DAYH[0]}–${DAYH[1]} h`,
|
|
315
|
+
vers: (a, b, n) => `${a} – ${b} · ${n} d`, verMain: 'main', verNote: (r, a) => `${r} stretch(es) fewer, ${a} more than the main version`,
|
|
316
|
+
mtitle: 'Method',
|
|
317
|
+
method: (m, m1) => [
|
|
318
|
+
`<b>Observations.</b> Every position fix (about every ${TICK} s) of a ${m1} on the line counts as one observation in the micro-segment it falls in. ${m[0].toUpperCase() + m.slice(1)} waiting at the terminus are not counted. Denser feeds (GTFS-RT) are thinned to one position per ${m1} every ${TICK} s.`,
|
|
319
|
+
`<b>Micro-segments.</b> Each stretch between two stops is split into equal parts of about ${P.segment_m} m${P.phase_m ? ` (offset ${P.phase_m} m)` : ''}.`,
|
|
320
|
+
`<b>Observations per hour.</b> Total observations divided by the hours during which the feed was alive. An hour with 50 % of polls counts as half an hour, so data gaps do not lower the values.`,
|
|
321
|
+
`<b>Observations per passage.</b> Total divided by the number of ${m} that went through: times ${TICK} s, it is roughly the time each ${m1} spends in the micro-segment.`,
|
|
322
|
+
`<b>Excess over evening.</b> Observations per passage at the chosen hour minus those from ${REFH[0]} to ${REFH[1]} h, on the same days.`,
|
|
323
|
+
`<b>Stop zones.</b> From ${(P.stop_zone || [30, 60])[0]} m before to ${(P.stop_zone || [30, 60])[1]} m after each stop. Hiding them brings out signals and junctions; the colour scale is then computed on the rest.`,
|
|
324
|
+
`<b>Scale.</b> Fixed for each measure whatever the hour and days: its top is the 98th percentile of the values from ${DAYH[0]} to ${DAYH[1]} h.`,
|
|
325
|
+
`<b>Days.</b> Each value is a ratio of sums over the chosen days. Fewer days means noisier values.`,
|
|
326
|
+
`<b>Route changes.</b> A micro-segment only counts on days when it is part of the main route. The map follows the most frequent version; the others can be picked with the selector.`,
|
|
327
|
+
],
|
|
328
|
+
footer: s => `${s ? 'Data: ' + s + '. ' : ''}Route and stops: GTFS. Computed with microsegments.`,
|
|
329
|
+
when: h => `${h} h – ${h + 1} h`, nodir: 'No data for this line.',
|
|
330
|
+
nomap: 'The map needs an internet connection (Leaflet).',
|
|
331
|
+
},
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
// ---------------------------------------------------------------- colours: the prototype's OKLab ramp
|
|
335
|
+
// Continuous ramp interpolated in OKLab; seqColor reads it from green (little) to dark red (most).
|
|
336
|
+
const RAMP = [[4.5,'#7a1020'],[7.5,'#e5484d'],[10.5,'#f0a13a'],[13.5,'#b8e27f'],[16.5,'#3fb950'],[21,'#1f8f45'],[27,'#11683e']];
|
|
337
|
+
const hex2 = h => [1,3,5].map(i => parseInt(h.slice(i,i+2),16)/255);
|
|
338
|
+
const lin = c => c <= .04045 ? c/12.92 : ((c+.055)/1.055)**2.4, gam = c => c <= .0031308 ? 12.92*c : 1.055*c**(1/2.4)-.055;
|
|
339
|
+
function toLab([r,g,b]) {
|
|
340
|
+
[r,g,b] = [r,g,b].map(lin);
|
|
341
|
+
const l = Math.cbrt(.4122214708*r+.5363325363*g+.0514459929*b), m = Math.cbrt(.2119034982*r+.6806995451*g+.1073969566*b), s = Math.cbrt(.0883024619*r+.2817188376*g+.6299787005*b);
|
|
342
|
+
return [.2104542553*l+.7936177850*m-.0040720468*s, 1.9779984951*l-2.4285922050*m+.4505937099*s, .0259040371*l+.7827717662*m-.8086757660*s];
|
|
343
|
+
}
|
|
344
|
+
function fromLab([L,a,b]) {
|
|
345
|
+
const l = (L+.3963377774*a+.2158037573*b)**3, m = (L-.1055613458*a-.0638541728*b)**3, s = (L-.0894841775*a-1.2914855480*b)**3;
|
|
346
|
+
return [4.0767416621*l-3.3077115913*m+.2309699292*s, -1.2684380046*l+2.6097574011*m-.3413193965*s, -.0041960863*l-.7034186147*m+1.7076147010*s]
|
|
347
|
+
.map(c => Math.round(Math.min(1, Math.max(0, gam(c)))*255));
|
|
348
|
+
}
|
|
349
|
+
const RAMP_LAB = RAMP.map(([v,h]) => [v, toLab(hex2(h))]);
|
|
350
|
+
function colorOf(v) {
|
|
351
|
+
const R = RAMP_LAB; let i = 0;
|
|
352
|
+
if (v <= R[0][0]) return RAMP[0][1];
|
|
353
|
+
if (v >= R[R.length-1][0]) return RAMP[R.length-1][1];
|
|
354
|
+
while (v > R[i+1][0]) i++;
|
|
355
|
+
const t = (v - R[i][0]) / (R[i+1][0] - R[i][0]);
|
|
356
|
+
return `rgb(${fromLab(R[i][1].map((c,k) => c + t*(R[i+1][1][k] - c))).join(',')})`;
|
|
357
|
+
}
|
|
358
|
+
function seqColor(v, max) {
|
|
359
|
+
if (v == null || !isFinite(v)) return 'var(--null)';
|
|
360
|
+
const t = Math.min(1, Math.sqrt(Math.max(0, v) / max));
|
|
361
|
+
return colorOf(22 - t * 17.5);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// ---------------------------------------------------------------- state
|
|
365
|
+
const qs = new URLSearchParams(location.search);
|
|
366
|
+
const st = {lang: T[qs.get('lang')] ? qs.get('lang') : (T[document.documentElement.lang] ? document.documentElement.lang : 'fr'),
|
|
367
|
+
dir: null, ver: null, band: null, hour: null, metric: 'oph', per10: false, stops: false, days: [], hot: null};
|
|
368
|
+
const tx = () => T[st.lang];
|
|
369
|
+
const noun = (pl) => { const n = NOUN[st.lang][D.mode] || NOUN[st.lang]._; return pl ? n[1] : n[0]; };
|
|
370
|
+
const nf = (v, d = 1) => v.toLocaleString(LOC[st.lang], {minimumFractionDigits: d, maximumFractionDigits: d});
|
|
371
|
+
const nfa = v => nf(v, Math.abs(v) >= 10 ? 0 : 1);
|
|
372
|
+
const signed = (v, d = 1) => (v > 0 ? '+' : v < 0 ? '−' : '') + nf(Math.abs(v), d);
|
|
373
|
+
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({'&': '&', '<': '<', '>': '>', '"': '"'}[c]));
|
|
374
|
+
const fmtDate = (s, o = {day: 'numeric', month: 'long', year: 'numeric'}) => new Date(s + 'T12:00:00').toLocaleDateString(LOC[st.lang], o);
|
|
375
|
+
const fmtShort = s => fmtDate(s, {day: '2-digit', month: '2-digit', year: '2-digit'});
|
|
376
|
+
const DIRS = D.dirs || [];
|
|
377
|
+
const PRESENT = [0,1,2,3,4,5,6].filter(w => (D.period.per_dow || [])[w] > 0);
|
|
378
|
+
st.days = PRESENT.slice();
|
|
379
|
+
st.dir = DIRS.length ? DIRS[0].dir : null;
|
|
380
|
+
st.hour = D.hours.includes(8) ? 8 : D.hours[0];
|
|
381
|
+
const dirOf = () => DIRS.find(x => x.dir === st.dir);
|
|
382
|
+
// the entry shown: the display version of the direction, or one of its other versions
|
|
383
|
+
const entry = () => { const d = dirOf(); if (!d) return null; if (!st.ver || st.ver === d.pattern_uid) return d; return (d.alt || []).find(a => a.pattern_uid === st.ver) || d; };
|
|
384
|
+
const isMain = () => { const d = dirOf(); return !st.ver || (d && st.ver === d.pattern_uid); };
|
|
385
|
+
const key = () => st.band || String(st.hour);
|
|
386
|
+
const nHours = k => BANDDEF[k] ? D.hours.filter(h => h >= BANDDEF[k][0] && h < BANDDEF[k][1]).length : 1;
|
|
387
|
+
|
|
388
|
+
// ---------------------------------------------------------------- sums over the chosen weekdays
|
|
389
|
+
// Totals are per weekday in the data (wk); every view sums the chosen weekdays, then divides.
|
|
390
|
+
const AGG = new Map();
|
|
391
|
+
function agg(E, days = st.days) {
|
|
392
|
+
const ck = E.pattern_uid + '|' + E.dir + '|' + days.join(',');
|
|
393
|
+
if (AGG.has(ck)) return AGG.get(ck);
|
|
394
|
+
const ns = E.seg.key.length, nl = E.links.length, o = {}, c = {}, p = {};
|
|
395
|
+
D.hours.forEach((h, j) => {
|
|
396
|
+
const a = new Float64Array(ns), b = new Float64Array(nl), q = new Float64Array(nl);
|
|
397
|
+
for (const w of days) {
|
|
398
|
+
const ow = E.wk.obs[w][j], cw = E.wk.cov[w][j], pw = E.wk.p[w][j];
|
|
399
|
+
for (let i = 0; i < ns; i++) a[i] += ow[i];
|
|
400
|
+
for (let l = 0; l < nl; l++) { b[l] += cw[l]; q[l] += pw[l]; }
|
|
401
|
+
}
|
|
402
|
+
o[h] = a; c[h] = b; p[h] = q;
|
|
403
|
+
});
|
|
404
|
+
const sumH = (src, n, hs) => { const r = new Float64Array(n); for (const h of hs) if (src[h]) src[h].forEach((v, i) => r[i] += v); return r; };
|
|
405
|
+
for (const [k, [h0, h1]] of Object.entries(BANDDEF)) {
|
|
406
|
+
const hs = D.hours.filter(h => h >= h0 && h < h1);
|
|
407
|
+
o[k] = sumH(o, ns, hs); c[k] = sumH(c, nl, hs); p[k] = sumH(p, nl, hs);
|
|
408
|
+
}
|
|
409
|
+
const rh = D.hours.filter(h => h >= REFH[0] && h < REFH[1]);
|
|
410
|
+
const ro = sumH(o, ns, rh), rp = sumH(p, nl, rh);
|
|
411
|
+
const nd = new Float64Array(nl);
|
|
412
|
+
for (const w of days) E.wk.days[w].forEach((v, l) => nd[l] += v);
|
|
413
|
+
const ref = E.seg.link.map((l, i) => rp[l] >= 0.5 * Math.max(1, nd[l]) * Math.max(1, rh.length) ? ro[i] / rp[l] : null);
|
|
414
|
+
const A = {o, c, p, ref, nd};
|
|
415
|
+
AGG.set(ck, A);
|
|
416
|
+
return A;
|
|
417
|
+
}
|
|
418
|
+
function isStop(E, i) { return E.seg.zone[i] === 'stop'; }
|
|
419
|
+
const shown = v => st.stops || !v.stop;
|
|
420
|
+
// Per micro-segment values for a key (hour or band).
|
|
421
|
+
function vals(E, k = key(), days = st.days) {
|
|
422
|
+
const A = agg(E, days), o = A.o[k], c = A.c[k], p = A.p[k], nh = nHours(k);
|
|
423
|
+
return E.seg.key.map((_, i) => {
|
|
424
|
+
const l = E.seg.link[i], len = E.seg.len[i];
|
|
425
|
+
const oph = c && c[l] > 0 ? o[i] / c[l] : null;
|
|
426
|
+
const opp = p && p[l] >= 0.5 * Math.max(1, A.nd[l]) * nh ? o[i] / p[l] : null;
|
|
427
|
+
const ref = A.ref[i];
|
|
428
|
+
return {i, l, x0: E.seg.x0[i], len, stop: isStop(E, i), flags: E.seg.flags[i] || 0, obs: o ? o[i] : 0,
|
|
429
|
+
oph, oph10: oph == null ? null : oph * 10 / len, opp, ref, ex: opp != null && ref != null ? opp - ref : null, nd: A.nd[l]};
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
const mkey = () => st.metric === 'oph' ? (st.per10 ? 'oph10' : 'oph') : st.metric;
|
|
433
|
+
function metricVal(v, m = mkey()) { const x = v[m]; return x == null ? null : m === 'ex' ? Math.max(0, x) : x; }
|
|
434
|
+
|
|
435
|
+
// ---------------------------------------------------------------- fixed scales (p98 over day hours, all days)
|
|
436
|
+
function nice(v) {
|
|
437
|
+
if (!(v > 0)) return 1;
|
|
438
|
+
const e = Math.pow(10, Math.floor(Math.log10(v))), f = v / e;
|
|
439
|
+
return [1, 1.2, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10].find(x => x >= f - 1e-9) * e;
|
|
440
|
+
}
|
|
441
|
+
const SCALES = {};
|
|
442
|
+
(function scales() {
|
|
443
|
+
const dh = D.hours.filter(h => h >= DAYH[0] && h < DAYH[1]);
|
|
444
|
+
for (const m of ['oph', 'oph10', 'opp', 'ex']) for (const stops of [true, false]) {
|
|
445
|
+
const fixed = D.scales && D.scales[m] != null ? (typeof D.scales[m] === 'object' ? D.scales[m][stops] : D.scales[m]) : null;
|
|
446
|
+
if (fixed != null) { SCALES[m + stops] = fixed; continue; }
|
|
447
|
+
const xs = [];
|
|
448
|
+
for (const E of DIRS) for (const h of dh) for (const v of vals(E, String(h), PRESENT)) {
|
|
449
|
+
if (!stops && v.stop) continue;
|
|
450
|
+
const x = metricVal(v, m); if (x != null && (m !== 'ex' || x > 0)) xs.push(x);
|
|
451
|
+
}
|
|
452
|
+
xs.sort((a, b) => a - b);
|
|
453
|
+
SCALES[m + stops] = nice(xs.length ? xs[Math.min(xs.length - 1, Math.floor(xs.length * .98))] : 1);
|
|
454
|
+
}
|
|
455
|
+
})();
|
|
456
|
+
const scaleMax = () => SCALES[mkey() + st.stops];
|
|
457
|
+
const colorFor = (v, max) => !shown(v) ? 'var(--masked)' : seqColor(metricVal(v), max);
|
|
458
|
+
|
|
459
|
+
// ---------------------------------------------------------------- helpers
|
|
460
|
+
function stopBefore(E, x) { let s = E.stops[0]; for (const t of E.stops) if (t.x <= x + .5) s = t; return s; }
|
|
461
|
+
function hourRuns(hs) {
|
|
462
|
+
const a = [...new Set(hs)].sort((x, y) => x - y), out = [];
|
|
463
|
+
for (const h of a) { const r = out[out.length - 1]; if (r && h === r[1]) r[1] = h + 1; else out.push([h, h + 1]); }
|
|
464
|
+
return out;
|
|
465
|
+
}
|
|
466
|
+
const runsLabel = hs => hourRuns(hs).map(([a, b]) => `${a}–${b} h`).join(', ');
|
|
467
|
+
function daysLabel() {
|
|
468
|
+
if (st.days.length === PRESENT.length) return tx().alldays;
|
|
469
|
+
if (st.days.join() === '0,1,2,3,4') return tx().weekdays;
|
|
470
|
+
return st.days.map(w => tx().dowl[w]).join(', ');
|
|
471
|
+
}
|
|
472
|
+
const whenLabel = () => st.band ? `${tx().bandl[st.band].toLowerCase()} (${BANDDEF[st.band][0]}–${BANDDEF[st.band][1]} h)` : tx().when(st.hour);
|
|
473
|
+
function tipHtml(E, v) {
|
|
474
|
+
const t = tx().tip, s = stopBefore(E, v.x0), lk = E.links[v.l];
|
|
475
|
+
let h = `<b>${t.after(Math.round(v.x0 - s.x), Math.round(v.x0 - s.x + v.len), esc(s.name))}</b> (${t.towards(esc(lk ? lk.to : E.to))})<br>`;
|
|
476
|
+
if (v.oph == null) h += t.nodata;
|
|
477
|
+
else {
|
|
478
|
+
h += t.oph(nfa(v.oph)) + ` · ${t.oph10(nfa(v.oph10))}`;
|
|
479
|
+
if (v.opp != null) h += '<br>' + t.opp(nf(v.opp, 2), nf(v.opp * TICK, 0));
|
|
480
|
+
if (v.ex != null) h += '<br>' + (Math.abs(v.ex * TICK) < .5 ? t.same : t.ex(signed(v.ex, 2), signed(v.ex * TICK, 0)));
|
|
481
|
+
}
|
|
482
|
+
h += `<br><span style="color:var(--ink-3)">${t.days(Math.round(v.nd))}${v.flags & 32 ? ' · ' + t.partial : ''}</span>`;
|
|
483
|
+
if (v.stop) h += `<br><i>${t.stop}</i>`;
|
|
484
|
+
return h;
|
|
485
|
+
}
|
|
486
|
+
const tipEl = document.getElementById('tip');
|
|
487
|
+
function showTip(e, html) { tipEl.innerHTML = html; tipEl.style.display = 'block'; const w = tipEl.offsetWidth, hh = tipEl.offsetHeight;
|
|
488
|
+
tipEl.style.left = Math.min(window.innerWidth - w - 8, e.clientX + 14) + 'px'; tipEl.style.top = (e.clientY + 16 + hh > window.innerHeight ? e.clientY - hh - 10 : e.clientY + 16) + 'px'; }
|
|
489
|
+
const hideTip = () => { tipEl.style.display = 'none'; };
|
|
490
|
+
const HOT_TOP = 8;
|
|
491
|
+
const hotAll = () => (D.hotspots || []).filter(h => (h.dir ?? h.direction_id) === st.dir && (!h.pattern_uid || h.pattern_uid === (dirOf() || {}).pattern_uid)).sort((a, b) => a.rank - b.rank);
|
|
492
|
+
// the list (and the map markers) show the top hotspots unless "show all" is on
|
|
493
|
+
const hotOfDir = () => { const a = hotAll(); return st.hotAll ? a : a.slice(0, HOT_TOP); };
|
|
494
|
+
|
|
495
|
+
// ---------------------------------------------------------------- static texts
|
|
496
|
+
function texts() {
|
|
497
|
+
const l = tx(), mode = (MODE[st.lang][D.mode] || MODE[st.lang]._);
|
|
498
|
+
document.documentElement.lang = st.lang;
|
|
499
|
+
const d0 = DIRS[0];
|
|
500
|
+
const ends = d0 ? `${d0.from} ↔ ${d0.to}` : '';
|
|
501
|
+
const per = D.period.first ? `${fmtDate(D.period.first)} – ${fmtDate(D.period.last)}` : '';
|
|
502
|
+
document.getElementById('kicker').textContent = [`${mode} ${D.line || ''}`.trim(), ends, per].filter(Boolean).join(' · ');
|
|
503
|
+
document.getElementById('h1').innerHTML = (D.line ? `<span class="badge">${esc(D.line)}</span>` : '') + esc(D.title || l.title(noun(true), D.line));
|
|
504
|
+
document.getElementById('lede').textContent = l.lede(nf(P.segment_m || 30, 0), nf(TICK, 0), noun(false), noun(true));
|
|
505
|
+
for (const [id, k] of [['l_days', 'days'], ['l_dir', 'dir'], ['l_ver', 'ver'], ['l_bands', 'bands'], ['l_metric', 'metric'], ['l_unit', 'unit'], ['l_stops', 'stops']])
|
|
506
|
+
document.getElementById(id).textContent = l[k];
|
|
507
|
+
document.getElementById('stopsT').title = l.stopsTitle(...(P.stop_zone || [30, 60]));
|
|
508
|
+
document.getElementById('hstitle').textContent = l.hstitle;
|
|
509
|
+
document.getElementById('mxtitle').textContent = l.mxtitle;
|
|
510
|
+
document.getElementById('proftitle').textContent = l.proftitle;
|
|
511
|
+
document.getElementById('covtitle').textContent = l.covtitle;
|
|
512
|
+
document.getElementById('mtitle').textContent = l.mtitle;
|
|
513
|
+
document.getElementById('method').innerHTML = l.method(noun(true), noun(false)).map(x => `<li>${x}</li>`).join('');
|
|
514
|
+
document.getElementById('footer').textContent = l.footer(D.source);
|
|
515
|
+
document.getElementById('play').textContent = timer ? l.pause : l.play;
|
|
516
|
+
document.getElementById('mxfull').textContent = mxCard.classList.contains('full') ? l.unfull : l.full;
|
|
517
|
+
document.getElementById('map').setAttribute('aria-label', l.metrics[st.metric]);
|
|
518
|
+
const hs = D.hours, tk = [hs[0], 8, 12, 17, hs[hs.length - 1]].filter((h, i, a) => hs.includes(h) && a.indexOf(h) === i);
|
|
519
|
+
document.getElementById('hticks').innerHTML = tk.map(h => `<span>${h} h</span>`).join('');
|
|
520
|
+
}
|
|
521
|
+
function tiles() {
|
|
522
|
+
const l = tx(), nx = (D.period.excluded || []).length;
|
|
523
|
+
const nseg = DIRS.reduce((a, d) => a + d.seg.key.length, 0);
|
|
524
|
+
let tot = 0;
|
|
525
|
+
for (const d of DIRS) for (const w of PRESENT) for (const row of d.wk.obs[w]) for (const v of row) tot += v;
|
|
526
|
+
const nh = (D.hotspots || []).length;
|
|
527
|
+
const big = v => v >= 1e6 ? nf(v / 1e6, 1) + (st.lang === 'fr' ? ' M' : 'M') : v >= 1e4 ? nf(v / 1e3, 0) + (st.lang === 'fr' ? ' k' : 'k') : nf(v, 0);
|
|
528
|
+
document.getElementById('tiles').innerHTML = [
|
|
529
|
+
[nf(D.period.days, 0), l.tiles.days(D.period.days, nx)],
|
|
530
|
+
[nf(nseg, 0), l.tiles.segs(nseg, nf(P.segment_m || 30, 0))],
|
|
531
|
+
[big(tot), l.tiles.obs],
|
|
532
|
+
[nf(nh, 0), l.tiles.hs(nh)],
|
|
533
|
+
].map(([b, s]) => `<div class="tile"><b>${b}</b><span>${s}</span></div>`).join('');
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// ---------------------------------------------------------------- controls
|
|
537
|
+
const $ = id => document.getElementById(id);
|
|
538
|
+
function buttons(el, items, onclick) {
|
|
539
|
+
el.innerHTML = '';
|
|
540
|
+
for (const [k, t, title] of items) { const b = document.createElement('button'); b.type = 'button'; b.textContent = t; b.dataset.k = k; if (title) b.title = title; b.onclick = () => onclick(k); el.append(b); }
|
|
541
|
+
}
|
|
542
|
+
function buildControls() {
|
|
543
|
+
const l = tx();
|
|
544
|
+
buttons($('langs'), [['fr', 'FR'], ['en', 'EN']], k => { st.lang = k; buildControls(); texts(); tiles(); drawAll(); });
|
|
545
|
+
buttons($('days'), [0,1,2,3,4,5,6].map(w => [w, l.dow[w], l.dowl[w]]), k => {
|
|
546
|
+
const w = +k; if (!PRESENT.includes(w)) return;
|
|
547
|
+
const on = st.days.includes(w);
|
|
548
|
+
if (on && st.days.length === 1) return;
|
|
549
|
+
st.days = on ? st.days.filter(x => x !== w) : [...st.days, w].sort();
|
|
550
|
+
render();
|
|
551
|
+
});
|
|
552
|
+
$('days').querySelectorAll('button').forEach(b => { if (!PRESENT.includes(+b.dataset.k)) b.disabled = true; });
|
|
553
|
+
const dirItems = DIRS.map(d => [d.dir, l.towards(d.to)]);
|
|
554
|
+
const setDir = k => { st.dir = +k; st.ver = null; st.hot = null; drawAll(); };
|
|
555
|
+
buttons($('dirs'), dirItems, setDir);
|
|
556
|
+
buttons($('mxdirs'), dirItems, setDir);
|
|
557
|
+
buttons($('bands'), Object.keys(BANDDEF).map(k => [k, l.bandl[k], `${BANDDEF[k][0]}–${BANDDEF[k][1]} h`]), k => { stop(); st.band = st.band === k ? null : k; render(); });
|
|
558
|
+
buttons($('metric'), Object.entries(l.metrics), k => { st.metric = k; render(); });
|
|
559
|
+
buttons($('unit'), [['seg', l.unitl.seg], ['m10', l.unitl.m10]], k => { st.per10 = k === 'm10'; render(); });
|
|
560
|
+
buttons($('stopsT'), [['true', l.stopsl.true], ['false', l.stopsl.false]], k => { st.stops = k === 'true'; render(); });
|
|
561
|
+
buildVersions();
|
|
562
|
+
}
|
|
563
|
+
function buildVersions() {
|
|
564
|
+
const d = dirOf(), l = tx();
|
|
565
|
+
const vs = (D.versions || []).filter(v => v.dir === st.dir).sort((a, b) => (b.display - a.display) || (a.first < b.first ? -1 : 1));
|
|
566
|
+
$('g_ver').hidden = !(d && d.alt && d.alt.length);
|
|
567
|
+
if ($('g_ver').hidden) return;
|
|
568
|
+
buttons($('vers'), vs.map(v => [v.pattern_uid, l.vers(fmtShort(v.first), fmtShort(v.last), v.n_days) + (v.display ? ` (${l.verMain})` : ''),
|
|
569
|
+
v.display ? '' : l.verNote(v.links_removed.length, v.links_added.length)]), k => { st.ver = k === d.pattern_uid ? null : k; st.hot = null; drawAll(); });
|
|
570
|
+
}
|
|
571
|
+
const hourEl = $('hour');
|
|
572
|
+
hourEl.min = D.hours[0]; hourEl.max = D.hours[D.hours.length - 1];
|
|
573
|
+
hourEl.setAttribute('aria-label', 'Heure');
|
|
574
|
+
|
|
575
|
+
// ---------------------------------------------------------------- map
|
|
576
|
+
let map = null, renderer = null, baseLayer, segLayer, stopLayer, hotLayer, segs = [];
|
|
577
|
+
const mapEl = $('map');
|
|
578
|
+
function initMap() {
|
|
579
|
+
if (typeof window.L === 'undefined' || !DIRS.length) { mapEl.innerHTML = `<div class="nomap">${DIRS.length ? tx().nomap : tx().nodir}</div>`; return; }
|
|
580
|
+
const Lf = window.L;
|
|
581
|
+
map = Lf.map('map', {scrollWheelZoom: false, zoomSnap: .25});
|
|
582
|
+
map.attributionControl.setPrefix(false);
|
|
583
|
+
if (D.tiles !== false) Lf.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Dark_Gray_Base/MapServer/tile/{z}/{y}/{x}',
|
|
584
|
+
{maxZoom: 16, opacity: .6, attribution: 'Esri, HERE, Garmin, © OpenStreetMap'}).addTo(map);
|
|
585
|
+
if (D.source) map.attributionControl.addAttribution(esc(D.source));
|
|
586
|
+
renderer = Lf.svg({padding: .5});
|
|
587
|
+
for (const c of (D.ctx || [])) Lf.polyline(c.map(([x, y]) => [y, x]), {renderer, color: '#1f2b47', weight: 1.5, interactive: false}).addTo(map);
|
|
588
|
+
baseLayer = Lf.layerGroup().addTo(map); hotLayer = Lf.layerGroup().addTo(map); segLayer = Lf.layerGroup().addTo(map); stopLayer = Lf.layerGroup().addTo(map);
|
|
589
|
+
const all = DIRS.flatMap(d => [d, ...(d.alt || [])]).flatMap(d => d.seg.c.flatMap(c => c.map(([x, y]) => [y, x])));
|
|
590
|
+
const fit = () => { map.invalidateSize(); if (all.length) map.fitBounds(Lf.latLngBounds(all), {padding: [24, 24]}); };
|
|
591
|
+
fit(); window.addEventListener('load', fit);
|
|
592
|
+
map.on('zoomend', declutter);
|
|
593
|
+
}
|
|
594
|
+
// Hide stop labels that would overlap an already placed one.
|
|
595
|
+
function declutter() {
|
|
596
|
+
if (!map) return;
|
|
597
|
+
const placed = [];
|
|
598
|
+
stopLayer.eachLayer(m => {
|
|
599
|
+
if (!m._lab) return;
|
|
600
|
+
const p = map.latLngToLayerPoint(m.getLatLng()), el = m.getElement && m.getElement();
|
|
601
|
+
if (!el) return;
|
|
602
|
+
const lab = el.querySelector('.lab'); if (!lab) return;
|
|
603
|
+
const ok = !placed.some(q => Math.abs(q.x - p.x) < 70 && Math.abs(q.y - p.y) < 16);
|
|
604
|
+
lab.classList.toggle('hide', !ok);
|
|
605
|
+
if (ok) placed.push(p);
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
function drawMap() {
|
|
609
|
+
if (!map) return;
|
|
610
|
+
const Lf = window.L, E = entry();
|
|
611
|
+
baseLayer.clearLayers(); segLayer.clearLayers(); stopLayer.clearLayers(); segs = [];
|
|
612
|
+
if (!E) return;
|
|
613
|
+
E.seg.c.forEach(c => { if (c.length > 1) Lf.polyline(c.map(([x, y]) => [y, x]), {renderer, color: '#05090f', weight: 14, interactive: false}).addTo(baseLayer); });
|
|
614
|
+
E.seg.c.forEach((c, i) => {
|
|
615
|
+
const p = Lf.polyline(c.map(([x, y]) => [y, x]), {renderer, weight: 10, lineCap: 'butt'}).addTo(segLayer);
|
|
616
|
+
p.bindTooltip(() => tipHtml(E, vals(E)[i]), {sticky: true});
|
|
617
|
+
segs.push(p);
|
|
618
|
+
});
|
|
619
|
+
E.stops.forEach((s, k) => {
|
|
620
|
+
if (!s.ll) return;
|
|
621
|
+
const ll = [s.ll[1], s.ll[0]];
|
|
622
|
+
// label above where the line runs east-west, to the right otherwise
|
|
623
|
+
const nb = E.stops[Math.min(k + 1, E.stops.length - 1)].ll || s.ll, pb = E.stops[Math.max(k - 1, 0)].ll || s.ll;
|
|
624
|
+
const side = Math.abs(nb[0] - pb[0]) * Math.cos(s.ll[1] * Math.PI / 180) > Math.abs(nb[1] - pb[1]) ? 'u' : 'r';
|
|
625
|
+
Lf.circleMarker(ll, {renderer, radius: 4, color: '#0b1224', weight: 2, fillColor: '#eef1f6', fillOpacity: 1, interactive: false}).addTo(stopLayer);
|
|
626
|
+
const m = Lf.marker(ll, {icon: Lf.divIcon({className: '', html: `<div class="lab ${side}">${esc(s.name)}</div>`, iconSize: [0, 0]}), interactive: false}).addTo(stopLayer);
|
|
627
|
+
m._lab = true;
|
|
628
|
+
});
|
|
629
|
+
drawHotMap();
|
|
630
|
+
setTimeout(declutter, 0);
|
|
631
|
+
}
|
|
632
|
+
function hotSegIdx(E, h) { const out = []; E.seg.x0.forEach((x, i) => { if (x + E.seg.len[i] > h.x0_m + .5 && x < h.x1_m - .5) out.push(i); }); return out; }
|
|
633
|
+
function drawHotMap() {
|
|
634
|
+
if (!map) return;
|
|
635
|
+
const Lf = window.L, E = entry();
|
|
636
|
+
hotLayer.clearLayers();
|
|
637
|
+
if (!E || !isMain()) return;
|
|
638
|
+
for (const h of hotOfDir()) {
|
|
639
|
+
const idx = hotSegIdx(E, h); if (!idx.length) continue;
|
|
640
|
+
const on = st.hot === h.rank;
|
|
641
|
+
idx.forEach(i => { const c = E.seg.c[i]; if (c.length > 1) Lf.polyline(c.map(([x, y]) => [y, x]), {renderer, color: on ? '#ffffff' : '#ffe600', weight: on ? 20 : 17, lineCap: 'butt', interactive: false}).addTo(hotLayer); });
|
|
642
|
+
const mid = E.seg.c[idx[Math.floor(idx.length / 2)]]; if (!mid || !mid.length) continue;
|
|
643
|
+
const pt = mid[Math.floor(mid.length / 2)];
|
|
644
|
+
Lf.marker([pt[1], pt[0]], {icon: Lf.divIcon({className: '', html: `<div class="hsm">${h.rank}</div>`, iconSize: [22, 22], iconAnchor: [30, 28]}), zIndexOffset: 1000})
|
|
645
|
+
.on('click', () => selectHot(h.rank)).addTo(hotLayer);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
function recolor() {
|
|
649
|
+
const E = entry(), max = scaleMax();
|
|
650
|
+
if (!E) return max;
|
|
651
|
+
const v = vals(E);
|
|
652
|
+
segs.forEach((p, i) => p.setStyle({color: colorFor(v[i], max), weight: shown(v[i]) ? 10 : 4}));
|
|
653
|
+
return max;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// ---------------------------------------------------------------- legend
|
|
657
|
+
function niceTicks(max) {
|
|
658
|
+
const cand = [0, .1, .2, .5, 1, 2, 3, 5, 10, 20, 30, 50, 100, 200, 500, 1000].filter(v => v < max * .97);
|
|
659
|
+
const pick = [0]; for (const v of cand.slice(1)) if (Math.sqrt(v / max) - Math.sqrt(pick[pick.length - 1] / max) > .16) pick.push(v);
|
|
660
|
+
return pick.concat(max);
|
|
661
|
+
}
|
|
662
|
+
function legend(max) {
|
|
663
|
+
const l = tx().legend, m = st.metric;
|
|
664
|
+
const bar = Array.from({length: 21}, (_, i) => seqColor(max * (i / 20) ** 2, max)).join(',');
|
|
665
|
+
const ticks = niceTicks(max).map(v => `<span style="left:${Math.sqrt(v / max) * 100}%">${v === max ? '≥ ' : ''}${m === 'ex' && v ? '+' : ''}${v ? nf(v, v < 1 ? 1 : v < 10 && v % 1 ? 1 : 0) : '0'}</span>`).join('');
|
|
666
|
+
const txt = m === 'oph' ? l.oph(noun(false), st.per10) : m === 'opp' ? l.opp(noun(false)) : l.ex(noun(false)) + (st.band === 'evening' || (!st.band && st.hour >= REFH[0] && st.hour < REFH[1]) ? l.evening : '');
|
|
667
|
+
$('legend').innerHTML = `<div class="ramp"><div class="bar" style="background:linear-gradient(90deg,${bar})"></div><div class="tk">${ticks}</div></div>` +
|
|
668
|
+
`<span class="txt">${txt}</span><span><i class="sw" style="background:var(--null)"></i>${l.nodata}</span>` +
|
|
669
|
+
(st.stops ? '' : `<span><i class="sw" style="background:var(--masked)"></i>${l.masked}</span>`) +
|
|
670
|
+
(hotOfDir().length && isMain() ? `<span><i class="hk"></i>${l.hot}</span>` : '');
|
|
671
|
+
$('mxlegend').innerHTML = $('legend').innerHTML;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// ---------------------------------------------------------------- hotspots list
|
|
675
|
+
function drawHotList() {
|
|
676
|
+
const l = tx(), E = entry(), hs = hotOfDir();
|
|
677
|
+
$('hsnote').textContent = l.hsnote(noun(false), nf(TICK, 0));
|
|
678
|
+
if (!isMain()) { $('hslist').innerHTML = `<p class="empty">${l.hsalt}</p>`; return; }
|
|
679
|
+
if (!hs.length) { $('hslist').innerHTML = `<p class="empty">${D.hotspots_status === 'not_computed' ? l.hsnot : l.hsnone}</p>`; return; }
|
|
680
|
+
$('hslist').innerHTML = hs.map(h => {
|
|
681
|
+
const v = h.excess_per_passage, lo = h.ci_lo, hi = h.ci_hi;
|
|
682
|
+
const meta = [l.zone[h.zone] || h.zone, l.kind[h.kind] || h.kind, runsLabel(h.hours || [])].filter(Boolean).join(' · ');
|
|
683
|
+
const ci = lo != null && hi != null ? l.ci(signed(lo * TICK, 0) + ' s', signed(hi * TICK, 0) + ' s') : '';
|
|
684
|
+
const per = h.persistence != null ? l.daysPct(nf(h.persistence * 100, 0)) : '';
|
|
685
|
+
return `<button type="button" class="hs${st.hot === h.rank ? ' on' : ''}" data-r="${h.rank}"><span class="n">${h.rank}</span>
|
|
686
|
+
<div><div class="nm">${esc(h.from_stop_name)} <span class="ar">→</span> ${esc(h.to_stop_name)}</div>
|
|
687
|
+
<div class="meta">${esc(meta)}</div><div class="meta">${[ci, per].filter(Boolean).join(' · ')}</div></div>
|
|
688
|
+
<div class="v">${v == null ? '—' : signed(v, 1)}<small>${l.perveh(noun(false))}</small><small>${v == null ? '' : l.secs(signed(v * TICK, 0))}</small></div></button>`;
|
|
689
|
+
}).join('');
|
|
690
|
+
const n = hotAll().length;
|
|
691
|
+
if (n > HOT_TOP) $('hslist').innerHTML += `<button type="button" class="btn" id="hsmore" style="margin:8px 0 4px">${st.hotAll ? l.hsless : l.hsmore(n)}</button>`;
|
|
692
|
+
$('hslist').querySelectorAll('.hs').forEach(b => b.onclick = () => selectHot(+b.dataset.r));
|
|
693
|
+
const mb = $('hsmore'); if (mb) mb.onclick = () => { st.hotAll = !st.hotAll; render(); };
|
|
694
|
+
}
|
|
695
|
+
function selectHot(r) {
|
|
696
|
+
st.hot = st.hot === r ? null : r;
|
|
697
|
+
const h = hotOfDir().find(x => x.rank === st.hot);
|
|
698
|
+
if (h && h.peak_hour != null && h.peak_hour >= 0 && D.hours.includes(h.peak_hour)) { stop(); st.band = null; st.hour = h.peak_hour; }
|
|
699
|
+
render();
|
|
700
|
+
if (h && map) { const E = entry(), idx = hotSegIdx(E, h); const pts = idx.flatMap(i => E.seg.c[i].map(([x, y]) => [y, x])); if (pts.length) map.fitBounds(window.L.latLngBounds(pts), {maxZoom: 16, padding: [80, 80]}); }
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// ---------------------------------------------------------------- matrix (hours x micro-segments)
|
|
704
|
+
let MX = null;
|
|
705
|
+
function drawMatrix() {
|
|
706
|
+
const E = entry(), svg = $('mx'), l = tx();
|
|
707
|
+
if (!E) { svg.innerHTML = ''; return; }
|
|
708
|
+
const W = svg.clientWidth || 800, m = {l: 44, r: 8, t: 8, b: 90};
|
|
709
|
+
const card = $('mxcard'), full = card.classList.contains('full');
|
|
710
|
+
const RH = full ? Math.max(18, Math.floor((window.innerHeight - 220) / D.hours.length)) : 22;
|
|
711
|
+
const H = m.t + D.hours.length * RH + m.b, Ltot = E.stops.length ? E.stops[E.stops.length - 1].x : 1, max = scaleMax();
|
|
712
|
+
const x = u => m.l + u / Ltot * (W - m.l - m.r);
|
|
713
|
+
svg.setAttribute('height', H);
|
|
714
|
+
let s = '';
|
|
715
|
+
const rows = [];
|
|
716
|
+
D.hours.forEach((h, r) => {
|
|
717
|
+
const v = vals(E, String(h)), y = m.t + r * RH; rows.push(v);
|
|
718
|
+
s += `<rect class="mxrow" data-h="${h}" x="${m.l}" y="${y}" width="${W - m.l - m.r}" height="${RH}" fill="#0b1224"/>`;
|
|
719
|
+
v.forEach(b => { s += `<rect x="${x(b.x0).toFixed(1)}" y="${y + .5}" width="${Math.max(.6, x(b.x0 + b.len) - x(b.x0) - .3).toFixed(1)}" height="${RH - 1}" fill="${colorFor(b, max)}" pointer-events="none"/>`; });
|
|
720
|
+
s += `<text x="${m.l - 6}" y="${y + RH / 2 + 4}" text-anchor="end" fill="var(--ink-2)" font-size="12" font-family="var(--mono)">${h} h</text>`;
|
|
721
|
+
});
|
|
722
|
+
for (const t of E.stops) s += `<line x1="${x(t.x)}" x2="${x(t.x)}" y1="${m.t}" y2="${m.t + D.hours.length * RH}" stroke="#0b1224" stroke-width="1.5" pointer-events="none"/>`;
|
|
723
|
+
// stop names, thinned so they do not overlap
|
|
724
|
+
let last = -1e9;
|
|
725
|
+
for (const t of E.stops) { const xx = x(t.x); if (xx - last < 13) continue; last = xx;
|
|
726
|
+
s += `<text x="${xx}" y="${H - m.b + 14}" transform="rotate(-40 ${xx} ${H - m.b + 14})" text-anchor="end" fill="var(--ink-2)" font-size="12" font-family="var(--sans)">${esc(t.name)}</text>`; }
|
|
727
|
+
if (isMain()) for (const h of hotOfDir()) for (const [a, b] of hourRuns(h.hours || [])) {
|
|
728
|
+
const r0 = D.hours.indexOf(a), r1 = D.hours.indexOf(b - 1); if (r0 < 0 || r1 < 0) continue;
|
|
729
|
+
const on = st.hot === h.rank;
|
|
730
|
+
s += `<rect x="${x(h.x0_m) - 1}" y="${m.t + r0 * RH}" width="${Math.max(3, x(h.x1_m) - x(h.x0_m) + 2)}" height="${(r1 - r0 + 1) * RH}" fill="none" stroke="${on ? '#fff' : '#ffe600'}" stroke-width="${on ? 2.5 : 1.5}" pointer-events="none"/>`;
|
|
731
|
+
}
|
|
732
|
+
if (!st.band && D.hours.includes(st.hour)) s += `<rect x="${m.l - 1}" y="${m.t + D.hours.indexOf(st.hour) * RH}" width="${W - m.l - m.r + 2}" height="${RH}" fill="none" stroke="#eef1f6" stroke-width="1.5" pointer-events="none"/>`;
|
|
733
|
+
else if (st.band) { const hs = D.hours.filter(h => h >= BANDDEF[st.band][0] && h < BANDDEF[st.band][1]); if (hs.length) s += `<rect x="${m.l - 1}" y="${m.t + D.hours.indexOf(hs[0]) * RH}" width="${W - m.l - m.r + 2}" height="${hs.length * RH}" fill="none" stroke="#eef1f6" stroke-width="1.5" stroke-dasharray="4 3" pointer-events="none"/>`; }
|
|
734
|
+
svg.innerHTML = s;
|
|
735
|
+
MX = {E, rows, m, RH, W, Ltot};
|
|
736
|
+
const what = l.metrics[st.metric].toLowerCase() + (st.metric === 'oph' && st.per10 ? ' (10 m)' : '');
|
|
737
|
+
$('mxsub').textContent = l.mxsub(E.from, E.to, daysLabel(), what, st.stops);
|
|
738
|
+
}
|
|
739
|
+
$('mx').addEventListener('mousemove', e => {
|
|
740
|
+
if (!MX) return;
|
|
741
|
+
const r = $('mx').getBoundingClientRect(), px = e.clientX - r.left, py = e.clientY - r.top, {m, RH, W, Ltot, E, rows} = MX;
|
|
742
|
+
const row = Math.floor((py - m.t) / RH), u = (px - m.l) / (W - m.l - m.r) * Ltot;
|
|
743
|
+
if (row < 0 || row >= rows.length || u < 0 || u > Ltot) return hideTip();
|
|
744
|
+
const v = rows[row]; let i = v.findIndex(b => u >= b.x0 && u < b.x0 + b.len); if (i < 0) i = v.length - 1;
|
|
745
|
+
showTip(e, `<span style="font-family:var(--mono)">${D.hours[row]} h</span> · ` + tipHtml(E, v[i]));
|
|
746
|
+
});
|
|
747
|
+
$('mx').addEventListener('mouseleave', hideTip);
|
|
748
|
+
$('mx').addEventListener('click', e => { const g = e.target.closest('.mxrow'); if (g) { stop(); st.band = null; st.hour = +g.dataset.h; render(); } });
|
|
749
|
+
|
|
750
|
+
// ---------------------------------------------------------------- profile along the line
|
|
751
|
+
function drawProfile() {
|
|
752
|
+
const E = entry(), svg = $('prof'), l = tx();
|
|
753
|
+
if (!E) { svg.innerHTML = ''; return; }
|
|
754
|
+
const W = svg.clientWidth || 800, H = 300, m = {l: 48, r: 12, t: 18, b: 74};
|
|
755
|
+
const Ltot = E.stops.length ? E.stops[E.stops.length - 1].x : 1, v = vals(E), max = scaleMax();
|
|
756
|
+
const evening = st.band !== 'evening' && st.metric !== 'ex' ? vals(E, 'evening') : null;
|
|
757
|
+
const YM = max;
|
|
758
|
+
const x = u => m.l + u / Ltot * (W - m.l - m.r), y = u => m.t + (1 - Math.min(Math.max(u, 0), YM) / YM) * (H - m.t - m.b);
|
|
759
|
+
let s = '<g class="axis">';
|
|
760
|
+
const e10 = Math.pow(10, Math.floor(Math.log10(YM / 5))), step = [1, 2, 2.5, 5, 10].map(f => f * e10).find(v => YM / v <= 6);
|
|
761
|
+
for (let k = 0; k <= YM + 1e-9; k += step)
|
|
762
|
+
s += `<line x1="${m.l}" x2="${W - m.r}" y1="${y(k)}" y2="${y(k)}" stroke-dasharray="${k ? '2 4' : ''}"/><text x="${m.l - 6}" y="${y(k) + 4}" text-anchor="end">${nf(k, k < 1 || k % 1 ? 1 : 0)}</text>`;
|
|
763
|
+
s += '</g>';
|
|
764
|
+
let last = -1e9;
|
|
765
|
+
for (const t of E.stops) {
|
|
766
|
+
const xx = x(t.x);
|
|
767
|
+
s += `<line x1="${xx}" x2="${xx}" y1="${m.t}" y2="${H - m.b}" stroke="#eef1f6" stroke-opacity=".15"/>`;
|
|
768
|
+
if (xx - last >= 13) { last = xx; s += `<text x="${xx}" y="${H - m.b + 14}" transform="rotate(-40 ${xx} ${H - m.b + 14})" text-anchor="end" fill="var(--ink-3)" font-size="10" font-family="var(--sans)">${esc(t.name)}</text>`; }
|
|
769
|
+
}
|
|
770
|
+
v.forEach(b => { const u = metricVal(b); if (u != null && shown(b)) s += `<rect x="${x(b.x0).toFixed(1)}" y="${y(u).toFixed(1)}" width="${Math.max(1, x(b.x0 + b.len) - x(b.x0) - .5).toFixed(1)}" height="${(y(0) - y(u)).toFixed(1)}" fill="${seqColor(u, max)}"/>`; });
|
|
771
|
+
if (evening) { let pth = ''; evening.forEach(b => { const u = metricVal(b); if (u != null && shown(b)) pth += `M${x(b.x0).toFixed(1)},${y(u).toFixed(1)}H${x(b.x0 + b.len).toFixed(1)}`; });
|
|
772
|
+
s += `<path d="${pth}" stroke="#eef1f6" stroke-width="1.5" fill="none" opacity=".7"/>`; }
|
|
773
|
+
s += `<text x="${m.l}" y="${m.t - 6}" fill="var(--ink-3)" font-size="11" font-family="var(--mono)">${esc(l.metrics[st.metric].toLowerCase())}${st.metric === 'oph' && st.per10 ? ' (10 m)' : ''}</text>`;
|
|
774
|
+
svg.innerHTML = s;
|
|
775
|
+
svg.onmousemove = e => { const r = svg.getBoundingClientRect(), u = (e.clientX - r.left - m.l) / (W - m.l - m.r) * Ltot; if (u < 0 || u > Ltot) return hideTip();
|
|
776
|
+
let i = v.findIndex(b => u >= b.x0 && u < b.x0 + b.len); if (i < 0) i = v.length - 1; showTip(e, tipHtml(E, v[i])); };
|
|
777
|
+
svg.onmouseleave = hideTip;
|
|
778
|
+
$('profsub').textContent = l.profsub(E.from, E.to, whenLabel(), !!evening);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// ---------------------------------------------------------------- coverage calendar
|
|
782
|
+
function drawCalendar() {
|
|
783
|
+
const l = tx(), cv = D.coverage || {dates: [], c: [], inc: []}, svg = $('cal');
|
|
784
|
+
const dates = cv.dates || [];
|
|
785
|
+
if (!dates.length) { svg.innerHTML = ''; $('covsub').textContent = ''; return; }
|
|
786
|
+
const exc = Object.fromEntries((D.period.excluded || []).map(e => [e.date, e.reason]));
|
|
787
|
+
const dayIdx = D.hours.map((h, j) => [h, j]).filter(([h]) => h >= DAYH[0] && h < DAYH[1]).map(([, j]) => j);
|
|
788
|
+
const t0 = new Date(dates[0] + 'T12:00:00Z'), dow0 = (t0.getUTCDay() + 6) % 7;
|
|
789
|
+
const C = 15, G = 3, top = 16, left = 26;
|
|
790
|
+
const weekOf = s => Math.floor(((new Date(s + 'T12:00:00Z') - t0) / 864e5 + dow0) / 7);
|
|
791
|
+
const nW = weekOf(dates[dates.length - 1]) + 1;
|
|
792
|
+
let s = '';
|
|
793
|
+
for (let w = 0; w < 7; w += 2) s += `<text x="0" y="${top + w * (C + G) + C - 3}">${l.dow[w]}</text>`;
|
|
794
|
+
let lastMonth = -1;
|
|
795
|
+
dates.forEach((d, k) => {
|
|
796
|
+
const dt = new Date(d + 'T12:00:00Z'), w = (dt.getUTCDay() + 6) % 7, col = weekOf(d);
|
|
797
|
+
if (dt.getUTCMonth() !== lastMonth && w <= 3) { lastMonth = dt.getUTCMonth(); s += `<text x="${left + col * (C + G)}" y="10">${dt.toLocaleDateString(LOC[st.lang], {month: 'short', timeZone: 'UTC'})}</text>`; }
|
|
798
|
+
const row = (cv.c || [])[k] || [], vs = dayIdx.map(j => row[j]).filter(v => v != null);
|
|
799
|
+
const mc = vs.length ? vs.reduce((a, b) => a + b, 0) / dayIdx.length : null;
|
|
800
|
+
const inc = cv.inc ? cv.inc[k] : (exc[d] ? exc[d] : true);
|
|
801
|
+
const selected = st.days.includes(w) || !PRESENT.includes(w) && inc !== true;
|
|
802
|
+
let fill, status;
|
|
803
|
+
if (inc === true) { fill = st.days.includes(w) ? colorOf(14 + 8 * (mc == null ? 1 : mc)) : '#24324f'; status = st.days.includes(w) ? l.cal.inc : l.cal.off; }
|
|
804
|
+
else if (inc === 'not_selected') { fill = '#16223d'; status = l.cal.off; }
|
|
805
|
+
else { fill = inc === 'no_data' ? '#3a4560' : '#c2414b'; status = `${l.cal.exc} : ${l.reasons[inc] || inc}`; }
|
|
806
|
+
const tip = `<b>${esc(fmtDate(d, {weekday: 'long', day: 'numeric', month: 'long', year: 'numeric'}))}</b><br>${esc(status)}${mc != null ? '<br>' + esc(l.covday(nf(mc * 100, 0))) : ''}`;
|
|
807
|
+
s += `<rect x="${left + col * (C + G)}" y="${top + w * (C + G)}" width="${C}" height="${C}" rx="3" fill="${fill}" data-tip="${esc(tip)}" opacity="${selected ? 1 : .55}"/>`;
|
|
808
|
+
});
|
|
809
|
+
svg.setAttribute('width', left + nW * (C + G)); svg.setAttribute('height', top + 7 * (C + G));
|
|
810
|
+
svg.innerHTML = s;
|
|
811
|
+
svg.querySelectorAll('rect').forEach(r => { r.onmousemove = e => showTip(e, r.dataset.tip); r.onmouseleave = hideTip; });
|
|
812
|
+
const nx = (D.period.excluded || []).length;
|
|
813
|
+
$('covsub').textContent = l.covsub(D.period.days, nx);
|
|
814
|
+
$('calleg').innerHTML = [[colorOf(22), l.cal.inc], ['#c2414b', l.cal.exc], ['#24324f', l.cal.off], ['#3a4560', l.cal.none]]
|
|
815
|
+
.map(([c, t]) => `<span><i class="sw" style="background:${c};width:12px;height:12px"></i>${esc(t)}</span>`).join('');
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// ---------------------------------------------------------------- render
|
|
819
|
+
function pressed(el, f) { el.querySelectorAll('button').forEach(b => b.setAttribute('aria-pressed', f(b.dataset.k))); }
|
|
820
|
+
function render() {
|
|
821
|
+
pressed($('langs'), k => k === st.lang);
|
|
822
|
+
pressed($('days'), k => st.days.includes(+k));
|
|
823
|
+
pressed($('dirs'), k => +k === st.dir); pressed($('mxdirs'), k => +k === st.dir);
|
|
824
|
+
pressed($('bands'), k => k === st.band);
|
|
825
|
+
pressed($('metric'), k => k === st.metric);
|
|
826
|
+
pressed($('unit'), k => (k === 'm10') === st.per10);
|
|
827
|
+
$('g_unit').hidden = st.metric !== 'oph';
|
|
828
|
+
pressed($('stopsT'), k => k === String(st.stops));
|
|
829
|
+
const d = dirOf(); if (!$('g_ver').hidden && d) pressed($('vers'), k => k === (st.ver || d.pattern_uid));
|
|
830
|
+
$('clock').innerHTML = st.band ? `${BANDDEF[st.band][0]}–${BANDDEF[st.band][1]} h<small>${tx().mean}, ${esc(daysLabel())}</small>` : `${String(st.hour).padStart(2, '0')}:00<small>${esc(daysLabel())}</small>`;
|
|
831
|
+
hourEl.value = st.hour;
|
|
832
|
+
legend(recolor()); drawHotMap(); drawHotList(); drawMatrix(); drawProfile(); drawCalendar();
|
|
833
|
+
}
|
|
834
|
+
function drawAll() { buildVersions(); drawMap(); render(); }
|
|
835
|
+
let timer = null;
|
|
836
|
+
function stop() { clearInterval(timer); timer = null; const b = $('play'); b.textContent = tx().play; b.setAttribute('aria-pressed', 'false'); }
|
|
837
|
+
hourEl.addEventListener('input', e => { stop(); st.band = null; st.hour = +e.target.value; render(); });
|
|
838
|
+
$('play').addEventListener('click', () => {
|
|
839
|
+
if (timer) return stop();
|
|
840
|
+
const b = $('play'); b.textContent = tx().pause; b.setAttribute('aria-pressed', 'true');
|
|
841
|
+
const H0 = D.hours[0], H1 = D.hours[D.hours.length - 1];
|
|
842
|
+
if (st.band || st.hour >= H1) st.hour = H0;
|
|
843
|
+
st.band = null; render();
|
|
844
|
+
timer = setInterval(() => { st.hour = st.hour >= H1 ? H0 : st.hour + 1; render(); }, 900);
|
|
845
|
+
});
|
|
846
|
+
window.addEventListener('resize', () => { if (map) map.invalidateSize(); drawProfile(); drawMatrix(); });
|
|
847
|
+
// Full screen: the card becomes a fixed overlay (works everywhere), plus the browser's full screen when available.
|
|
848
|
+
const mxCard = $('mxcard'), mxBtn = $('mxfull');
|
|
849
|
+
function setFull(on) {
|
|
850
|
+
mxCard.classList.toggle('full', on); document.body.classList.toggle('mxopen', on);
|
|
851
|
+
mxBtn.textContent = on ? tx().unfull : tx().full; mxBtn.setAttribute('aria-pressed', on);
|
|
852
|
+
if (on && mxCard.requestFullscreen) mxCard.requestFullscreen().catch(() => {});
|
|
853
|
+
if (!on && document.fullscreenElement) document.exitFullscreen().catch(() => {});
|
|
854
|
+
requestAnimationFrame(drawMatrix);
|
|
855
|
+
}
|
|
856
|
+
mxBtn.onclick = () => setFull(!mxCard.classList.contains('full'));
|
|
857
|
+
document.addEventListener('fullscreenchange', () => { if (!document.fullscreenElement && mxCard.classList.contains('full')) setFull(false); else drawMatrix(); });
|
|
858
|
+
document.addEventListener('keydown', e => { if (e.key === 'Escape' && mxCard.classList.contains('full')) setFull(false); });
|
|
859
|
+
|
|
860
|
+
buildControls(); texts(); tiles(); initMap(); drawAll();
|
|
861
|
+
</script>
|
|
862
|
+
</body>
|
|
863
|
+
</html>
|