microsegments 0.2.2__tar.gz → 0.2.3__tar.gz

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.
Files changed (104) hide show
  1. {microsegments-0.2.2 → microsegments-0.2.3}/PKG-INFO +1 -1
  2. {microsegments-0.2.2 → microsegments-0.2.3}/src/microsegments/_version.py +2 -2
  3. {microsegments-0.2.2 → microsegments-0.2.3}/src/microsegments/html/export.py +10 -4
  4. {microsegments-0.2.2 → microsegments-0.2.3}/src/microsegments/html/template.html +135 -52
  5. {microsegments-0.2.2 → microsegments-0.2.3}/src/microsegments/plot.py +22 -8
  6. {microsegments-0.2.2 → microsegments-0.2.3}/.github/workflows/publish.yml +0 -0
  7. {microsegments-0.2.2 → microsegments-0.2.3}/.github/workflows/test.yml +0 -0
  8. {microsegments-0.2.2 → microsegments-0.2.3}/.gitignore +0 -0
  9. {microsegments-0.2.2 → microsegments-0.2.3}/LICENSE +0 -0
  10. {microsegments-0.2.2 → microsegments-0.2.3}/README.md +0 -0
  11. {microsegments-0.2.2 → microsegments-0.2.3}/docs/report.jpg +0 -0
  12. {microsegments-0.2.2 → microsegments-0.2.3}/examples/gtfsrt_csv.toml +0 -0
  13. {microsegments-0.2.2 → microsegments-0.2.3}/examples/make_synth.py +0 -0
  14. {microsegments-0.2.2 → microsegments-0.2.3}/examples/stib1_platform.toml +0 -0
  15. {microsegments-0.2.2 → microsegments-0.2.3}/examples/stib55.toml +0 -0
  16. {microsegments-0.2.2 → microsegments-0.2.3}/examples/stib55_full.toml +0 -0
  17. {microsegments-0.2.2 → microsegments-0.2.3}/examples/stib71_platform.toml +0 -0
  18. {microsegments-0.2.2 → microsegments-0.2.3}/examples/synth.toml +0 -0
  19. {microsegments-0.2.2 → microsegments-0.2.3}/pyproject.toml +0 -0
  20. {microsegments-0.2.2 → microsegments-0.2.3}/src/microsegments/__init__.py +0 -0
  21. {microsegments-0.2.2 → microsegments-0.2.3}/src/microsegments/aggregate.py +0 -0
  22. {microsegments-0.2.2 → microsegments-0.2.3}/src/microsegments/cli.py +0 -0
  23. {microsegments-0.2.2 → microsegments-0.2.3}/src/microsegments/compare.py +0 -0
  24. {microsegments-0.2.2 → microsegments-0.2.3}/src/microsegments/config.py +0 -0
  25. {microsegments-0.2.2 → microsegments-0.2.3}/src/microsegments/contract.py +0 -0
  26. {microsegments-0.2.2 → microsegments-0.2.3}/src/microsegments/hotspots.py +0 -0
  27. {microsegments-0.2.2 → microsegments-0.2.3}/src/microsegments/html/__init__.py +0 -0
  28. {microsegments-0.2.2 → microsegments-0.2.3}/src/microsegments/html/leaflet.min.css +0 -0
  29. {microsegments-0.2.2 → microsegments-0.2.3}/src/microsegments/io/__init__.py +0 -0
  30. {microsegments-0.2.2 → microsegments-0.2.3}/src/microsegments/io/coverage.py +0 -0
  31. {microsegments-0.2.2 → microsegments-0.2.3}/src/microsegments/io/events.py +0 -0
  32. {microsegments-0.2.2 → microsegments-0.2.3}/src/microsegments/io/gtfsrt.py +0 -0
  33. {microsegments-0.2.2 → microsegments-0.2.3}/src/microsegments/io/ids.py +0 -0
  34. {microsegments-0.2.2 → microsegments-0.2.3}/src/microsegments/io/stib.py +0 -0
  35. {microsegments-0.2.2 → microsegments-0.2.3}/src/microsegments/io/tabular.py +0 -0
  36. {microsegments-0.2.2 → microsegments-0.2.3}/src/microsegments/io/timeutil.py +0 -0
  37. {microsegments-0.2.2 → microsegments-0.2.3}/src/microsegments/locate/__init__.py +0 -0
  38. {microsegments-0.2.2 → microsegments-0.2.3}/src/microsegments/locate/common.py +0 -0
  39. {microsegments-0.2.2 → microsegments-0.2.3}/src/microsegments/locate/linear.py +0 -0
  40. {microsegments-0.2.2 → microsegments-0.2.3}/src/microsegments/locate/mapmatch.py +0 -0
  41. {microsegments-0.2.2 → microsegments-0.2.3}/src/microsegments/locate/resample.py +0 -0
  42. {microsegments-0.2.2 → microsegments-0.2.3}/src/microsegments/metrics.py +0 -0
  43. {microsegments-0.2.2 → microsegments-0.2.3}/src/microsegments/network/__init__.py +0 -0
  44. {microsegments-0.2.2 → microsegments-0.2.3}/src/microsegments/network/calendar.py +0 -0
  45. {microsegments-0.2.2 → microsegments-0.2.3}/src/microsegments/network/geometry.py +0 -0
  46. {microsegments-0.2.2 → microsegments-0.2.3}/src/microsegments/network/keys.py +0 -0
  47. {microsegments-0.2.2 → microsegments-0.2.3}/src/microsegments/network/patterns.py +0 -0
  48. {microsegments-0.2.2 → microsegments-0.2.3}/src/microsegments/pipeline.py +0 -0
  49. {microsegments-0.2.2 → microsegments-0.2.3}/src/microsegments/py.typed +0 -0
  50. {microsegments-0.2.2 → microsegments-0.2.3}/src/microsegments/report.py +0 -0
  51. {microsegments-0.2.2 → microsegments-0.2.3}/src/microsegments/scale.py +0 -0
  52. {microsegments-0.2.2 → microsegments-0.2.3}/src/microsegments/schema.py +0 -0
  53. {microsegments-0.2.2 → microsegments-0.2.3}/src/microsegments/segments.py +0 -0
  54. {microsegments-0.2.2 → microsegments-0.2.3}/src/microsegments/simulate.py +0 -0
  55. {microsegments-0.2.2 → microsegments-0.2.3}/src/microsegments/tracks.py +0 -0
  56. {microsegments-0.2.2 → microsegments-0.2.3}/src/microsegments/tune.py +0 -0
  57. {microsegments-0.2.2 → microsegments-0.2.3}/tests/fixtures/stib55/golden/buckets.parquet +0 -0
  58. {microsegments-0.2.2 → microsegments-0.2.3}/tests/fixtures/stib55/golden/drops.parquet +0 -0
  59. {microsegments-0.2.2 → microsegments-0.2.3}/tests/fixtures/stib55/golden/flen.parquet +0 -0
  60. {microsegments-0.2.2 → microsegments-0.2.3}/tests/fixtures/stib55/golden/obs_day.parquet +0 -0
  61. {microsegments-0.2.2 → microsegments-0.2.3}/tests/fixtures/stib55/golden/obs_dow.parquet +0 -0
  62. {microsegments-0.2.2 → microsegments-0.2.3}/tests/fixtures/stib55/golden/passages.parquet +0 -0
  63. {microsegments-0.2.2 → microsegments-0.2.3}/tests/fixtures/stib55/golden/summary.json +0 -0
  64. {microsegments-0.2.2 → microsegments-0.2.3}/tests/fixtures/stib55/golden/validation.parquet +0 -0
  65. {microsegments-0.2.2 → microsegments-0.2.3}/tests/fixtures/stib55/gtfs/agency.parquet +0 -0
  66. {microsegments-0.2.2 → microsegments-0.2.3}/tests/fixtures/stib55/gtfs/calendar.parquet +0 -0
  67. {microsegments-0.2.2 → microsegments-0.2.3}/tests/fixtures/stib55/gtfs/calendar_dates.parquet +0 -0
  68. {microsegments-0.2.2 → microsegments-0.2.3}/tests/fixtures/stib55/gtfs/routes.parquet +0 -0
  69. {microsegments-0.2.2 → microsegments-0.2.3}/tests/fixtures/stib55/gtfs/shapes.parquet +0 -0
  70. {microsegments-0.2.2 → microsegments-0.2.3}/tests/fixtures/stib55/gtfs/stop_times.parquet +0 -0
  71. {microsegments-0.2.2 → microsegments-0.2.3}/tests/fixtures/stib55/gtfs/stops.parquet +0 -0
  72. {microsegments-0.2.2 → microsegments-0.2.3}/tests/fixtures/stib55/gtfs/trips.parquet +0 -0
  73. {microsegments-0.2.2 → microsegments-0.2.3}/tests/fixtures/stib55/punctuality/route55.parquet +0 -0
  74. {microsegments-0.2.2 → microsegments-0.2.3}/tests/fixtures/stib55/speed55_ref.json +0 -0
  75. {microsegments-0.2.2 → microsegments-0.2.3}/tests/fixtures/stib55/vd55/20250318.parquet +0 -0
  76. {microsegments-0.2.2 → microsegments-0.2.3}/tests/fixtures/stib55/vd55/20250318.snaps.parquet +0 -0
  77. {microsegments-0.2.2 → microsegments-0.2.3}/tests/fixtures/stib55/vd55/20250319.parquet +0 -0
  78. {microsegments-0.2.2 → microsegments-0.2.3}/tests/fixtures/stib55/vd55/20250319.snaps.parquet +0 -0
  79. {microsegments-0.2.2 → microsegments-0.2.3}/tests/gtfs_fixtures.py +0 -0
  80. {microsegments-0.2.2 → microsegments-0.2.3}/tests/regression/make_golden.py +0 -0
  81. {microsegments-0.2.2 → microsegments-0.2.3}/tests/t4_fixtures.py +0 -0
  82. {microsegments-0.2.2 → microsegments-0.2.3}/tests/test_aggregate.py +0 -0
  83. {microsegments-0.2.2 → microsegments-0.2.3}/tests/test_compare.py +0 -0
  84. {microsegments-0.2.2 → microsegments-0.2.3}/tests/test_coverage.py +0 -0
  85. {microsegments-0.2.2 → microsegments-0.2.3}/tests/test_hotspots.py +0 -0
  86. {microsegments-0.2.2 → microsegments-0.2.3}/tests/test_hotspots_criteria.py +0 -0
  87. {microsegments-0.2.2 → microsegments-0.2.3}/tests/test_io_events.py +0 -0
  88. {microsegments-0.2.2 → microsegments-0.2.3}/tests/test_io_gtfsrt.py +0 -0
  89. {microsegments-0.2.2 → microsegments-0.2.3}/tests/test_io_stib.py +0 -0
  90. {microsegments-0.2.2 → microsegments-0.2.3}/tests/test_io_tabular.py +0 -0
  91. {microsegments-0.2.2 → microsegments-0.2.3}/tests/test_locate_linear.py +0 -0
  92. {microsegments-0.2.2 → microsegments-0.2.3}/tests/test_locate_mapmatch.py +0 -0
  93. {microsegments-0.2.2 → microsegments-0.2.3}/tests/test_metrics.py +0 -0
  94. {microsegments-0.2.2 → microsegments-0.2.3}/tests/test_network_geometry.py +0 -0
  95. {microsegments-0.2.2 → microsegments-0.2.3}/tests/test_network_patterns.py +0 -0
  96. {microsegments-0.2.2 → microsegments-0.2.3}/tests/test_pipeline_cli.py +0 -0
  97. {microsegments-0.2.2 → microsegments-0.2.3}/tests/test_regression_stib55.py +0 -0
  98. {microsegments-0.2.2 → microsegments-0.2.3}/tests/test_report_html.py +0 -0
  99. {microsegments-0.2.2 → microsegments-0.2.3}/tests/test_scaffold.py +0 -0
  100. {microsegments-0.2.2 → microsegments-0.2.3}/tests/test_segments.py +0 -0
  101. {microsegments-0.2.2 → microsegments-0.2.3}/tests/test_simulate.py +0 -0
  102. {microsegments-0.2.2 → microsegments-0.2.3}/tests/test_tracks.py +0 -0
  103. {microsegments-0.2.2 → microsegments-0.2.3}/tests/test_tune.py +0 -0
  104. {microsegments-0.2.2 → microsegments-0.2.3}/tests/test_versions_merge.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: microsegments
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: Where do transit vehicles linger? Count vehicle position observations per micro-segment of a line, from GTFS + AVL / GTFS-RT positions.
5
5
  Project-URL: Homepage, https://github.com/GaspardMerten/microsegments
