zero2route3d-sdk 0.2.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.
- zero2route3d/__init__.py +212 -0
- zero2route3d/accessibility_equity.py +214 -0
- zero2route3d/ahp_engine.py +117 -0
- zero2route3d/api.py +244 -0
- zero2route3d/basemap.py +44 -0
- zero2route3d/cli.py +251 -0
- zero2route3d/copernicus_dem.py +76 -0
- zero2route3d/copernicus_eo_suite.py +443 -0
- zero2route3d/dem_fetcher.py +223 -0
- zero2route3d/environmental_raster.py +411 -0
- zero2route3d/evacuation.py +121 -0
- zero2route3d/html_bundler.py +603 -0
- zero2route3d/input_validation.py +100 -0
- zero2route3d/integrations.py +260 -0
- zero2route3d/isochrone_engine.py +220 -0
- zero2route3d/kinematics.py +383 -0
- zero2route3d/local_server.py +116 -0
- zero2route3d/map_matching_3d.py +374 -0
- zero2route3d/micro_elevation.py +264 -0
- zero2route3d/mobility_profiles.py +517 -0
- zero2route3d/multimodal.py +172 -0
- zero2route3d/network_source.py +512 -0
- zero2route3d/osm_downloader.py +328 -0
- zero2route3d/osm_styling.py +351 -0
- zero2route3d/pareto_router.py +331 -0
- zero2route3d/plotting.py +292 -0
- zero2route3d/profile_dxf.py +231 -0
- zero2route3d/profile_stats.py +648 -0
- zero2route3d/py.typed +1 -0
- zero2route3d/qml_generator.py +189 -0
- zero2route3d/raster_source.py +206 -0
- zero2route3d/report_generator.py +154 -0
- zero2route3d/route_corridor_3d.py +479 -0
- zero2route3d/routing_engine.py +747 -0
- zero2route3d/solar_shadow.py +157 -0
- zero2route3d/tsp_solver.py +78 -0
- zero2route3d_sdk-0.2.0.dist-info/METADATA +251 -0
- zero2route3d_sdk-0.2.0.dist-info/RECORD +42 -0
- zero2route3d_sdk-0.2.0.dist-info/WHEEL +5 -0
- zero2route3d_sdk-0.2.0.dist-info/entry_points.txt +2 -0
- zero2route3d_sdk-0.2.0.dist-info/licenses/LICENSE +21 -0
- zero2route3d_sdk-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
"""Standalone single-file interactive HTML report generator.
|
|
2
|
+
|
|
3
|
+
The report is genuinely self-contained: all CSS and JavaScript are inlined and
|
|
4
|
+
no network request is made when it is opened, so it works from a file:// URL, an
|
|
5
|
+
email attachment or an air-gapped machine.
|
|
6
|
+
|
|
7
|
+
By default it renders a dependency-free viewer -- a plan-view route map, a
|
|
8
|
+
longitudinal elevation/speed profile, a slope-coloured track and a scrubber that
|
|
9
|
+
links all three. Pass ``web_dir`` pointing at the 02Route 3D QGIS plugin's
|
|
10
|
+
``web/`` folder to bundle the full Three.js WebGL studio instead; that folder
|
|
11
|
+
(Three.js alone is >1 MB) deliberately ships with the plugin rather than with
|
|
12
|
+
this SDK.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import math
|
|
19
|
+
from html import escape
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
22
|
+
|
|
23
|
+
from .routing_engine import RouteResult3D
|
|
24
|
+
|
|
25
|
+
_WEBGL_ASSETS = (
|
|
26
|
+
("css", "studio3d.css"),
|
|
27
|
+
("js", "three.module.js"),
|
|
28
|
+
("js", "OrbitControls.js"),
|
|
29
|
+
("js", "KinematicAvatarRig.js"),
|
|
30
|
+
("js", "TerrainSlicerSystem.js"),
|
|
31
|
+
("js", "VoiceCueSystem.js"),
|
|
32
|
+
("js", "app3d.js"),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class StandaloneHtmlBundler:
|
|
37
|
+
"""Bundles a 3D route, its statistics and an interactive viewer into one HTML file."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, web_dir: Optional[Union[str, Path]] = None) -> None:
|
|
40
|
+
self.web_dir: Optional[Path] = Path(web_dir) if web_dir else None
|
|
41
|
+
|
|
42
|
+
# -- public API --------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
def bundle_to_file(
|
|
45
|
+
self, data: Union[RouteResult3D, Dict[str, Any]], output_path: Union[str, Path]
|
|
46
|
+
) -> None:
|
|
47
|
+
"""Generate a standalone HTML document from a RouteResult3D or GeoJSON dict."""
|
|
48
|
+
self.export_standalone_html(data, output_path)
|
|
49
|
+
|
|
50
|
+
def export_standalone_html(
|
|
51
|
+
self,
|
|
52
|
+
result_or_geojson: Union[RouteResult3D, Dict[str, Any]],
|
|
53
|
+
output_path: Union[str, Path],
|
|
54
|
+
) -> None:
|
|
55
|
+
"""Write the standalone HTML document atomically to ``output_path``."""
|
|
56
|
+
out_p = Path(output_path)
|
|
57
|
+
out_p.parent.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
html_document = self.bundle(result_or_geojson)
|
|
59
|
+
temp_path = out_p.with_suffix(out_p.suffix + ".tmp")
|
|
60
|
+
temp_path.write_text(html_document, encoding="utf-8")
|
|
61
|
+
temp_path.replace(out_p)
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def has_webgl_assets(self) -> bool:
|
|
65
|
+
"""True when ``web_dir`` holds the Three.js studio assets needed to bundle it."""
|
|
66
|
+
if self.web_dir is None:
|
|
67
|
+
return False
|
|
68
|
+
return all((self.web_dir / folder / name).exists() for folder, name in _WEBGL_ASSETS)
|
|
69
|
+
|
|
70
|
+
def bundle(self, result_or_geojson: Union[RouteResult3D, Dict[str, Any]]) -> str:
|
|
71
|
+
"""Return the complete standalone HTML document as a string."""
|
|
72
|
+
payload = self._extract_payload(result_or_geojson)
|
|
73
|
+
if self.has_webgl_assets:
|
|
74
|
+
return self._render_webgl_studio(payload)
|
|
75
|
+
return self._render_builtin_viewer(payload)
|
|
76
|
+
|
|
77
|
+
# -- payload -----------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
@staticmethod
|
|
80
|
+
def _finite_number(value: Any, default: float = 0.0) -> float:
|
|
81
|
+
try:
|
|
82
|
+
number = float(value)
|
|
83
|
+
except (TypeError, ValueError):
|
|
84
|
+
return default
|
|
85
|
+
return number if math.isfinite(number) else default
|
|
86
|
+
|
|
87
|
+
@classmethod
|
|
88
|
+
def _json_safe(cls, value: Any) -> Any:
|
|
89
|
+
"""Convert route payload values into strict JSON-safe primitives."""
|
|
90
|
+
if isinstance(value, float):
|
|
91
|
+
return value if math.isfinite(value) else None
|
|
92
|
+
if isinstance(value, dict):
|
|
93
|
+
return {str(key): cls._json_safe(item) for key, item in value.items()}
|
|
94
|
+
if isinstance(value, (list, tuple)):
|
|
95
|
+
return [cls._json_safe(item) for item in value]
|
|
96
|
+
return value
|
|
97
|
+
|
|
98
|
+
def _extract_payload(
|
|
99
|
+
self, result_or_geojson: Union[RouteResult3D, Dict[str, Any]]
|
|
100
|
+
) -> Dict[str, Any]:
|
|
101
|
+
"""Normalize a RouteResult3D or a GeoJSON Feature into one rendering payload."""
|
|
102
|
+
if isinstance(result_or_geojson, RouteResult3D):
|
|
103
|
+
geojson_data: Dict[str, Any] = result_or_geojson.to_geojson_feature()
|
|
104
|
+
elif isinstance(result_or_geojson, dict):
|
|
105
|
+
geojson_data = result_or_geojson
|
|
106
|
+
else:
|
|
107
|
+
geojson_data = {}
|
|
108
|
+
|
|
109
|
+
props: Dict[str, Any] = {}
|
|
110
|
+
if isinstance(geojson_data, dict):
|
|
111
|
+
if geojson_data.get("type") == "FeatureCollection":
|
|
112
|
+
features = geojson_data.get("features") or []
|
|
113
|
+
if features and isinstance(features[0], dict):
|
|
114
|
+
props = features[0].get("properties") or {}
|
|
115
|
+
else:
|
|
116
|
+
props = geojson_data.get("properties") or {}
|
|
117
|
+
|
|
118
|
+
coordinates = self._extract_coordinates(geojson_data)
|
|
119
|
+
return {
|
|
120
|
+
"geojson": geojson_data,
|
|
121
|
+
"profile_name": str(props.get("profile_name", "3D Route")),
|
|
122
|
+
"profile_color": str(props.get("profile_color", "#38bdf8")),
|
|
123
|
+
"distance_km": self._finite_number(props.get("distance_km")),
|
|
124
|
+
"duration_min": self._finite_number(props.get("duration_min")),
|
|
125
|
+
"climb_m": self._finite_number(props.get("elevation_gain_m")),
|
|
126
|
+
"descent_m": self._finite_number(props.get("elevation_loss_m")),
|
|
127
|
+
"max_slope_pct": self._finite_number(props.get("max_slope_pct")),
|
|
128
|
+
"calories_kcal": self._finite_number(props.get("calories_kcal")),
|
|
129
|
+
"ada_compliant": bool(props.get("ada_compliant", False)),
|
|
130
|
+
"elevation_profile": props.get("elevation_profile") or [],
|
|
131
|
+
"cue_sheet": props.get("cue_sheet") or [],
|
|
132
|
+
"coordinates": coordinates,
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
@staticmethod
|
|
136
|
+
def _extract_coordinates(geojson_data: Any) -> List[Tuple[float, float, float]]:
|
|
137
|
+
"""Pull the 3D LineString vertices out of a Feature or FeatureCollection."""
|
|
138
|
+
if not isinstance(geojson_data, dict):
|
|
139
|
+
return []
|
|
140
|
+
geometry = geojson_data.get("geometry")
|
|
141
|
+
if geometry is None and geojson_data.get("type") == "FeatureCollection":
|
|
142
|
+
features = geojson_data.get("features") or []
|
|
143
|
+
if features and isinstance(features[0], dict):
|
|
144
|
+
geometry = features[0].get("geometry")
|
|
145
|
+
if not isinstance(geometry, dict):
|
|
146
|
+
return []
|
|
147
|
+
raw = geometry.get("coordinates") or []
|
|
148
|
+
if geometry.get("type") == "MultiLineString":
|
|
149
|
+
raw = [pt for line in raw for pt in line]
|
|
150
|
+
|
|
151
|
+
coords: List[Tuple[float, float, float]] = []
|
|
152
|
+
for pt in raw:
|
|
153
|
+
if not isinstance(pt, (list, tuple)) or len(pt) < 2:
|
|
154
|
+
continue
|
|
155
|
+
try:
|
|
156
|
+
lon, lat = float(pt[0]), float(pt[1])
|
|
157
|
+
elevation = float(pt[2]) if len(pt) > 2 else 0.0
|
|
158
|
+
except (TypeError, ValueError):
|
|
159
|
+
continue
|
|
160
|
+
if math.isfinite(lon) and math.isfinite(lat) and math.isfinite(elevation):
|
|
161
|
+
coords.append((lon, lat, elevation))
|
|
162
|
+
return coords
|
|
163
|
+
|
|
164
|
+
def _embedded_json(self, payload: Dict[str, Any]) -> str:
|
|
165
|
+
"""Serialize a payload for safe inlining inside a <script> block."""
|
|
166
|
+
text = json.dumps(self._json_safe(payload), allow_nan=False)
|
|
167
|
+
return text.replace("<", "\\u003c").replace(">", "\\u003e").replace("&", "\\u0026")
|
|
168
|
+
|
|
169
|
+
# -- renderers ---------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
def _render_webgl_studio(self, payload: Dict[str, Any]) -> str:
|
|
172
|
+
"""Inline the plugin's Three.js studio; only used when ``web_dir`` supplies it."""
|
|
173
|
+
web_dir = self.web_dir
|
|
174
|
+
if web_dir is None: # pragma: no cover - guarded by has_webgl_assets
|
|
175
|
+
raise RuntimeError("web_dir must be set to bundle the WebGL studio.")
|
|
176
|
+
|
|
177
|
+
def read(folder: str, name: str) -> str:
|
|
178
|
+
return (web_dir / folder / name).read_text(encoding="utf-8")
|
|
179
|
+
|
|
180
|
+
css_content = read("css", "studio3d.css")
|
|
181
|
+
three_js = read("js", "three.module.js")
|
|
182
|
+
controls_js = read("js", "OrbitControls.js")
|
|
183
|
+
rig_js = read("js", "KinematicAvatarRig.js")
|
|
184
|
+
slicer_js = read("js", "TerrainSlicerSystem.js")
|
|
185
|
+
voice_js = read("js", "VoiceCueSystem.js")
|
|
186
|
+
app_js = read("js", "app3d.js")
|
|
187
|
+
|
|
188
|
+
for module_import in (
|
|
189
|
+
"import * as THREE from './three.module.js';",
|
|
190
|
+
"import { OrbitControls } from './OrbitControls.js';",
|
|
191
|
+
"import { KinematicAvatarRig } from './KinematicAvatarRig.js';",
|
|
192
|
+
"import { TerrainSlicerSystem } from './TerrainSlicerSystem.js';",
|
|
193
|
+
"import { VoiceCueSystem } from './VoiceCueSystem.js';",
|
|
194
|
+
):
|
|
195
|
+
app_js = app_js.replace(module_import, "")
|
|
196
|
+
rig_js = rig_js.replace(module_import, "")
|
|
197
|
+
slicer_js = slicer_js.replace(module_import, "")
|
|
198
|
+
voice_js = voice_js.replace(module_import, "")
|
|
199
|
+
|
|
200
|
+
for export_class in (
|
|
201
|
+
"KinematicAvatarRig",
|
|
202
|
+
"TerrainSlicerSystem",
|
|
203
|
+
"VoiceCueSystem",
|
|
204
|
+
):
|
|
205
|
+
rig_js = rig_js.replace(f"export class {export_class}", f"class {export_class}")
|
|
206
|
+
slicer_js = slicer_js.replace(f"export class {export_class}", f"class {export_class}")
|
|
207
|
+
voice_js = voice_js.replace(f"export class {export_class}", f"class {export_class}")
|
|
208
|
+
|
|
209
|
+
safe_profile_name = escape(payload["profile_name"], quote=True)
|
|
210
|
+
json_str = self._embedded_json(payload["geojson"])
|
|
211
|
+
|
|
212
|
+
return f"""<!DOCTYPE html>
|
|
213
|
+
<html lang="en">
|
|
214
|
+
<head>
|
|
215
|
+
<meta charset="UTF-8">
|
|
216
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
217
|
+
<title>02Route 3D Studio - {safe_profile_name}</title>
|
|
218
|
+
<style>
|
|
219
|
+
{css_content}
|
|
220
|
+
</style>
|
|
221
|
+
</head>
|
|
222
|
+
<body>
|
|
223
|
+
<div id="canvasContainer"></div>
|
|
224
|
+
|
|
225
|
+
<div class="hud-panel top-hud">
|
|
226
|
+
<div class="brand-section">
|
|
227
|
+
<span class="brand-badge">OFFLINE 3D</span>
|
|
228
|
+
<span class="brand-title">02Route 3D - {safe_profile_name}</span>
|
|
229
|
+
</div>
|
|
230
|
+
<div class="stats-row">
|
|
231
|
+
<div class="stat-item"><span class="stat-label">Distance</span><span class="stat-value">{payload["distance_km"]:.2f} km</span></div>
|
|
232
|
+
<div class="stat-item"><span class="stat-label">Duration</span><span class="stat-value">{payload["duration_min"]:.1f} min</span></div>
|
|
233
|
+
<div class="stat-item"><span class="stat-label">Climb</span><span class="stat-value">+{payload["climb_m"]:.1f} m</span></div>
|
|
234
|
+
<div class="stat-item"><span class="stat-label">Max Slope</span><span class="stat-value">{payload["max_slope_pct"]:.1f}%</span></div>
|
|
235
|
+
</div>
|
|
236
|
+
</div>
|
|
237
|
+
|
|
238
|
+
<div class="hud-panel bottom-controls">
|
|
239
|
+
<div class="multi-metric-panel" id="multiMetricPanel">
|
|
240
|
+
<div class="multi-metric-rows" id="multiMetricRows"></div>
|
|
241
|
+
</div>
|
|
242
|
+
<div class="player-scrubber-row">
|
|
243
|
+
<button class="btn-play" id="btnPlay" title="Play/Pause 3D Centerline Simulation">
|
|
244
|
+
<span id="playIcon">▶</span>
|
|
245
|
+
</button>
|
|
246
|
+
<div class="scrubber-container">
|
|
247
|
+
<input type="range" class="scrubber-slider" id="scrubber" min="0" max="1000" value="0">
|
|
248
|
+
</div>
|
|
249
|
+
</div>
|
|
250
|
+
</div>
|
|
251
|
+
|
|
252
|
+
<script type="text/javascript">
|
|
253
|
+
{three_js}
|
|
254
|
+
</script>
|
|
255
|
+
<script type="text/javascript">
|
|
256
|
+
{controls_js}
|
|
257
|
+
</script>
|
|
258
|
+
<script type="text/javascript">
|
|
259
|
+
{rig_js}
|
|
260
|
+
</script>
|
|
261
|
+
<script type="text/javascript">
|
|
262
|
+
{slicer_js}
|
|
263
|
+
</script>
|
|
264
|
+
<script type="text/javascript">
|
|
265
|
+
{voice_js}
|
|
266
|
+
</script>
|
|
267
|
+
<script type="text/javascript">
|
|
268
|
+
{app_js}
|
|
269
|
+
|
|
270
|
+
window.addEventListener('DOMContentLoaded', () => {{
|
|
271
|
+
const data = {json_str};
|
|
272
|
+
if (window.setRouteData) {{
|
|
273
|
+
window.setRouteData(data);
|
|
274
|
+
}}
|
|
275
|
+
}});
|
|
276
|
+
</script>
|
|
277
|
+
</body>
|
|
278
|
+
</html>"""
|
|
279
|
+
|
|
280
|
+
def _render_builtin_viewer(self, payload: Dict[str, Any]) -> str:
|
|
281
|
+
"""Render the dependency-free interactive report bundled with the SDK."""
|
|
282
|
+
safe_profile_name = escape(payload["profile_name"], quote=True)
|
|
283
|
+
accent = payload["profile_color"]
|
|
284
|
+
if not (isinstance(accent, str) and accent.startswith("#") and len(accent) in (4, 7)):
|
|
285
|
+
accent = "#38bdf8"
|
|
286
|
+
|
|
287
|
+
view_payload = {
|
|
288
|
+
"coordinates": payload["coordinates"],
|
|
289
|
+
"elevationProfile": payload["elevation_profile"],
|
|
290
|
+
"cueSheet": payload["cue_sheet"],
|
|
291
|
+
"profileName": payload["profile_name"],
|
|
292
|
+
"accent": accent,
|
|
293
|
+
"stats": {
|
|
294
|
+
"distance_km": payload["distance_km"],
|
|
295
|
+
"duration_min": payload["duration_min"],
|
|
296
|
+
"climb_m": payload["climb_m"],
|
|
297
|
+
"descent_m": payload["descent_m"],
|
|
298
|
+
"max_slope_pct": payload["max_slope_pct"],
|
|
299
|
+
"calories_kcal": payload["calories_kcal"],
|
|
300
|
+
"ada_compliant": payload["ada_compliant"],
|
|
301
|
+
},
|
|
302
|
+
"geojson": payload["geojson"],
|
|
303
|
+
}
|
|
304
|
+
json_str = self._embedded_json(view_payload)
|
|
305
|
+
empty_state = "" if payload["coordinates"] else "is-empty"
|
|
306
|
+
|
|
307
|
+
return f"""<!DOCTYPE html>
|
|
308
|
+
<html lang="en">
|
|
309
|
+
<head>
|
|
310
|
+
<meta charset="UTF-8">
|
|
311
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
312
|
+
<title>02Route 3D Report - {safe_profile_name}</title>
|
|
313
|
+
<style>
|
|
314
|
+
{_BUILTIN_CSS.replace("__ACCENT__", accent)}
|
|
315
|
+
</style>
|
|
316
|
+
</head>
|
|
317
|
+
<body class="{empty_state}">
|
|
318
|
+
<header class="topbar">
|
|
319
|
+
<div class="brand">
|
|
320
|
+
<span class="badge">OFFLINE REPORT</span>
|
|
321
|
+
<h1>02Route 3D — {safe_profile_name}</h1>
|
|
322
|
+
</div>
|
|
323
|
+
<div class="stats" id="statsRow"></div>
|
|
324
|
+
</header>
|
|
325
|
+
|
|
326
|
+
<main class="grid">
|
|
327
|
+
<section class="panel">
|
|
328
|
+
<h2>Plan view</h2>
|
|
329
|
+
<div class="plot-wrap"><svg id="planView" preserveAspectRatio="xMidYMid meet"></svg></div>
|
|
330
|
+
<p class="hint">Slope-coloured centreline. Green = descent, grey = flat, red = climb.</p>
|
|
331
|
+
</section>
|
|
332
|
+
<section class="panel">
|
|
333
|
+
<h2>Longitudinal profile</h2>
|
|
334
|
+
<div class="plot-wrap"><svg id="profileView" preserveAspectRatio="none"></svg></div>
|
|
335
|
+
<div class="scrub-row">
|
|
336
|
+
<input type="range" id="scrubber" min="0" max="1000" value="0" aria-label="Position along route">
|
|
337
|
+
<output id="readout"></output>
|
|
338
|
+
</div>
|
|
339
|
+
</section>
|
|
340
|
+
</main>
|
|
341
|
+
|
|
342
|
+
<section class="panel cues" id="cuePanel">
|
|
343
|
+
<h2>Cue sheet</h2>
|
|
344
|
+
<ol id="cueList"></ol>
|
|
345
|
+
</section>
|
|
346
|
+
|
|
347
|
+
<p class="empty-note">No route geometry was supplied, so there is nothing to draw.</p>
|
|
348
|
+
|
|
349
|
+
<script>
|
|
350
|
+
const ROUTE = {json_str};
|
|
351
|
+
{_BUILTIN_JS}
|
|
352
|
+
</script>
|
|
353
|
+
</body>
|
|
354
|
+
</html>"""
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
_BUILTIN_CSS = """
|
|
358
|
+
:root {
|
|
359
|
+
color-scheme: dark;
|
|
360
|
+
--accent: __ACCENT__;
|
|
361
|
+
--bg: #0b1220;
|
|
362
|
+
--panel: #131c2e;
|
|
363
|
+
--line: #24314b;
|
|
364
|
+
--text: #e6edf7;
|
|
365
|
+
--muted: #94a3b8;
|
|
366
|
+
}
|
|
367
|
+
* { box-sizing: border-box; }
|
|
368
|
+
body {
|
|
369
|
+
margin: 0;
|
|
370
|
+
padding: 0 24px 32px;
|
|
371
|
+
background: var(--bg);
|
|
372
|
+
color: var(--text);
|
|
373
|
+
font: 14px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
374
|
+
}
|
|
375
|
+
.topbar {
|
|
376
|
+
display: flex; flex-wrap: wrap; gap: 16px; align-items: center;
|
|
377
|
+
justify-content: space-between; padding: 20px 0 16px;
|
|
378
|
+
border-bottom: 1px solid var(--line);
|
|
379
|
+
}
|
|
380
|
+
.brand h1 { margin: 6px 0 0; font-size: 20px; font-weight: 700; }
|
|
381
|
+
.badge {
|
|
382
|
+
display: inline-block; padding: 2px 8px; border-radius: 999px;
|
|
383
|
+
background: var(--accent); color: #05111f; font-size: 11px;
|
|
384
|
+
font-weight: 700; letter-spacing: .06em;
|
|
385
|
+
}
|
|
386
|
+
.stats { display: flex; flex-wrap: wrap; gap: 10px; }
|
|
387
|
+
.stat {
|
|
388
|
+
background: var(--panel); border: 1px solid var(--line); border-radius: 8px;
|
|
389
|
+
padding: 8px 12px; min-width: 96px;
|
|
390
|
+
}
|
|
391
|
+
.stat span { display: block; font-size: 11px; color: var(--muted); }
|
|
392
|
+
.stat strong { font-size: 15px; font-variant-numeric: tabular-nums; }
|
|
393
|
+
.grid { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); margin-top: 20px; }
|
|
394
|
+
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 16px; }
|
|
395
|
+
.panel h2 { margin: 0 0 12px; font-size: 13px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); }
|
|
396
|
+
.plot-wrap { width: 100%; overflow-x: auto; }
|
|
397
|
+
svg { width: 100%; height: 280px; display: block; }
|
|
398
|
+
.hint { margin: 8px 0 0; font-size: 12px; color: var(--muted); }
|
|
399
|
+
.scrub-row { display: flex; gap: 12px; align-items: center; margin-top: 12px; }
|
|
400
|
+
.scrub-row input { flex: 1; accent-color: var(--accent); }
|
|
401
|
+
.scrub-row output { font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
402
|
+
.cues { margin-top: 16px; }
|
|
403
|
+
.cues ol { margin: 0; padding-left: 20px; max-height: 260px; overflow-y: auto; }
|
|
404
|
+
.cues li { padding: 4px 0; border-bottom: 1px solid var(--line); }
|
|
405
|
+
.cues li:last-child { border-bottom: 0; }
|
|
406
|
+
.cue-dist { color: var(--muted); font-variant-numeric: tabular-nums; margin-right: 8px; }
|
|
407
|
+
.empty-note { display: none; margin-top: 24px; color: var(--muted); }
|
|
408
|
+
body.is-empty .grid, body.is-empty .cues { display: none; }
|
|
409
|
+
body.is-empty .empty-note { display: block; }
|
|
410
|
+
@media print {
|
|
411
|
+
body { background: #fff; color: #111; }
|
|
412
|
+
.panel { background: #fff; border-color: #ccc; }
|
|
413
|
+
}
|
|
414
|
+
"""
|
|
415
|
+
|
|
416
|
+
_BUILTIN_JS = """
|
|
417
|
+
(function () {
|
|
418
|
+
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
419
|
+
const coords = Array.isArray(ROUTE.coordinates) ? ROUTE.coordinates : [];
|
|
420
|
+
const accent = ROUTE.accent || '#38bdf8';
|
|
421
|
+
|
|
422
|
+
function el(name, attrs) {
|
|
423
|
+
const node = document.createElementNS(SVG_NS, name);
|
|
424
|
+
for (const key in attrs) node.setAttribute(key, attrs[key]);
|
|
425
|
+
return node;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function fmt(value, digits) {
|
|
429
|
+
return Number.isFinite(value) ? value.toFixed(digits) : '-';
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const s = ROUTE.stats || {};
|
|
433
|
+
const statsRow = document.getElementById('statsRow');
|
|
434
|
+
[
|
|
435
|
+
['Distance', fmt(s.distance_km, 2) + ' km'],
|
|
436
|
+
['Duration', fmt(s.duration_min, 1) + ' min'],
|
|
437
|
+
['Climb', '+' + fmt(s.climb_m, 1) + ' m'],
|
|
438
|
+
['Descent', '-' + fmt(s.descent_m, 1) + ' m'],
|
|
439
|
+
['Max slope', fmt(s.max_slope_pct, 1) + '%'],
|
|
440
|
+
['Energy', s.calories_kcal ? fmt(s.calories_kcal, 0) + ' kcal' : 'n/a'],
|
|
441
|
+
['ADA', s.ada_compliant ? 'Compliant' : 'Not compliant']
|
|
442
|
+
].forEach(function (pair) {
|
|
443
|
+
const box = document.createElement('div');
|
|
444
|
+
box.className = 'stat';
|
|
445
|
+
const label = document.createElement('span');
|
|
446
|
+
label.textContent = pair[0];
|
|
447
|
+
const value = document.createElement('strong');
|
|
448
|
+
value.textContent = pair[1];
|
|
449
|
+
box.appendChild(label);
|
|
450
|
+
box.appendChild(value);
|
|
451
|
+
statsRow.appendChild(box);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
const cueList = document.getElementById('cueList');
|
|
455
|
+
const cues = Array.isArray(ROUTE.cueSheet) ? ROUTE.cueSheet : [];
|
|
456
|
+
if (!cues.length) {
|
|
457
|
+
document.getElementById('cuePanel').style.display = 'none';
|
|
458
|
+
} else {
|
|
459
|
+
cues.forEach(function (cue) {
|
|
460
|
+
const li = document.createElement('li');
|
|
461
|
+
const dist = document.createElement('span');
|
|
462
|
+
dist.className = 'cue-dist';
|
|
463
|
+
const metres = Number(cue.distance_m || cue.cumulative_distance_m || 0);
|
|
464
|
+
dist.textContent = Number.isFinite(metres) ? metres.toFixed(0) + ' m' : '';
|
|
465
|
+
li.appendChild(dist);
|
|
466
|
+
li.appendChild(document.createTextNode(
|
|
467
|
+
cue.instruction || cue.text || cue.street_name || 'Continue'
|
|
468
|
+
));
|
|
469
|
+
cueList.appendChild(li);
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (coords.length < 2) return;
|
|
474
|
+
|
|
475
|
+
// Cumulative distance and per-vertex slope, from the geometry itself.
|
|
476
|
+
const R = 6371000;
|
|
477
|
+
const rad = Math.PI / 180;
|
|
478
|
+
const cum = [0];
|
|
479
|
+
const slopes = [0];
|
|
480
|
+
for (let i = 1; i < coords.length; i++) {
|
|
481
|
+
const a = coords[i - 1];
|
|
482
|
+
const b = coords[i];
|
|
483
|
+
const meanLat = (a[1] + b[1]) * 0.5 * rad;
|
|
484
|
+
const dx = (b[0] - a[0]) * rad * Math.cos(meanLat) * R;
|
|
485
|
+
const dy = (b[1] - a[1]) * rad * R;
|
|
486
|
+
const step = Math.sqrt(dx * dx + dy * dy);
|
|
487
|
+
cum.push(cum[i - 1] + step);
|
|
488
|
+
slopes.push(step > 0.01 ? ((b[2] - a[2]) / step) * 100 : 0);
|
|
489
|
+
}
|
|
490
|
+
const total = cum[cum.length - 1] || 1;
|
|
491
|
+
|
|
492
|
+
function slopeColor(pct) {
|
|
493
|
+
if (pct > 2) return '#ef4444';
|
|
494
|
+
if (pct < -2) return '#22c55e';
|
|
495
|
+
return '#94a3b8';
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// -- plan view --------------------------------------------------------
|
|
499
|
+
const plan = document.getElementById('planView');
|
|
500
|
+
const W = 600, H = 280, PAD = 24;
|
|
501
|
+
plan.setAttribute('viewBox', '0 0 ' + W + ' ' + H);
|
|
502
|
+
|
|
503
|
+
let minLon = Infinity, maxLon = -Infinity, minLat = Infinity, maxLat = -Infinity;
|
|
504
|
+
coords.forEach(function (c) {
|
|
505
|
+
minLon = Math.min(minLon, c[0]); maxLon = Math.max(maxLon, c[0]);
|
|
506
|
+
minLat = Math.min(minLat, c[1]); maxLat = Math.max(maxLat, c[1]);
|
|
507
|
+
});
|
|
508
|
+
const latScale = Math.cos(((minLat + maxLat) / 2) * rad) || 1;
|
|
509
|
+
const spanX = Math.max((maxLon - minLon) * latScale, 1e-9);
|
|
510
|
+
const spanY = Math.max(maxLat - minLat, 1e-9);
|
|
511
|
+
const scale = Math.min((W - 2 * PAD) / spanX, (H - 2 * PAD) / spanY);
|
|
512
|
+
const offX = (W - spanX * scale) / 2;
|
|
513
|
+
const offY = (H - spanY * scale) / 2;
|
|
514
|
+
|
|
515
|
+
function planX(lon) { return offX + (lon - minLon) * latScale * scale; }
|
|
516
|
+
function planY(lat) { return H - offY - (lat - minLat) * scale; }
|
|
517
|
+
|
|
518
|
+
for (let i = 1; i < coords.length; i++) {
|
|
519
|
+
plan.appendChild(el('line', {
|
|
520
|
+
x1: planX(coords[i - 1][0]), y1: planY(coords[i - 1][1]),
|
|
521
|
+
x2: planX(coords[i][0]), y2: planY(coords[i][1]),
|
|
522
|
+
stroke: slopeColor(slopes[i]), 'stroke-width': 3, 'stroke-linecap': 'round'
|
|
523
|
+
}));
|
|
524
|
+
}
|
|
525
|
+
[[coords[0], '#22c55e', 'A'], [coords[coords.length - 1], '#ef4444', 'B']]
|
|
526
|
+
.forEach(function (marker) {
|
|
527
|
+
plan.appendChild(el('circle', {
|
|
528
|
+
cx: planX(marker[0][0]), cy: planY(marker[0][1]), r: 6,
|
|
529
|
+
fill: marker[1], stroke: '#0b1220', 'stroke-width': 2
|
|
530
|
+
}));
|
|
531
|
+
const label = el('text', {
|
|
532
|
+
x: planX(marker[0][0]), y: planY(marker[0][1]) - 11,
|
|
533
|
+
fill: '#e6edf7', 'font-size': 12, 'text-anchor': 'middle'
|
|
534
|
+
});
|
|
535
|
+
label.textContent = marker[2];
|
|
536
|
+
plan.appendChild(label);
|
|
537
|
+
});
|
|
538
|
+
const planMarker = el('circle', { cx: planX(coords[0][0]), cy: planY(coords[0][1]), r: 5,
|
|
539
|
+
fill: accent, stroke: '#0b1220', 'stroke-width': 2 });
|
|
540
|
+
plan.appendChild(planMarker);
|
|
541
|
+
|
|
542
|
+
// -- longitudinal profile --------------------------------------------
|
|
543
|
+
const prof = document.getElementById('profileView');
|
|
544
|
+
prof.setAttribute('viewBox', '0 0 ' + W + ' ' + H);
|
|
545
|
+
let minZ = Infinity, maxZ = -Infinity;
|
|
546
|
+
coords.forEach(function (c) { minZ = Math.min(minZ, c[2]); maxZ = Math.max(maxZ, c[2]); });
|
|
547
|
+
if (!(maxZ > minZ)) { maxZ = minZ + 1; }
|
|
548
|
+
|
|
549
|
+
function profX(d) { return PAD + (d / total) * (W - 2 * PAD); }
|
|
550
|
+
function profY(z) { return H - PAD - ((z - minZ) / (maxZ - minZ)) * (H - 2 * PAD); }
|
|
551
|
+
|
|
552
|
+
let area = 'M ' + profX(0) + ' ' + (H - PAD);
|
|
553
|
+
coords.forEach(function (c, i) { area += ' L ' + profX(cum[i]) + ' ' + profY(c[2]); });
|
|
554
|
+
area += ' L ' + profX(total) + ' ' + (H - PAD) + ' Z';
|
|
555
|
+
prof.appendChild(el('path', { d: area, fill: accent, 'fill-opacity': 0.18 }));
|
|
556
|
+
|
|
557
|
+
for (let i = 1; i < coords.length; i++) {
|
|
558
|
+
prof.appendChild(el('line', {
|
|
559
|
+
x1: profX(cum[i - 1]), y1: profY(coords[i - 1][2]),
|
|
560
|
+
x2: profX(cum[i]), y2: profY(coords[i][2]),
|
|
561
|
+
stroke: slopeColor(slopes[i]), 'stroke-width': 2.5
|
|
562
|
+
}));
|
|
563
|
+
}
|
|
564
|
+
[minZ, maxZ].forEach(function (z) {
|
|
565
|
+
prof.appendChild(el('line', {
|
|
566
|
+
x1: PAD, y1: profY(z), x2: W - PAD, y2: profY(z),
|
|
567
|
+
stroke: '#24314b', 'stroke-width': 1, 'stroke-dasharray': '4 4'
|
|
568
|
+
}));
|
|
569
|
+
const t = el('text', { x: 4, y: profY(z) + 4, fill: '#94a3b8', 'font-size': 11 });
|
|
570
|
+
t.textContent = z.toFixed(0) + ' m';
|
|
571
|
+
prof.appendChild(t);
|
|
572
|
+
});
|
|
573
|
+
const needle = el('line', { x1: profX(0), y1: PAD, x2: profX(0), y2: H - PAD,
|
|
574
|
+
stroke: accent, 'stroke-width': 1.5 });
|
|
575
|
+
prof.appendChild(needle);
|
|
576
|
+
|
|
577
|
+
// -- scrubber ---------------------------------------------------------
|
|
578
|
+
const scrubber = document.getElementById('scrubber');
|
|
579
|
+
const readout = document.getElementById('readout');
|
|
580
|
+
|
|
581
|
+
function update(fraction) {
|
|
582
|
+
const target = fraction * total;
|
|
583
|
+
let i = 1;
|
|
584
|
+
while (i < cum.length - 1 && cum[i] < target) i++;
|
|
585
|
+
const span = cum[i] - cum[i - 1] || 1;
|
|
586
|
+
const t = Math.max(0, Math.min(1, (target - cum[i - 1]) / span));
|
|
587
|
+
const lon = coords[i - 1][0] + (coords[i][0] - coords[i - 1][0]) * t;
|
|
588
|
+
const lat = coords[i - 1][1] + (coords[i][1] - coords[i - 1][1]) * t;
|
|
589
|
+
const z = coords[i - 1][2] + (coords[i][2] - coords[i - 1][2]) * t;
|
|
590
|
+
|
|
591
|
+
planMarker.setAttribute('cx', planX(lon));
|
|
592
|
+
planMarker.setAttribute('cy', planY(lat));
|
|
593
|
+
needle.setAttribute('x1', profX(target));
|
|
594
|
+
needle.setAttribute('x2', profX(target));
|
|
595
|
+
readout.textContent =
|
|
596
|
+
(target / 1000).toFixed(2) + ' km / ' + z.toFixed(1) + ' m / ' +
|
|
597
|
+
slopes[i].toFixed(1) + '%';
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
scrubber.addEventListener('input', function () { update(this.value / 1000); });
|
|
601
|
+
update(0);
|
|
602
|
+
})();
|
|
603
|
+
"""
|