6
6
  Project-URL: Repository, https://github.com/GaspardMerten/microsegments
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.2.2'
22
- __version_tuple__ = version_tuple = (0, 2, 2)
21
+ __version__ = version = '0.2.3'
22
+ __version_tuple__ = version_tuple = (0, 2, 3)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -49,12 +49,16 @@ def _default_title(c: dict, lang: str) -> str:
49
49
 
50
50
 
51
51
  def render(analysis_or_contract, *, title: str | None = None, lang: str = "fr", tiles: bool = True,
52
- scales: dict | None = None, description: str | None = None, **contract_kw) -> str:
52
+ scales: dict | None = None, description: str | None = None, show_hotspots: bool = True, **contract_kw) -> str:
53
53
  """The full HTML document as a string. ``contract_kw`` go to ``to_contract`` when an Analysis /
54
54
  RunResult is given. The colour scale is fixed for every page (``microsegments.scale``); ``scales`` is
55
- deprecated and ignored."""
55
+ deprecated and ignored. ``show_hotspots=False`` hides every hotspot element of the one-period view
56
+ (list, map markers and outlines, tile, method paragraph); the page reads it from the contract key
57
+ ``show_hotspots``, so a page fed with its data later can set that key instead."""
56
58
  c = dict(_contract(analysis_or_contract, **contract_kw))
57
59
  c["tiles"] = bool(tiles)
60
+ if not show_hotspots:
61
+ c["show_hotspots"] = False
58
62
  if scales:
59
63
  warnings.warn("render(scales=...) is ignored: the colour scale is fixed (microsegments.scale)",
60
64
  DeprecationWarning, stacklevel=2)
@@ -79,11 +83,13 @@ def render(analysis_or_contract, *, title: str | None = None, lang: str = "fr",
79
83
 
80
84
 
81
85
  def export(analysis_or_contract, path: str | Path, *, title: str | None = None, lang: str = "fr",
82
- standalone: bool = True, tiles: bool = True, scales: dict | None = None, **contract_kw) -> Path:
86
+ standalone: bool = True, tiles: bool = True, scales: dict | None = None, show_hotspots: bool = True,
87
+ **contract_kw) -> Path:
83
88
  """Write the report to ``path``. ``analysis_or_contract``: an ``Analysis``, a ``RunResult`` or a
84
89
  contract dict (``report.to_contract``). ``standalone`` (default): a complete HTML document; False:
85
90
  only what goes inside ``<body>`` (head elements included inline), to embed in another page."""
86
- page = render(analysis_or_contract, title=title, lang=lang, tiles=tiles, scales=scales, **contract_kw)
91
+ page = render(analysis_or_contract, title=title, lang=lang, tiles=tiles, scales=scales, show_hotspots=show_hotspots,
92
+ **contract_kw)
87
93
  if not standalone:
88
94
  head = re.search(r"<head>(.*)</head>", page, re.DOTALL).group(1)
89
95
  body = re.search(r"<body>(.*)</body>", page, re.DOTALL).group(1)
@@ -32,6 +32,7 @@ h1 .badge{display:inline-block;background:var(--acc);color:#111;font:700 .62em/1
32
32
  h2{font:700 24px/1.2 var(--serif);margin:0 0 6px}
33
33
  p.lede{font-size:clamp(17px,1.6vw,19px);color:var(--ink-2);max-width:68ch;margin:0}
34
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
+ .tiles.t3{grid-template-columns:repeat(3,1fr)}
35
36
  .tile{background:var(--surface);padding:18px 18px 16px;min-width:0}
36
37
  .tile b{display:block;font:700 clamp(24px,3vw,32px)/1 var(--serif);font-variant-numeric:tabular-nums}
37
38
  .tile span{display:block;margin-top:8px;color:var(--ink-2);font-size:14px;line-height:1.4}
@@ -78,6 +79,15 @@ input[type=range]{width:100%;accent-color:var(--acc)}
78
79
  .legend .ramp .tk span:last-child{transform:translateX(-100%)}
79
80
  .legend .txt{flex:1 1 260px}
80
81
  .sw{display:inline-block;width:14px;height:8px;border-radius:2px;vertical-align:middle;margin-right:5px}
82
+ .sw.hatch{background:repeating-linear-gradient(45deg,#5d6a88 0 2px,#18213a 2px 5px)}
83
+ /* short label + '*' that opens a plain-language explanation (hover, tap, Esc / outside click closes) */
84
+ .qm{font:700 13px/1 var(--sans);color:var(--acc);background:transparent;border:1px solid var(--line);border-radius:50%;width:20px;height:20px;padding:0;margin-left:4px;cursor:pointer;vertical-align:middle}
85
+ .qm:hover,.qm[aria-expanded=true]{border-color:var(--acc)}
86
+ .qm:focus-visible{outline:2px solid var(--acc);outline-offset:2px}
87
+ #pop{position:fixed;z-index:3100;max-width:min(340px,calc(100vw - 24px));background:#0f1830;color:var(--ink);border:1px solid var(--line);border-radius:8px;box-shadow:0 6px 20px rgba(0,0,0,.5);font:13px/1.5 var(--sans);padding:9px 12px}
88
+ body.nohs .grid{grid-template-columns:minmax(0,1fr)}
89
+ body.nohs .grid .side{display:none}
90
+ body.nohs #map{height:min(70vh,640px)}
81
91
  .legend .hk{display:inline-block;width:16px;height:8px;border:2px solid var(--hot);border-radius:2px;vertical-align:middle;margin-right:5px}
82
92
  .side{padding:16px 16px 10px}
83
93
  .side .note{font-size:13px;color:var(--ink-3);margin:0 0 10px}
@@ -114,11 +124,12 @@ svg{display:block}
114
124
  .cal .calleg{display:flex;gap:6px 14px;flex-wrap:wrap;font-size:12px;color:var(--ink-2)}
115
125
  .calwrap{overflow-x:auto;max-width:100%}
116
126
  .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}
117
- @media (max-width:900px){.grid{grid-template-columns:minmax(0,1fr)}#map{height:440px}.tiles{grid-template-columns:repeat(2,1fr)}}
127
+ @media (max-width:900px){.grid{grid-template-columns:minmax(0,1fr)}#map,body.nohs #map{height:440px}.tiles{grid-template-columns:repeat(2,1fr)}.tiles.t3{grid-template-columns:repeat(2,1fr)}.tiles.t3 .tile:last-child{grid-column:1/-1}}
118
128
  @media (max-width:600px){.wrap{padding-left:16px;padding-right:16px}header{padding-top:24px}.clock{font-size:24px;min-width:90px}}
119
129
  </style>
120
130
  </head>
121
131
  <body>
132
+ <svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false"><defs><pattern id="mshatch" patternUnits="userSpaceOnUse" width="6" height="6" patternTransform="rotate(45)"><rect width="6" height="6" fill="#18213a"/><rect width="2.4" height="6" fill="#5d6a88"/></pattern></defs></svg>
122
133
  <header class="wrap">
123
134
  <div class="top"><div class="kicker" id="kicker"></div><div class="seg" id="langs" role="group" aria-label="Language"></div></div>
124
135
  <h1 id="h1"></h1>
@@ -178,6 +189,7 @@ svg{display:block}
178
189
  </main>
179
190
  <footer class="wrap" id="footer"></footer>
180
191
  <div id="tip" role="tooltip"></div>
192
+ <div id="pop" role="tooltip" hidden></div>
181
193
  <script id="ms-data" type="application/json">__DATA__</script>
182
194
  <script>
183
195
  "use strict";
@@ -216,7 +228,7 @@ const T = {
216
228
  towards: t => `vers ${t}`,
217
229
  play: '▶ Journée', pause: '❚❚ Pause', mean: 'moyenne',
218
230
  tiles: {
219
- days: (n, x) => `jours analysés${x ? `, ${x} écartés faute de données fiables` : ''}`,
231
+ days: (n, x) => `jours analysés${x ? ` ; ${x}` : ''}`,
220
232
  segs: (n, L) => `microsegments d'environ ${L} m, dans les deux sens`,
221
233
  obs: 'positions comptées sur la période',
222
234
  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',
@@ -232,12 +244,18 @@ const T = {
232
244
  zone: {stop: 'à l\'arrêt', running: 'entre les arrêts'},
233
245
  perveh: m1 => `obs. par ${m1}`, ci: (a, b) => `IC 95 % : ${a} à ${b}`, daysPct: p => `${p} % des jours`, secs: s => `≈ ${s} s`,
234
246
  legend: {
235
- oph: (m, u) => `Observations par heure de relevés, pour ${u} de voie : combien de fois, en moyenne, un ${m} y est vu en une heure (1 observation ≈ ${TICK} s de présence).`,
236
- opp: (m1, u) => `Temps passé par chaque ${m1}, en secondes pour ${u} de voie (observations par passage × ${TICK} s). Ce sont les couleurs de l'échelle de vitesse de la STIB : 30 m en 4 s ≈ 27 km/h, en 14 s ≈ 7,5 km/h.`,
237
- ex: (m1, u) => `Secondes perdues par chaque ${m1}, pour ${u} de voie, par rapport au soir (${REFH[0]}–${REFH[1]} h, circulation fluide).`,
238
- exl: (m1, u) => `Secondes perdues par chaque ${m1}, pour ${u} de voie, par rapport au niveau habituel de la ligne (la médiane, de 6 à 23 h, des microsegments entre arrêts).`,
239
- same: 'Même échelle sur tout le site.',
240
- 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.',
247
+ oph: (m, u) => `Observations par heure, pour ${u}`,
248
+ opp: (m1, u) => `Secondes par ${m1}, pour ${u}`,
249
+ ex: (m1, u) => `Écart avec le soir, s par ${m1} pour ${u}`,
250
+ exl: (m1, u) => `Écart avec la ligne, s par ${m1} pour ${u}`,
251
+ help: {
252
+ oph: (m1, m) => `Toutes les ${TICK} s, on note où se trouve chaque ${m1}. Ici : combien de fois, en une heure, un ${m1} est vu sur ce bout de voie. Plus il y en a, plus les ${m} y passent de temps (1 observation ≈ ${TICK} s).`,
253
+ opp: (m1, m) => `Toutes les ${TICK} s, on note où se trouve chaque ${m1}. Plus un bout de voie collecte d'observations, plus les ${m} y restent : vert = ça roule, rouge = ça bloque. 1 observation ≈ ${TICK} s.`,
254
+ ex: (m1, m) => `Le temps que chaque ${m1} perd ici par rapport au soir (${REFH[0]}–${REFH[1]} h), quand la circulation est fluide. Vert : comme le soir ; rouge : nettement plus lent.`,
255
+ exl: (m1, m) => `Le temps que chaque ${m1} perd ici par rapport à son allure habituelle sur la ligne, entre deux arrêts. Fait ressortir un feu ou un carrefour lent à toute heure, même le soir.`,
256
+ },
257
+ masked: 'zone d\'arrêt masquée', nodata: 'pas assez de données', hot: 'point chaud', evening: ' (le soir est la référence)',
258
+ what: 'Explication',
241
259
  },
242
260
  tip: {
243
261
  after: (a, b, s) => `${a} à ${b} m après ${s}`, towards: t => `vers ${t}`,
@@ -247,15 +265,19 @@ const T = {
247
265
  exl: (v, s) => `${v} par rapport à la ligne (≈ ${s} s)`, freq: (v, m) => `${v} ${m} par heure`,
248
266
  },
249
267
  mxtitle: 'Toute la journée d\'un coup d\'œil',
250
- 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.`,
268
+ mxsub: (f, t, d, what, stops, hs) => [`De ${f} à ${t}, ${d}.`, `En colonnes, la ligne découpée en microsegments (traits sombres : arrêts) ; en lignes, les heures. Couleur : ${what}, comme sur la carte${stops ? '' : ' ; hachures : zones d\'arrêt masquées'}.${hs ? ' Contours jaunes : points chauds.' : ''} Cliquez une heure pour l'afficher sur la carte.`],
251
269
  full: '⛶ Plein écran', unfull: '✕ Quitter le plein écran',
252
270
  proftitle: 'Le long de la ligne, microsegment par microsegment',
253
- profsub: (f, t, w, ref, m) => `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).` : ''} Pointillés jaunes : ${m} par heure (échelle de droite).`,
271
+ profsub: (f, t, w, ref, m) => [`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).` : ''} Pointillés jaunes : ${m} par heure (échelle de droite).`],
254
272
  perh: m => `${m}/h`,
255
273
  covtitle: 'Les jours retenus',
256
- 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.`,
274
+ covsub: (n, x) => [`${n} jours retenus${x ? ` ; ${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. Survolez une case pour le détail.`],
257
275
  cal: {inc: 'retenu', exc: 'écarté', off: 'hors sélection', none: 'pas de données'},
258
- 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'},
276
+ 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', holiday: 'jour férié', not_selected: 'hors sélection'},
277
+ // excluded days per reason, short (tiles, calendar caption): [one, several]
278
+ xsum: (n, parts) => `${n} ${n > 1 ? 'jours écartés' : 'jour écarté'} : ${parts}`,
279
+ xr: {holiday: ['férié', 'fériés'], feed: ['flux incomplet', 'flux incomplets'], line_absent: ['sans la ligne dans le flux', 'sans la ligne dans le flux'],
280
+ deviation: ['hors itinéraire', 'hors itinéraire'], vehicle_ratio: ['nombre de véhicules anormal', 'nombre de véhicules anormal'], excluded: ['à la demande', 'à la demande']},
259
281
  covday: (c) => `relevés : ${c} % des heures ${DAYH[0]}–${DAYH[1]} h`,
260
282
  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`,
261
283
  mtitle: 'Méthode',
@@ -267,7 +289,7 @@ const T = {
267
289
  `<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.`,
268
290
  `<b>Écart avec la ligne.</b> Une seconde référence : le niveau habituel de la ligne dans ce sens, soit la médiane des observations par passage et par mètre des microsegments entre arrêts, de 6 à 23 h, multipliée par la longueur du microsegment. Elle fait ressortir ce qui ralentit à toute heure, soir compris.${agreeText('fr')}`,
269
291
  `<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 ; les couleurs ne changent pas.`,
270
- `<b>Échelle.</b> Fixe pour tout le site : les mêmes couleurs pour toutes les lignes, périodes, jours, heures et options. Chaque mesure est ramenée à 30 m de voie, puis lue sur l'échelle de vitesse de la STIB, du vert (fluide) au rouge (lent) : un passage de 30 m en 4 s fait 27 km/h (vert foncé), en 14 s, 7,5 km/h (rouge), en 24 s ou plus, 4,5 km/h ou moins (rouge foncé). Les écarts (avec le soir, avec la ligne) partagent la même échelle : un écart de +x s se lit comme un passage fluide de 3 s rallongé de x s, donc 0 s en vert, +10 s en rouge, +21 s ou plus en rouge foncé. Les observations par heure se lisent à raison de 2 s par observation (≈ 10 passages par heure) : 1,5 en vert foncé, 12 ou plus en rouge foncé. Afficher « 10 m » ne change que les nombres (divisés par 3), pas les couleurs. Ces repères viennent des données 2025 de plusieurs lignes de tram et de bus : entre les arrêts, un passage prend en médiane 3 à 4,5 s pour 30 m et, au 98e centile, 12 à 23 s.`,
292
+ `<b>Échelle.</b> Les mêmes couleurs pour toutes les lignes, périodes, jours, heures et options. Chaque mesure est ramenée à 30 m de voie, puis lue sur l'échelle de vitesse de la STIB, du vert (fluide) au rouge (lent) : un passage de 30 m en 4 s fait 27 km/h (vert foncé), en 14 s, 7,5 km/h (rouge), en 24 s ou plus, 4,5 km/h ou moins (rouge foncé). Les écarts (avec le soir, avec la ligne) partagent la même échelle : un écart de +x s se lit comme un passage fluide de 3 s rallongé de x s, donc 0 s en vert, +10 s en rouge, +21 s ou plus en rouge foncé. Les observations par heure se lisent à raison de 2 s par observation (≈ 10 passages par heure) : 1,5 en vert foncé, 12 ou plus en rouge foncé. Afficher « 10 m » ne change que les nombres (divisés par 3), pas les couleurs. Ces repères viennent des données 2025 de plusieurs lignes de tram et de bus : entre les arrêts, un passage prend en médiane 3 à 4,5 s pour 30 m et, au 98e centile, 12 à 23 s.`,
271
293
  `<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.`,
272
294
  `<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.`,
273
295
  `<b>Points chauds.</b> Deux critères. Comparé au soir : à certaines heures (au moins deux de suite, ou toute une pointe), chaque ${m1} est observé plus souvent qu'entre ${REFH[0]} et ${REFH[1]} h. Comparé au reste de la ligne, entre les arrêts seulement : sur toute la journée, soirée comprise, plus d'observations par mètre que le niveau habituel de la ligne dans ce sens (la médiane des microsegments entre arrêts) ; c'est ce qui fait ressortir un feu ou un carrefour lent à toute heure, que la comparaison au soir efface. Dans les deux cas : au moins ${HTH} s de plus par ${m1} avec 95 % de confiance (en rééchantillonnant les jours), un écart positif au moins 60 % des jours, et une correction pour les tests multiples. Les microsegments voisins forment un tronçon ; les tronçons sont classés par observations en plus par jour. « Toute la journée » : le retard ne disparaît pas en soirée ; « aux heures de pointe » : il est surtout là aux pointes ; « mixte » : un peu des deux.`,
@@ -293,7 +315,7 @@ const T = {
293
315
  towards: t => `to ${t}`,
294
316
  play: '▶ Day', pause: '❚❚ Pause', mean: 'average',
295
317
  tiles: {
296
- days: (n, x) => `days analysed${x ? `, ${x} dropped for unreliable data` : ''}`,
318
+ days: (n, x) => `days analysed${x ? `; ${x}` : ''}`,
297
319
  segs: (n, L) => `micro-segments of about ${L} m, both directions`,
298
320
  obs: 'positions counted over the period',
299
321
  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',
@@ -309,12 +331,18 @@ const T = {
309
331
  zone: {stop: 'at a stop', running: 'between stops'},
310
332
  perveh: m1 => `obs. per ${m1}`, ci: (a, b) => `95 % CI: ${a} to ${b}`, daysPct: p => `${p} % of days`, secs: s => `≈ ${s} s`,
311
333
  legend: {
312
- oph: (m, u) => `Observations per hour of polling, per ${u} of track: how many times, on average, a ${m} is seen there in an hour (1 observation ≈ ${TICK} s of presence).`,
313
- opp: (m1, u) => `Time spent by each ${m1}, in seconds per ${u} of track (observations per passage × ${TICK} s). These are the colours of the STIB speed scale: 30 m in 4 s ≈ 27 km/h, in 14 s ≈ 7.5 km/h.`,
314
- ex: (m1, u) => `Seconds lost by each ${m1}, per ${u} of track, compared with the evening (${REFH[0]}–${REFH[1]} h, free-flowing traffic).`,
315
- exl: (m1, u) => `Seconds lost by each ${m1}, per ${u} of track, compared with the line's usual level (the median, from 6 to 23 h, of the micro-segments between stops).`,
316
- same: 'Same scale across the whole site.',
317
- masked: 'stop zone hidden', nodata: 'not enough data', hot: 'hotspot', evening: ' Pick another hour: the evening is the reference.',
334
+ oph: (m, u) => `Observations per hour, per ${u}`,
335
+ opp: (m1, u) => `Seconds per ${m1}, per ${u}`,
336
+ ex: (m1, u) => `Excess over the evening, s per ${m1} per ${u}`,
337
+ exl: (m1, u) => `Excess over the line, s per ${m1} per ${u}`,
338
+ help: {
339
+ oph: (m1, m) => `Every ${TICK} s, we note where each ${m1} is. Here: how many times in an hour a ${m1} is seen on this bit of track. The more, the longer the ${m} spend there (1 observation ≈ ${TICK} s).`,
340
+ opp: (m1, m) => `Every ${TICK} s, we note where each ${m1} is. The more observations a bit of track collects, the longer the ${m} stay there: green = moving, red = stuck. 1 observation ≈ ${TICK} s.`,
341
+ ex: (m1, m) => `The time each ${m1} loses here compared with the evening (${REFH[0]}–${REFH[1]} h), when traffic flows. Green: like the evening; red: clearly slower.`,
342
+ exl: (m1, m) => `The time each ${m1} loses here compared with its usual pace on the line, between stops. Brings out a signal or junction that is slow at every hour, evening included.`,
343
+ },
344
+ masked: 'stop zone hidden', nodata: 'not enough data', hot: 'hotspot', evening: ' (the evening is the reference)',
345
+ what: 'Explanation',
318
346
  },
319
347
  tip: {
320
348
  after: (a, b, s) => `${a} to ${b} m after ${s}`, towards: t => `to ${t}`,
@@ -324,15 +352,18 @@ const T = {
324
352
  exl: (v, s) => `${v} vs the line (≈ ${s} s)`, freq: (v, m) => `${v} ${m} per hour`,
325
353
  },
326
354
  mxtitle: 'The whole day at a glance',
327
- 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.`,
355
+ mxsub: (f, t, d, what, stops, hs) => [`From ${f} to ${t}, ${d}.`, `Columns: the line cut into micro-segments (dark lines: stops); rows: hours. Colour: ${what}, as on the map${stops ? '' : '; hatching: stop zones hidden'}.${hs ? ' Yellow outlines: hotspots.' : ''} Click an hour to show it on the map.`],
328
356
  full: '⛶ Full screen', unfull: '✕ Exit full screen',
329
357
  proftitle: 'Along the line, micro-segment by micro-segment',
330
- profsub: (f, t, w, ref, m) => `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).` : ''} Yellow dots: ${m} per hour (right scale).`,
358
+ profsub: (f, t, w, ref, m) => [`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).` : ''} Yellow dots: ${m} per hour (right scale).`],
331
359
  perh: m => `${m}/h`,
332
360
  covtitle: 'Days used',
333
- 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.`,
361
+ covsub: (n, x) => [`${n} days used${x ? `; ${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. Hover a square for details.`],
334
362
  cal: {inc: 'used', exc: 'dropped', off: 'not selected', none: 'no data'},
335
- 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'},
363
+ 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', holiday: 'public holiday', not_selected: 'not selected'},
364
+ xsum: (n, parts) => `${n} ${n > 1 ? 'days' : 'day'} dropped: ${parts}`,
365
+ xr: {holiday: ['public holiday', 'public holidays'], feed: ['incomplete feed', 'incomplete feed'], line_absent: ['line missing from the feed', 'line missing from the feed'],
366
+ deviation: ['off-route trips', 'off-route trips'], vehicle_ratio: ['abnormal vehicle count', 'abnormal vehicle count'], excluded: ['on request', 'on request']},
336
367
  covday: (c) => `polls: ${c} % of ${DAYH[0]}–${DAYH[1]} h`,
337
368
  vers: (a, b, n) => `${a} – ${b} · ${n} d`, verMain: 'main', verNote: (r, a) => `${r} stretch(es) fewer, ${a} more than the main version`,
338
369
  mtitle: 'Method',
@@ -344,7 +375,7 @@ const T = {
344
375
  `<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.`,
345
376
  `<b>Excess over the line.</b> A second reference: the line's usual level in that direction, the median observations per passage and per metre of the micro-segments between stops, from 6 to 23 h, times the micro-segment's length. It brings out what is slow at every hour, evening included.${agreeText('en')}`,
346
377
  `<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 colours do not change.`,
347
- `<b>Scale.</b> Fixed across the whole site: the same colours for every line, period, day, hour and option. Each measure is brought to 30 m of track, then read on the STIB speed scale, from green (fluid) to red (slow): 30 m in 4 s is 27 km/h (dark green), in 14 s 7.5 km/h (red), in 24 s or more 4.5 km/h or less (dark red). Both excesses (vs the evening, vs the line) share that scale: an excess of +x s reads as a fluid 3 s passage made x s longer, so 0 s is green, +10 s red, +21 s or more dark red. Observations per hour are read at 2 s per observation (≈ 10 passages an hour): 1.5 is dark green, 12 or more dark red. Showing "10 m" only changes the numbers (divided by 3), not the colours. These marks come from 2025 data of several tram and bus lines: between stops a passage takes a median 3 to 4.5 s per 30 m and, at the 98th percentile, 12 to 23 s.`,
378
+ `<b>Scale.</b> The same colours for every line, period, day, hour and option. Each measure is brought to 30 m of track, then read on the STIB speed scale, from green (fluid) to red (slow): 30 m in 4 s is 27 km/h (dark green), in 14 s 7.5 km/h (red), in 24 s or more 4.5 km/h or less (dark red). Both excesses (vs the evening, vs the line) share that scale: an excess of +x s reads as a fluid 3 s passage made x s longer, so 0 s is green, +10 s red, +21 s or more dark red. Observations per hour are read at 2 s per observation (≈ 10 passages an hour): 1.5 is dark green, 12 or more dark red. Showing "10 m" only changes the numbers (divided by 3), not the colours. These marks come from 2025 data of several tram and bus lines: between stops a passage takes a median 3 to 4.5 s per 30 m and, at the 98th percentile, 12 to 23 s.`,
348
379
  `<b>Days.</b> Each value is a ratio of sums over the chosen days. Fewer days means noisier values.`,
349
380
  `<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.`,
350
381
  `<b>Hotspots.</b> Two criteria. Vs the evening: at some hours (at least two in a row, or a whole peak), each ${m1} is observed more often than from ${REFH[0]} to ${REFH[1]} h. Vs the rest of the line, between stops only: over the whole day, evening included, more observations per metre than the line's usual level in that direction (the median of the micro-segments between stops); this brings out a signal or a slow junction present at every hour, which the evening comparison cancels. Both need at least ${HTH} s more per ${m1} with 95 % confidence (resampling days), a positive excess on at least 60 % of days, and a multiple-testing correction. Neighbouring micro-segments form a stretch; stretches are ranked by extra observations per day. "All day": the delay does not go away in the evening; "at peak hours": it is mostly there at peaks; "mixed": a bit of both.`,
@@ -372,14 +403,16 @@ const CT = {
372
403
  perveh: m1 => `s par ${m1}`, per30: m1 => `s par ${m1}, pour 30 m`, perday: v => `${v} min par jour au total`,
373
404
  freq: (a, b, m) => `${a} → ${b} ${m}/h`, freqflag: 'fréquence modifiée : une partie de l\'écart peut venir de l\'horaire',
374
405
  ci: (a, b) => `IC 95 % : ${a} à ${b} s`,
375
- legend: {d: m1 => `Différence de temps passé par ${m1}, en secondes pour 30 m de voie, après moins avant.`,
376
- dx: m1 => `Différence de l'écart avec le soir, en secondes par ${m1} pour 30 m, après moins avant : neutralise un changement du niveau du soir.`,
406
+ legend: {d: m1 => `Différence, s par ${m1} pour 30 m`,
407
+ dx: m1 => `Différence de l'écart au soir, s par ${m1} pour 30 m`,
408
+ help: {d: m1 => `Pour chaque bout de voie : le temps par ${m1} après, moins le temps avant. Vert : plus rapide qu'avant ; rouge : plus lent ; pâle : différence incertaine.`,
409
+ dx: m1 => `Comme la différence, mais calculée sur l'écart avec le soir : si le soir lui-même a changé, ce changement n'est pas compté. Vert : plus rapide ; rouge : plus lent ; pâle : incertain.`},
377
410
  faster: 'plus rapide', slower: 'plus lent', pale: 'pâle : différence incertaine', absent: 'absent avant', chg: 'changement retenu'},
378
411
  tip: {a: (v, s) => `avant : ${v} obs. par passage (≈ ${s} s)`, b: (v, s) => `après : ${v} (≈ ${s} s)`,
379
412
  d: (v, m1) => `<b>${v} s par ${m1}</b>`, unsure: 'différence incertaine', absent: 'absent de la première période',
380
413
  freq: (a, b, m) => `${a} → ${b} ${m} par heure`},
381
- mxsub: (f, t, a, b, m1, stops) => `De ${f} à ${t}. Chaque case : la différence de temps par ${m1} entre ${a} et ${b}, à cette heure. Vert : plus rapide ; rouge : plus lent ; pâle : incertain${stops ? '' : ' ; zones d\'arrêt masquées'}. Contours jaunes : changements retenus. Cliquez une heure pour l'afficher sur la carte.`,
382
- profsub: (f, t, w, m1, m) => `De ${f} à ${t}, ${w}. Au-dessus de zéro, chaque ${m1} met plus de temps qu'avant ; en dessous, moins. Pointillés jaunes : ${m} par heure après (échelle de droite).`,
414
+ mxsub: (f, t, a, b, m1, stops) => [`De ${f} à ${t}.`, `Chaque case : la différence de temps par ${m1} entre ${a} et ${b}, à cette heure. Vert : plus rapide ; rouge : plus lent ; pâle : incertain${stops ? '' : ' ; hachures : zones d\'arrêt masquées'}. Contours jaunes : changements retenus. Cliquez une heure pour l'afficher sur la carte.`],
415
+ profsub: (f, t, w, m1, m) => [`De ${f} à ${t}, ${w}.`, `Au-dessus de zéro, chaque ${m1} met plus de temps qu'avant ; en dessous, moins. Pointillés jaunes : ${m} par heure après (échelle de droite).`],
383
416
  days: 'mêmes jours de semaine que chaque période',
384
417
  method: (m1, m, a, b) => `<b>Comparaison de deux périodes.</b> ${a} (avant) et ${b} (après), chacune sur ses propres jours et avec sa propre référence. Les microsegments sont appariés d'une période à l'autre ; ceux qui n'existent que dans une période sont écartés. La différence porte sur les observations par passage, donc le temps par ${m1}, ce qui la rend indépendante de la fréquence. L'incertitude vient d'un rééchantillonnage des jours de chaque période. Quand la fréquence change de plus de 20 %, le tronçon est signalé : un horaire différent (plus de ${m}, plus de files) peut expliquer une partie de l'écart.`,
385
418
  },
@@ -397,14 +430,16 @@ const CT = {
397
430
  perveh: m1 => `s per ${m1}`, per30: m1 => `s per ${m1}, per 30 m`, perday: v => `${v} min per day in total`,
398
431
  freq: (a, b, m) => `${a} → ${b} ${m}/h`, freqflag: 'frequency changed: part of the difference may come from the timetable',
399
432
  ci: (a, b) => `95 % CI: ${a} to ${b} s`,
400
- legend: {d: m1 => `Difference in time spent per ${m1}, in seconds per 30 m of track, after minus before.`,
401
- dx: m1 => `Difference in the excess over the evening, in seconds per ${m1} per 30 m, after minus before: removes a change of the evening level.`,
433
+ legend: {d: m1 => `Difference, s per ${m1} per 30 m`,
434
+ dx: m1 => `Difference of the excess over the evening, s per ${m1} per 30 m`,
435
+ help: {d: m1 => `For each bit of track: the time per ${m1} after, minus the time before. Green: faster than before; red: slower; pale: uncertain difference.`,
436
+ dx: m1 => `Like the difference, but computed on the excess over the evening: if the evening itself changed, that change is left out. Green: faster; red: slower; pale: uncertain.`},
402
437
  faster: 'faster', slower: 'slower', pale: 'pale: uncertain difference', absent: 'absent before', chg: 'retained change'},
403
438
  tip: {a: (v, s) => `before: ${v} obs. per passage (≈ ${s} s)`, b: (v, s) => `after: ${v} (≈ ${s} s)`,
404
439
  d: (v, m1) => `<b>${v} s per ${m1}</b>`, unsure: 'uncertain difference', absent: 'absent from the first period',
405
440
  freq: (a, b, m) => `${a} → ${b} ${m} per hour`},
406
- mxsub: (f, t, a, b, m1, stops) => `From ${f} to ${t}. Each cell: the difference in time per ${m1} between ${a} and ${b}, at that hour. Green: faster; red: slower; pale: uncertain${stops ? '' : '; stop zones hidden'}. Yellow outlines: retained changes. Click an hour to show it on the map.`,
407
- profsub: (f, t, w, m1, m) => `From ${f} to ${t}, ${w}. Above zero, each ${m1} takes longer than before; below, less. Yellow dots: ${m} per hour after (right scale).`,
441
+ mxsub: (f, t, a, b, m1, stops) => [`From ${f} to ${t}.`, `Each cell: the difference in time per ${m1} between ${a} and ${b}, at that hour. Green: faster; red: slower; pale: uncertain${stops ? '' : '; hatching: stop zones hidden'}. Yellow outlines: retained changes. Click an hour to show it on the map.`],
442
+ profsub: (f, t, w, m1, m) => [`From ${f} to ${t}, ${w}.`, `Above zero, each ${m1} takes longer than before; below, less. Yellow dots: ${m} per hour after (right scale).`],
408
443
  days: 'same weekdays as each period',
409
444
  method: (m1, m, a, b) => `<b>Comparing two periods.</b> ${a} (before) and ${b} (after), each on its own days and with its own reference. Micro-segments are matched from one period to the other; those that exist in one period only are left out. The difference is in observations per passage, so in time per ${m1}, which makes it independent of the frequency. The uncertainty comes from resampling the days of each period. When the frequency changes by more than 20 %, the stretch is flagged: a different timetable (more ${m}, more queues) may explain part of the difference.`,
410
445
  },
@@ -445,7 +480,8 @@ function colorOf(v) {
445
480
  // is 3-4.4 s per 30 m, p90 6.5-10 s, p98 12-23 s; stop zones p98 33-46 s; positive excess over the evening:
446
481
  // median ~1 s, p98 5-17 s. So typical values stay green and hotspots (10 s and more per 30 m) turn red.
447
482
  const SC = {L: 30, T0: 3, T1: 24, TICKS: [3, 6, 9, 12, 15, 24], BASE: 3, OPH: 2, CMP: 10, CTICKS: [-10, -3, 0, 3, 10]};
448
- const NULLC = '#3a4560', MASKC = '#26324d'; // no data / masked stop zone: discreet dark greys (map, matrix, legend)
483
+ // no data: flat discreet grey; masked stop zone: grey hatching (SVG pattern #mshatch, legend swatch .sw.hatch)
484
+ const NULLC = '#3a4560', MASKC = 'url(#mshatch)';
449
485
  function timeColor(T) {
450
486
  if (T == null || !isFinite(T)) return NULLC;
451
487
  return colorOf(108 / Math.max(T, 1e-9));
@@ -467,7 +503,7 @@ function divColor(v, pale) {
467
503
  // ---------------------------------------------------------------- state
468
504
  const qs = new URLSearchParams(location.search);
469
505
  const st = {lang: T[qs.get('lang')] ? qs.get('lang') : (T[document.documentElement.lang] ? document.documentElement.lang : 'fr'),
470
- dir: null, ver: null, band: null, hour: null, metric: 'oph', per10: false, stops: false, days: [], hot: null,
506
+ dir: null, ver: null, band: null, hour: null, metric: 'oph', per10: false, stops: qs.get('stops') !== 'false', days: [], hot: null,
471
507
  mode: D.compare ? 'cmp' : 'one', cmetric: 'd', chg: null};
472
508
  const tx = () => T[st.lang];
473
509
  const noun = (pl) => { const n = NOUN[st.lang][D.mode] || NOUN[st.lang]._; return pl ? n[1] : n[0]; };
@@ -585,8 +621,35 @@ function showTip(e, html) { tipEl.innerHTML = html; tipEl.style.display = 'block
585
621
  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'; }
586
622
  const hideTip = () => { tipEl.style.display = 'none'; };
587
623
  const HOT_TOP = 8;
624
+ // masked stop zone in the profiles: a hatched band over the plot height
625
+ const maskBar = (x, b, m, H) => `<rect x="${x(b.x0).toFixed(1)}" y="${m.t}" width="${Math.max(1, x(b.x0 + b.len) - x(b.x0) - .5).toFixed(1)}" height="${H - m.t - m.b}" fill="${MASKC}" opacity=".55"/>`;
626
+ // short text + '*' button; the button carries the plain-language explanation for the popover
627
+ const helpBtn = t => `<button type="button" class="qm" aria-expanded="false" aria-controls="pop" aria-label="${esc(tx().legend.what)}" data-help="${esc(t)}">*</button>`;
628
+ function setSub(el, parts) { el.innerHTML = esc(parts[0]) + helpBtn(parts[1]); }
629
+ const popEl = document.getElementById('pop');
630
+ let popBtn = null, popPinned = false;
631
+ function openPop(b, pin) {
632
+ if (popBtn && popBtn !== b) popBtn.setAttribute('aria-expanded', 'false');
633
+ popBtn = b; popPinned = pin; popEl.textContent = b.dataset.help; popEl.hidden = false; b.setAttribute('aria-expanded', 'true');
634
+ const r = b.getBoundingClientRect(), w = popEl.offsetWidth, h = popEl.offsetHeight;
635
+ popEl.style.left = Math.max(12, Math.min(window.innerWidth - w - 12, r.left + r.width / 2 - w / 2)) + 'px';
636
+ popEl.style.top = (r.bottom + 8 + h > window.innerHeight && r.top - h - 8 > 0 ? r.top - h - 8 : r.bottom + 8) + 'px';
637
+ }
638
+ function closePop() { if (popBtn) popBtn.setAttribute('aria-expanded', 'false'); popBtn = null; popPinned = false; popEl.hidden = true; }
639
+ document.addEventListener('click', e => {
640
+ const b = e.target.closest && e.target.closest('.qm');
641
+ if (b) { e.preventDefault(); if (popBtn === b && popPinned) closePop(); else openPop(b, true); return; }
642
+ if (popBtn && !popEl.contains(e.target)) closePop();
643
+ });
644
+ document.addEventListener('keydown', e => { if (e.key === 'Escape' && popBtn) { const b = popBtn; closePop(); b.focus(); } });
645
+ const canHover = window.matchMedia && matchMedia('(hover: hover)').matches;
646
+ document.addEventListener('mouseover', e => { const b = canHover && e.target.closest && e.target.closest('.qm'); if (b && !popPinned) openPop(b, false); });
647
+ document.addEventListener('mouseout', e => { const b = e.target.closest && e.target.closest('.qm'); if (b && popBtn === b && !popPinned) closePop(); });
648
+ document.addEventListener('focusin', e => { const b = e.target.closest && e.target.closest('.qm'); if (b && !popPinned && b.matches(':focus-visible')) openPop(b, false); });
649
+ window.addEventListener('scroll', () => { if (popBtn) { if (popBtn.isConnected) openPop(popBtn, popPinned); else closePop(); } }, {passive: true});
588
650
  // stop-zone hotspots are listed only when stop zones are shown
589
- const hotDir = () => (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);
651
+ const hsOn = () => isCmp() || D.show_hotspots !== false;
652
+ const hotDir = () => !hsOn() ? [] : (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);
590
653
  const hotAll = () => hotDir().filter(h => st.stops || h.zone !== 'stop');
591
654
  // the list (and the map markers) show the top hotspots unless "show all" is on
592
655
  const hotOfDir = () => { const a = hotAll(); return st.hotAll ? a : a.slice(0, HOT_TOP); };
@@ -613,7 +676,7 @@ function agreeText(lang) {
613
676
  if (!ag.length) return '';
614
677
  const rho = ag.map(a => nf(a.spearman_day, 2)).join(lang === 'fr' ? ' et ' : ' and ');
615
678
  const nh = ag.reduce((t, a) => t + (a.n_hotspots || 0), 0), nb = ag.reduce((t, a) => t + (a.n_both || 0), 0);
616
- const hs = nh ? (lang === 'fr' ? ` ${nb} des ${nh} points chauds ressortent avec les deux références.` : ` ${nb} of the ${nh} hotspots stand out against both references.`) : '';
679
+ const hs = nh && D.show_hotspots !== false ? (lang === 'fr' ? ` ${nb} des ${nh} points chauds ressortent avec les deux références.` : ` ${nb} of the ${nh} hotspots stand out against both references.`) : '';
617
680
  return lang === 'fr'
618
681
  ? ` En journée, entre les arrêts, les deux écarts classent les microsegments avec une corrélation de rang de ${rho}${ag.length > 1 ? ' selon le sens' : ''} (1 : même classement).${hs}`
619
682
  : ` In the daytime, between stops, the two excesses rank the micro-segments with a rank correlation of ${rho}${ag.length > 1 ? ' by direction' : ''} (1: same ranking).${hs}`;
@@ -664,9 +727,9 @@ function cmpLegend() {
664
727
  const bar = Array.from({length: 41}, (_, i) => { const t = (i - 20) / 20; return divColor(Math.sign(t) * M * t * t, false); }).join(',');
665
728
  const tk = SC.CTICKS.map(v => `<span style="left:${pos(v)}%">${v === M ? '≥ ' : v === -M ? '≤ ' : ''}${v ? signed(v, 0) + ' s' : '0'}</span>`).join('');
666
729
  const html = `<div class="ramp"><div class="bar" style="background:linear-gradient(90deg,${bar})"></div><div class="tk">${tk}</div></div>` +
667
- `<span class="txt">${c[mm](noun(false))} <span style="color:${FASTC}">${c.faster}</span> · <span style="color:${SLOWC}">${c.slower}</span> · ${c.pale}. ${tx().legend.same}</span>` +
730
+ `<span class="txt">${esc(c[mm](noun(false)))}${helpBtn(c.help[mm](noun(false)))}</span>` +
668
731
  `<span><i class="sw" style="background:${NULLC}"></i>${c.absent}</span>` +
669
- (st.stops ? '' : `<span><i class="sw" style="background:${MASKC}"></i>${tx().legend.masked}</span>`) +
732
+ (st.stops ? '' : `<span><i class="sw hatch"></i>${tx().legend.masked}</span>`) +
670
733
  (chgDir().length ? `<span><i class="hk"></i>${c.chg}</span>` : '');
671
734
  $('legend').innerHTML = html; $('mxlegend').innerHTML = html;
672
735
  }
@@ -726,6 +789,7 @@ function cmpProfile() {
726
789
  s += `<line x1="${xx}" x2="${xx}" y1="${m.t}" y2="${H - m.b}" stroke="#eef1f6" stroke-opacity=".15"/>`;
727
790
  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>`; }
728
791
  }
792
+ v.forEach(b => { if (!shown(b)) s += maskBar(x, b, m, H); });
729
793
  v.forEach(b => { const u = cSec(b); if (u == null || !shown(b)) return; const ys = y(u), y0 = y(0);
730
794
  s += `<rect x="${x(b.x0).toFixed(1)}" y="${Math.min(ys, y0).toFixed(1)}" width="${Math.max(1, x(b.x0 + b.len) - x(b.x0) - .5).toFixed(1)}" height="${Math.max(.5, Math.abs(y0 - ys)).toFixed(1)}" fill="${divColor(u, !b.sig)}"/>`; });
731
795
  s += freqLine(v.map(b => b.fb == null ? null : b.fb), v, x, H, m, W);
@@ -734,7 +798,7 @@ function cmpProfile() {
734
798
  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();
735
799
  let i = v.findIndex(b => u >= b.x0 && u < b.x0 + b.len); if (i < 0) i = v.length - 1; showTip(e, cTipHtml(E, v[i])); };
736
800
  svg.onmouseleave = hideTip;
737
- $('profsub').textContent = c.profsub(E.from, E.to, whenLabel(), noun(false), noun(true));
801
+ setSub($('profsub'), c.profsub(E.from, E.to, whenLabel(), noun(false), noun(true)));
738
802
  }
739
803
 
740
804
  // ---------------------------------------------------------------- static texts
@@ -759,7 +823,7 @@ function texts() {
759
823
  document.getElementById('proftitle').textContent = l.proftitle;
760
824
  document.getElementById('covtitle').textContent = l.covtitle;
761
825
  document.getElementById('mtitle').textContent = l.mtitle;
762
- document.getElementById('method').innerHTML = l.method(noun(true), noun(false)).concat(CMP ? [ctx().method(noun(false), noun(true), perLabel(CMP.a), perLabel(CMP.b))] : []).map(x => `<li>${x}</li>`).join('');
826
+ document.getElementById('method').innerHTML = l.method(noun(true), noun(false)).filter(x => hsOn() || !/^<b>(Points chauds|Hotspots)\./.test(x)).concat(CMP ? [ctx().method(noun(false), noun(true), perLabel(CMP.a), perLabel(CMP.b))] : []).map(x => `<li>${x}</li>`).join('');
763
827
  document.getElementById('footer').textContent = l.footer(D.source);
764
828
  document.getElementById('play').textContent = timer ? l.pause : l.play;
765
829
  document.getElementById('mxfull').textContent = mxCard.classList.contains('full') ? l.unfull : l.full;
@@ -767,25 +831,40 @@ function texts() {
767
831
  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);
768
832
  document.getElementById('hticks').innerHTML = tk.map(h => `<span>${h} h</span>`).join('');
769
833
  }
834
+ // "4 jours écartés : 2 fériés, 2 flux incomplets" (missing, frozen and empty feed hours count as one reason)
835
+ const FEED_REASONS = ['low_coverage', 'frozen', 'no_data'];
836
+ function exclSummary(excluded) {
837
+ const l = tx(), ex = excluded || [];
838
+ if (!ex.length) return '';
839
+ const counts = new Map();
840
+ for (const e of ex) { const k = FEED_REASONS.includes(e.reason) ? 'feed' : e.reason; counts.set(k, (counts.get(k) || 0) + 1); }
841
+ const parts = [...counts].sort((a, b) => b[1] - a[1]).map(([k, n]) => {
842
+ const w = l.xr[k] || [l.reasons[k] || k, l.reasons[k] || k];
843
+ return `${nf(n, 0)} ${w[n > 1 ? 1 : 0]}`;
844
+ });
845
+ return l.xsum(ex.length, parts.join(', '));
846
+ }
770
847
  function tiles() {
771
- const l = tx(), nx = (D.period.excluded || []).length;
848
+ const l = tx();
772
849
  const nseg = DIRS.reduce((a, d) => a + d.seg.key.length, 0);
773
850
  let tot = 0;
774
851
  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;
775
852
  const nh = (D.hotspots || []).length;
776
853
  if (isCmp()) {
777
854
  const c = ctx().tiles, ch = CMP.changes || [];
855
+ document.getElementById('tiles').classList.remove('t3');
778
856
  document.getElementById('tiles').innerHTML = [[nf(CMP.a.days, 0), `${c.a} (${perLabel(CMP.a)})`], [nf(CMP.b.days, 0), `${c.b} (${perLabel(CMP.b)})`],
779
857
  [nf(ch.filter(x => x.sign > 0).length, 0), c.slower], [nf(ch.filter(x => x.sign < 0).length, 0), c.faster]]
780
858
  .map(([b, s]) => `<div class="tile"><b>${b}</b><span>${esc(s)}</span></div>`).join('');
781
859
  return;
782
860
  }
783
861
  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);
862
+ document.getElementById('tiles').classList.toggle('t3', !hsOn());
784
863
  document.getElementById('tiles').innerHTML = [
785
- [nf(D.period.days, 0), l.tiles.days(D.period.days, nx)],
864
+ [nf(D.period.days, 0), esc(l.tiles.days(D.period.days, exclSummary(D.period.excluded)))],
786
865
  [nf(nseg, 0), l.tiles.segs(nseg, nf(P.segment_m || 30, 0))],
787
866
  [big(tot), l.tiles.obs],
788
- [nf(nh, 0), l.tiles.hs(nh)],
867
+ ...(hsOn() ? [[nf(nh, 0), l.tiles.hs(nh)]] : []),
789
868
  ].map(([b, s]) => `<div class="tile"><b>${b}</b><span>${s}</span></div>`).join('');
790
869
  }
791
870
 
@@ -925,11 +1004,11 @@ function legend() {
925
1004
  const lab = (T, i) => { const x = tickVal(T, m), d = Math.abs(x - Math.round(x)) < 1e-9 ? 0 : 1;
926
1005
  return (i === n - 1 ? '≥ ' : i === 0 && !isEx(m) ? '≤ ' : '') + (isEx(m) && x ? '+' : '') + nf(x, d) + (m === 'oph' ? '' : ' s'); };
927
1006
  const ticks = SC.TICKS.map((T, i) => `<span style="left:${tPos(T) * 100}%">${lab(T, i)}</span>`).join('');
928
- const txt = (m === 'oph' ? l.oph(noun(false), u) : m === 'opp' ? l.opp(noun(false), u) : m === 'exl' ? l.exl(noun(false), u)
929
- : l.ex(noun(false), u) + (st.band === 'evening' || (!st.band && st.hour >= REFH[0] && st.hour < REFH[1]) ? l.evening : '')) + ' ' + l.same;
1007
+ const txt = esc((m === 'oph' ? l.oph(noun(false), u) : m === 'opp' ? l.opp(noun(false), u) : m === 'exl' ? l.exl(noun(false), u)
1008
+ : l.ex(noun(false), u) + (st.band === 'evening' || (!st.band && st.hour >= REFH[0] && st.hour < REFH[1]) ? l.evening : ''))) + helpBtn(l.help[m](noun(false), noun(true)));
930
1009
  $('legend').innerHTML = `<div class="ramp"><div class="bar" style="background:linear-gradient(90deg,${bar})"></div><div class="tk">${ticks}</div></div>` +
931
1010
  `<span class="txt">${txt}</span><span><i class="sw" style="background:${NULLC}"></i>${l.nodata}</span>` +
932
- (st.stops ? '' : `<span><i class="sw" style="background:${MASKC}"></i>${l.masked}</span>`) +
1011
+ (st.stops ? '' : `<span><i class="sw hatch"></i>${l.masked}</span>`) +
933
1012
  (hotOfDir().length && isMain() ? `<span><i class="hk"></i>${l.hot}</span>` : '');
934
1013
  $('mxlegend').innerHTML = $('legend').innerHTML;
935
1014
  }
@@ -1000,7 +1079,7 @@ function drawMatrix() {
1000
1079
  svg.innerHTML = s;
1001
1080
  MX = {E, rows, m, RH, W, Ltot, tip: cm ? cTipHtml : tipHtml};
1002
1081
  const what = l.metrics[st.metric].toLowerCase() + (st.per10 ? ' (10 m)' : ' (30 m)');
1003
- $('mxsub').textContent = cm ? ctx().mxsub(E.from, E.to, perLabel(CMP.a), perLabel(CMP.b), noun(false), st.stops) : l.mxsub(E.from, E.to, daysLabel(), what, st.stops);
1082
+ setSub($('mxsub'), cm ? ctx().mxsub(E.from, E.to, perLabel(CMP.a), perLabel(CMP.b), noun(false), st.stops) : l.mxsub(E.from, E.to, daysLabel(), what, st.stops, hsOn() && hotOfDir().length > 0));
1004
1083
  }
1005
1084
  $('mx').addEventListener('mousemove', e => {
1006
1085
  if (!MX) return;
@@ -1035,6 +1114,7 @@ function drawProfile() {
1035
1114
  s += `<line x1="${xx}" x2="${xx}" y1="${m.t}" y2="${H - m.b}" stroke="#eef1f6" stroke-opacity=".15"/>`;
1036
1115
  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>`; }
1037
1116
  }
1117
+ v.forEach(b => { if (!shown(b)) s += maskBar(x, b, m, H); });
1038
1118
  v.forEach(b => { const u = pv(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="${colorFor(b)}"/>`; });
1039
1119
  if (evening) { let pth = ''; evening.forEach(b => { const u = pv(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)}`; });
1040
1120
  s += `<path d="${pth}" stroke="#eef1f6" stroke-width="1.5" fill="none" opacity=".7"/>`; }
@@ -1044,7 +1124,7 @@ function drawProfile() {
1044
1124
  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();
1045
1125
  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])); };
1046
1126
  svg.onmouseleave = hideTip;
1047
- $('profsub').textContent = l.profsub(E.from, E.to, whenLabel(), !!evening, noun(true));
1127
+ setSub($('profsub'), l.profsub(E.from, E.to, whenLabel(), !!evening, noun(true)));
1048
1128
  }
1049
1129
 
1050
1130
  // ---------------------------------------------------------------- coverage calendar
@@ -1053,6 +1133,7 @@ function drawCalendar() {
1053
1133
  const dates = cv.dates || [];
1054
1134
  if (!dates.length) { svg.innerHTML = ''; $('covsub').textContent = ''; return; }
1055
1135
  const exc = Object.fromEntries((D.period.excluded || []).map(e => [e.date, e.reason]));
1136
+ const xlab = Object.fromEntries((D.period.excluded || []).filter(e => e.label).map(e => [e.date, e.label]));
1056
1137
  const dayIdx = D.hours.map((h, j) => [h, j]).filter(([h]) => h >= DAYH[0] && h < DAYH[1]).map(([, j]) => j);
1057
1138
  const t0 = new Date(dates[0] + 'T12:00:00Z'), dow0 = (t0.getUTCDay() + 6) % 7;
1058
1139
  const C = 15, G = 3, top = 16, left = 26;
@@ -1071,15 +1152,14 @@ function drawCalendar() {
1071
1152
  let fill, status;
1072
1153
  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; }
1073
1154
  else if (inc === 'not_selected') { fill = '#16223d'; status = l.cal.off; }
1074
- else { fill = inc === 'no_data' ? '#3a4560' : '#c2414b'; status = `${l.cal.exc} : ${l.reasons[inc] || inc}`; }
1155
+ else { fill = inc === 'no_data' ? '#3a4560' : '#c2414b'; status = `${l.cal.exc} : ${l.reasons[inc] || inc}${xlab[d] ? ` (${xlab[d]})` : ''}`; }
1075
1156
  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))) : ''}`;
1076
1157
  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}"/>`;
1077
1158
  });
1078
1159
  svg.setAttribute('width', left + nW * (C + G)); svg.setAttribute('height', top + 7 * (C + G));
1079
1160
  svg.innerHTML = s;
1080
1161
  svg.querySelectorAll('rect').forEach(r => { r.onmousemove = e => showTip(e, r.dataset.tip); r.onmouseleave = hideTip; });
1081
- const nx = (D.period.excluded || []).length;
1082
- $('covsub').textContent = l.covsub(D.period.days, nx);
1162
+ setSub($('covsub'), l.covsub(D.period.days, exclSummary(D.period.excluded)));
1083
1163
  $('calleg').innerHTML = [[colorOf(22), l.cal.inc], ['#c2414b', l.cal.exc], ['#24324f', l.cal.off], ['#3a4560', l.cal.none]]
1084
1164
  .map(([c, t]) => `<span><i class="sw" style="background:${c};width:12px;height:12px"></i>${esc(t)}</span>`).join('');
1085
1165
  }
@@ -1101,6 +1181,9 @@ function render() {
1101
1181
  const dl = isCmp() ? ctx().days : daysLabel();
1102
1182
  $('clock').innerHTML = st.band ? `${BANDDEF[st.band][0]}–${BANDDEF[st.band][1]} h<small>${tx().mean}, ${esc(dl)}</small>` : `${String(st.hour).padStart(2, '0')}:00<small>${esc(dl)}</small>`;
1103
1183
  hourEl.value = st.hour;
1184
+ const nohs = !hsOn();
1185
+ if (document.body.classList.contains('nohs') !== nohs) { document.body.classList.toggle('nohs', nohs); if (map) setTimeout(() => map.invalidateSize(), 0); }
1186
+ if (popBtn) closePop();
1104
1187
  recolor(); legend(); drawHotMap(); drawHotList(); drawMatrix(); drawProfile(); drawCalendar();
1105
1188
  }
1106
1189
  function drawAll() { buildVersions(); drawMap(); render(); }
@@ -32,7 +32,8 @@ _ALIASES = {"oph": "obs_per_h", "oph10": "obs_per_h_10m", "opp": "obs_per_passag
32
32
  "excess": "excess_per_passage"}
33
33
  _RAMP = list(S.RAMP) # the STIB speed ramp (km/h stops), as on the page
34
34
  NULL_COLOR = "#3a4560"
35
- MASK_COLOR = "#26324d"
35
+ MASK_COLOR = "#18213a" # masked stop zones: hatched (background + stripes), distinct from flat no-data grey
36
+ MASK_HATCH = "#5d6a88"
36
37
  BG = "#0b1224"
37
38
 
38
39
 
@@ -163,6 +164,14 @@ def _frame(an: Analysis, direction_id: int, metric: str, pattern_uid: str | None
163
164
  return r.with_columns(v.cast(pl.Float64).alias("value"))
164
165
 
165
166
 
167
+ def _hatch_spans(ax, segs: pl.DataFrame, y0: float, y1: float, alpha: float = 1.0) -> None:
168
+ """Masked stop zones: hatched rectangles over [x0, x0 + len] x [y0, y1]."""
169
+ from matplotlib.patches import Rectangle
170
+ for x0, ln in zip(segs["x0_m"].to_list(), segs["len_m"].to_list()):
171
+ ax.add_patch(Rectangle((x0, min(y0, y1)), ln, abs(y1 - y0), facecolor=MASK_COLOR, edgecolor=MASK_HATCH,
172
+ hatch="////", lw=0, alpha=alpha, zorder=1.5))
173
+
174
+
166
175
  def auto_vmax(analysis=None, metric: str = "obs_per_h", stops: bool = True, q: float = 0.98) -> float:
167
176
  """Top of the fixed colour scale in the units of ``metric`` per 30 m (per 10 m for ``obs_per_h_10m``).
168
177
  It does not depend on the analysis (kept for compatibility; the arguments other than ``metric`` are
@@ -229,7 +238,6 @@ def matrix(analysis, direction_id: int = 0, metric: str = "obs_per_h", days=None
229
238
  for h, s, v in f.select("hour", "seg_idx", "T").iter_rows():
230
239
  if h in hi and v is not None and np.isfinite(v):
231
240
  Z[hi[h], si[s]] = v
232
- zone = np.array(segs["zone"].to_list())
233
241
  x_edges = np.concatenate([segs["x0_m"].to_numpy(), [segs["x0_m"][-1] + segs["len_m"][-1]]]) if segs.height else np.array([0, 1])
234
242
  y_edges = np.arange(len(hours) + 1) - 0.5 + hours[0] if hours else np.array([0, 1])
235
243
  fig, ax = (ax.figure, ax) if ax is not None else plt.subplots(figsize=figsize, layout="constrained")
@@ -237,9 +245,7 @@ def matrix(analysis, direction_id: int = 0, metric: str = "obs_per_h", days=None
237
245
  cm = cmap()
238
246
  ax.pcolormesh(x_edges, y_edges, np.ma.masked_invalid(Z), cmap=cm, norm=norm(), shading="flat")
239
247
  if not stops and segs.height:
240
- M = np.where(np.broadcast_to(zone == "stop", Z.shape), 1.0, np.nan)
241
- from matplotlib.colors import ListedColormap
242
- ax.pcolormesh(x_edges, y_edges, np.ma.masked_invalid(M), cmap=ListedColormap([MASK_COLOR]), shading="flat")
248
+ _hatch_spans(ax, segs.filter(pl.col("zone") == "stop"), y_edges[0], y_edges[-1])
243
249
  xs, names = _stops(an, direction_id, links, pattern_uid)
244
250
  for x in xs:
245
251
  ax.axvline(x, color=BG, lw=1.2)
@@ -294,6 +300,8 @@ def profile(analysis, direction_id: int = 0, hour: int | str | None = None, *, b
294
300
  colors = cmap()(norm()(np.nan_to_num(T, nan=S.T_MIN)))
295
301
  colors[np.isnan(T)] = (0, 0, 0, 0)
296
302
  ax.bar(cur["x0_m"].to_numpy(), np.nan_to_num(v), width=cur["len_m"].to_numpy() * 0.96, align="edge", color=colors)
303
+ if not stops:
304
+ _hatch_spans(ax, f.filter((pl.col("hour") == code) & (pl.col("zone") == "stop")), 0, top * 1.05, alpha=0.55)
297
305
  if reference and code != BAND_HOUR["evening"] and not m.startswith("excess") and m != "log2_ratio":
298
306
  ev = f.filter(pl.col("hour") == BAND_HOUR["evening"])
299
307
  if not stops:
@@ -335,8 +343,7 @@ def map(analysis, direction_id: int = 0, hour: int | str | None = None, *, band:
335
343
  T = _seconds(an, m, g["value"].fill_null(np.nan).to_numpy(), g["len_m"].to_numpy())
336
344
  colors = cmap()(norm()(np.nan_to_num(T, nan=S.T_MIN)))
337
345
  colors[np.isnan(T)] = matplotlib_color(NULL_COLOR)
338
- if not stops:
339
- colors[np.array(g["zone"].to_list()) == "stop"] = matplotlib_color(MASK_COLOR)
346
+ masked = np.array(g["zone"].to_list()) == "stop" if not stops else np.zeros(len(lines), bool)
340
347
  fig, ax = (ax.figure, ax) if ax is not None else plt.subplots(figsize=figsize, layout="constrained")
341
348
  if hotspots is not None and hotspots.height and uid == an.dirs[direction_id].display_uid:
342
349
  x0 = g["x0_m"].to_numpy()
@@ -349,7 +356,14 @@ def map(analysis, direction_id: int = 0, hour: int | str | None = None, *, band:
349
356
  mid = hl[len(hl) // 2][len(hl[len(hl) // 2]) // 2]
350
357
  ax.annotate(str(r["rank"]), mid, xytext=(8, 8), textcoords="offset points", fontsize=8,
351
358
  bbox=dict(boxstyle="circle", fc="#ffe600", ec="none"), zorder=4)
352
- ax.add_collection(LineCollection([c for c in lines], colors=colors, linewidths=linewidth, capstyle="butt", zorder=2))
359
+ keep = ~masked
360
+ ax.add_collection(LineCollection([c for c, k in zip(lines, keep) if k], colors=colors[keep], linewidths=linewidth,
361
+ capstyle="butt", zorder=2))
362
+ if masked.any(): # masked stop zones: striped (dashes over a dark casing), unlike the flat no-data grey
363
+ ml = [c for c, k in zip(lines, masked) if k]
364
+ ax.add_collection(LineCollection(ml, colors=MASK_COLOR, linewidths=linewidth, capstyle="butt", zorder=2))
365
+ ax.add_collection(LineCollection(ml, colors=MASK_HATCH, linewidths=linewidth, linestyles=(0, (1, 1.5)),
366
+ capstyle="butt", zorder=2))
353
367
  xs, names = _stops(an, direction_id, links, uid)
354
368
  if links is not None:
355
369
  lk = links.filter(pl.col("pattern_uid") == uid).sort("link_idx")
File without changes
File without changes
File without changes