mirror of
https://github.com/sunnypilot/sunnypilot.git
synced 2026-07-21 06:02:29 +08:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2bf5f45ffe | |||
| f76da384cd |
@@ -12,3 +12,6 @@
|
||||
|
||||
selfdrive/car/tests/test_models_segs.txt filter=lfs diff=lfs merge=lfs -text
|
||||
common/hardware/tici/updater filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
# Small vendored Leaflet UI icons are kept directly in Git.
|
||||
tools/clustermaps/web/vendor/leaflet/images/*.png -filter -diff -merge -text
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
# clustermaps
|
||||
|
||||
clustermaps is an offline, read-only route-analysis tool for full openpilot/sunnypilot rlogs. It converts high-rate IMU, vehicle-state, pose, and GPS messages into a compact per-route JSON cache, GeoJSON exports, and a static HTML5 map.
|
||||
|
||||
## Quick start
|
||||
|
||||
From the repository root:
|
||||
|
||||
```bash
|
||||
.venv/bin/python tools/clustermaps/build_map.py \
|
||||
0000000000000000/00000000--0000000000 \
|
||||
--label "Complete sample route · 13 segments" \
|
||||
--serve
|
||||
```
|
||||
|
||||
Then open [http://127.0.0.1:8765](http://127.0.0.1:8765).
|
||||
|
||||
The page includes a local Route Manager. Paste a route, load its available segment list, select segments, and watch the checkpointed import progress. Imports survive a server restart: each completed segment is stored separately under the gitignored data directory, and the next `--serve` run resumes unfinished jobs before assembling one route-level map entry.
|
||||
|
||||
The identifier determines how much is imported:
|
||||
|
||||
- `DONGLE/ROUTE` imports every available segment in the route.
|
||||
- `DONGLE/ROUTE/3` imports only segment 3.
|
||||
- `DONGLE/ROUTE/0:5` imports the half-open segment range 0 through 4.
|
||||
|
||||
Import additional routes by passing more identifiers or by running the command again. Existing cached routes remain in the atlas, so this is how observations from multiple drives and drivers accumulate:
|
||||
|
||||
```bash
|
||||
.venv/bin/python tools/clustermaps/build_map.py ROUTE_A ROUTE_B ROUTE_C
|
||||
```
|
||||
|
||||
Inspect or remove local entries using their privacy-preserving cache ids:
|
||||
|
||||
```bash
|
||||
.venv/bin/python tools/clustermaps/build_map.py --list
|
||||
.venv/bin/python tools/clustermaps/build_map.py --remove CACHE_ID
|
||||
```
|
||||
|
||||
Routes can also be removed from the webpage with the × control on each drive card. Removal only deletes the derived local JSON and GeoJSON entry; it never changes the source rlogs.
|
||||
|
||||
If the server is already running, import from a second terminal and refresh the browser. The server disables HTTP caching so updated route indexes and scripts are picked up immediately.
|
||||
|
||||
Only full rlogs are accepted. `LogReader` is explicitly configured for `ReadMode.RLOG`; it never falls back to qlogs because qlog IMU decimation is too aggressive for impact analysis.
|
||||
|
||||
## Cache and exports
|
||||
|
||||
Generated files live under `web/data/` and are intentionally gitignored because they contain precise route geometry:
|
||||
|
||||
- `index.json`: the multi-route manifest and cross-route repeat-location clusters.
|
||||
- `routes/<id>.json`: compact track, layer, event, diagnostic, and source-coverage data.
|
||||
- `geojson/<id>.geojson`: route line plus event and rough-road point features.
|
||||
- `repeated_locations.geojson`: locations independently observed on at least two cached routes.
|
||||
|
||||
Route names are retained in the local browser cache for recognizable route management; cache ids and driver ids remain hashed, and a friendly `--label` can be supplied. Treat `web/data/` as private because route names and map geometry identify drives. The browser loads compact derived files and never downloads, decompresses, or parses rlogs itself.
|
||||
|
||||
## Device rlogs
|
||||
|
||||
To copy full rlogs from a reachable comma without modifying the device:
|
||||
|
||||
```bash
|
||||
mkdir -p tools/clustermaps/private/device_rlogs/DEVICE
|
||||
rsync -a --partial --progress \
|
||||
-e 'ssh -o BatchMode=yes -o ConnectTimeout=5' \
|
||||
--include='*/' --include='rlog.*' --exclude='*' \
|
||||
comma@DEVICE:/data/media/0/realdata/ \
|
||||
tools/clustermaps/private/device_rlogs/DEVICE/
|
||||
```
|
||||
|
||||
The transfer is resumable and copies only rlogs—no camera files or qlogs. Analyze the copied route directories with:
|
||||
|
||||
```bash
|
||||
.venv/bin/python tools/clustermaps/build_map.py \
|
||||
--import-device-dir tools/clustermaps/private/device_rlogs/DEVICE \
|
||||
--device-key YOUR_DEVICE_KEY
|
||||
```
|
||||
|
||||
Device analysis also checkpoints one derived file per segment, so rerunning the command skips completed work. The copied rlogs and derived checkpoints are gitignored.
|
||||
|
||||
## Layers
|
||||
|
||||
- **Route traces:** GPS-derived path, colored per imported route.
|
||||
- **Road roughness:** two-second RMS windows of high-pass-filtered vertical acceleration.
|
||||
- **Sharp impacts:** route-adaptive vertical IMU peaks while the vehicle is moving.
|
||||
- **Hard acceleration / braking:** sustained, low-pass-filtered `carState.aEgo` threshold crossings.
|
||||
- **Body motion:** roll/pitch angular-rate activity from `livePose`, excluding normal yaw motion.
|
||||
- **GPS samples:** the exact absolute-position cadence used for interpolation.
|
||||
- **IMU detail:** geolocated accelerometer and gyroscope components plus magnitudes.
|
||||
- **Vehicle dynamics:** speed, acceleration, pedals, steering, yaw, wheel speeds, blinkers, and blind-spot state where present.
|
||||
- **Pose and visual odometry:** fused device motion and camera-derived translation/rotation.
|
||||
- **Controls and calibration:** desired/estimated curvature and learned roll, steering ratio, stiffness, and angle offset.
|
||||
- **Road vision:** compact lane probabilities, lane width, and model action curvature.
|
||||
- **Radar:** primary lead distance, relative speed, lateral position, and track status.
|
||||
- **Sound, magnetic, thermal, and power:** cabin sound pressure, magnetic field, sensor temperature, device temperature, battery, and utilization.
|
||||
- **Repeated locations:** impact or roughness observations clustered within 20 meters across two or more cached routes.
|
||||
|
||||
The “All recorded services” panel inventories every service found in every imported rlog, including services not copied into the browser cache. High-volume or sensitive payloads such as raw CAN, raw GNSS measurements, full vision-model tensors, camera/encoder metadata, driver-monitoring output, and system logs remain inventory-only. This exposes what the source logs contain without turning the local map into a raw-log mirror.
|
||||
|
||||
The basemap selector offers OpenStreetMap streets, Esri World Imagery satellite tiles, and a neutral coordinate-grid data-only canvas. Streets and Satellite require a network connection; Data only leaves every local sensor overlay working without tile requests. The selected mode is remembered in the browser.
|
||||
|
||||
The Visuals panel includes Point bleed. Roughness samples are interpolated into one low-resolution scalar field with a Gaussian edge falloff and a continuous color ramp across every selected route; other point layers use a softly blurred density canvas. The Heat, Corridor, and Night presets enable it automatically; Signal keeps precise individual markers.
|
||||
|
||||
## Visualization styles
|
||||
|
||||
Open **Visuals** above the map to switch between:
|
||||
|
||||
- **Signal:** precise centerline rendering with the least visual bleed.
|
||||
- **Heat:** coverage glow plus overlapping impact halos.
|
||||
- **Corridor:** extra-wide road bands for area-scale coverage inspection.
|
||||
- **Night:** high-contrast street tiles with bright sensor overlays.
|
||||
|
||||
Road width and signal intensity can be adjusted independently. Coverage glow, impact heat halos, and the high-contrast map can also be combined manually; display preferences are kept in local browser storage.
|
||||
|
||||
## Interpretation
|
||||
|
||||
The current thresholds are screening heuristics, not labeled pothole classifications. Vehicle suspension, tire pressure, device mount, road speed, and sensor generation all affect magnitude. Repeated observations across independent passes are stronger evidence than a single adaptive-threshold event.
|
||||
|
||||
For calibration, keep ordinary full rlogs and record ground-truth route times for known potholes, expansion joints, speed bumps, and false positives. Device or logger changes should only be considered if validation reveals clipping, missing samples, or inadequate positioning.
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Offline road-quality analysis tools for openpilot routes."""
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Executable
+395
@@ -0,0 +1,395 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import http.server
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import socketserver
|
||||
import threading
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from openpilot.tools.clustermaps.analyzer import (
|
||||
AnalyzerConfig,
|
||||
analyze_route,
|
||||
merge_segment_routes,
|
||||
rebuild_index,
|
||||
write_route_cache,
|
||||
)
|
||||
from openpilot.tools.lib.route import SegmentRange
|
||||
|
||||
|
||||
TOOL_ROOT = Path(__file__).resolve().parent
|
||||
DEFAULT_CACHE_DIR = TOOL_ROOT / "web" / "data"
|
||||
CACHE_ID_PATTERN = re.compile(r"[0-9a-f]{16}")
|
||||
DEVICE_SEGMENT_PATTERN = re.compile(r"(?P<route>.+)--(?P<segment>[0-9]+)")
|
||||
IMPORT_JOBS: dict[str, dict] = {}
|
||||
IMPORT_JOBS_LOCK = threading.Lock()
|
||||
CACHE_WRITE_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _public_job(job: dict) -> dict:
|
||||
return {key: value for key, value in job.items() if not key.startswith("_")}
|
||||
|
||||
|
||||
def _persist_job(job: dict) -> None:
|
||||
manifest_path = Path(job["_manifestPath"])
|
||||
manifest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary_path = manifest_path.with_suffix(".tmp")
|
||||
temporary_path.write_text(json.dumps(_public_job(job), separators=(",", ":")))
|
||||
temporary_path.replace(manifest_path)
|
||||
|
||||
|
||||
def _update_job(job_id: str, **updates) -> None:
|
||||
with IMPORT_JOBS_LOCK:
|
||||
IMPORT_JOBS[job_id].update(updates)
|
||||
_persist_job(IMPORT_JOBS[job_id])
|
||||
|
||||
|
||||
def _run_import_job(job_id: str, route_identifier: str, label: str | None, segments: list[int], cache_dir: Path) -> None:
|
||||
try:
|
||||
available = SegmentRange(route_identifier).seg_idxs
|
||||
cache_key = route_identifier if segments == available else f"{route_identifier}/segments={','.join(map(str, segments))}"
|
||||
route_cache_id = hashlib.sha256(cache_key.encode()).hexdigest()[:16]
|
||||
segment_dir = cache_dir / "route_analysis" / route_cache_id / "segments"
|
||||
segment_dir.mkdir(parents=True, exist_ok=True)
|
||||
analyzed_segments: list[dict] = []
|
||||
_update_job(job_id, status="running", stage="Locating full rlogs", percent=2)
|
||||
for position, segment_index in enumerate(segments, start=1):
|
||||
segment_path = segment_dir / f"{segment_index}.json"
|
||||
if segment_path.exists():
|
||||
try:
|
||||
segment_route = json.loads(segment_path.read_text())
|
||||
except (json.JSONDecodeError, OSError):
|
||||
segment_path.unlink(missing_ok=True)
|
||||
segment_route = None
|
||||
else:
|
||||
segment_route = None
|
||||
if segment_route is None:
|
||||
_update_job(
|
||||
job_id,
|
||||
stage=f"Analyzing segment {segment_index} ({position} of {len(segments)})",
|
||||
percent=round(80 * (position - 1) / len(segments)),
|
||||
)
|
||||
segment_route = analyze_route(route_identifier, segment_indexes=[segment_index])
|
||||
temporary_path = segment_path.with_suffix(".tmp")
|
||||
temporary_path.write_text(json.dumps(segment_route, separators=(",", ":")))
|
||||
temporary_path.replace(segment_path)
|
||||
analyzed_segments.append(segment_route)
|
||||
_update_job(
|
||||
job_id,
|
||||
stage=f"Checkpointed segment {position} of {len(segments)}",
|
||||
completedSegments=position,
|
||||
percent=round(80 * position / len(segments)),
|
||||
)
|
||||
|
||||
_update_job(job_id, stage="Assembling route and map layers", percent=88)
|
||||
route = merge_segment_routes(
|
||||
route_identifier,
|
||||
analyzed_segments,
|
||||
label=label,
|
||||
cache_key=cache_key,
|
||||
segment_indexes=segments,
|
||||
)
|
||||
with CACHE_WRITE_LOCK:
|
||||
route_path, geojson_path = write_route_cache(route, cache_dir)
|
||||
_update_job(
|
||||
job_id,
|
||||
status="complete",
|
||||
stage="Ready",
|
||||
percent=100,
|
||||
cacheId=route["id"],
|
||||
routeFile=route_path.name,
|
||||
geojsonFile=geojson_path.name,
|
||||
summary=route["summary"],
|
||||
)
|
||||
except Exception as error:
|
||||
_update_job(job_id, status="error", stage="Import failed", error=str(error))
|
||||
|
||||
|
||||
def _load_and_resume_jobs(cache_dir: Path) -> None:
|
||||
imports_dir = cache_dir / "imports"
|
||||
for manifest_path in imports_dir.glob("*/job.json"):
|
||||
try:
|
||||
job = json.loads(manifest_path.read_text())
|
||||
except (json.JSONDecodeError, OSError):
|
||||
continue
|
||||
job["_manifestPath"] = str(manifest_path)
|
||||
with IMPORT_JOBS_LOCK:
|
||||
IMPORT_JOBS[job["id"]] = job
|
||||
if job["status"] in ("queued", "running"):
|
||||
_update_job(job["id"], status="queued", stage="Resuming from segment checkpoints")
|
||||
threading.Thread(
|
||||
target=_run_import_job,
|
||||
args=(job["id"], job["route"], job.get("label"), job["segments"], cache_dir),
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
|
||||
class RoadQualityRequestHandler(http.server.SimpleHTTPRequestHandler):
|
||||
cache_dir = DEFAULT_CACHE_DIR
|
||||
|
||||
def end_headers(self) -> None:
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
super().end_headers()
|
||||
|
||||
def _json_response(self, payload: dict | list, status: int = 200) -> None:
|
||||
encoded = json.dumps(payload).encode()
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(encoded)))
|
||||
self.end_headers()
|
||||
self.wfile.write(encoded)
|
||||
|
||||
def _error_response(self, message: str, status: int = 400) -> None:
|
||||
self._json_response({"error": message}, status)
|
||||
|
||||
def _read_json(self) -> dict:
|
||||
if self.headers.get("X-Road-Quality-Request") != "1":
|
||||
raise ValueError("Missing local request header")
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
if length <= 0 or length > 65_536:
|
||||
raise ValueError("Invalid request size")
|
||||
payload = json.loads(self.rfile.read(length))
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("Expected a JSON object")
|
||||
return payload
|
||||
|
||||
def do_GET(self) -> None:
|
||||
parsed = urlparse(self.path)
|
||||
if parsed.path.startswith(("/data/device_rlogs/", "/data/device_analysis/", "/data/route_analysis/", "/data/imports/")):
|
||||
self._error_response("Private analysis data is not browser-accessible", 404)
|
||||
return
|
||||
if parsed.path == "/api/segments":
|
||||
try:
|
||||
route_identifier = parse_qs(parsed.query).get("route", [""])[0].strip()
|
||||
segment_range = SegmentRange(route_identifier)
|
||||
base_identifier = f"{segment_range.dongle_id}/{segment_range.log_id}"
|
||||
segments = SegmentRange(base_identifier).seg_idxs
|
||||
self._json_response({
|
||||
"route": base_identifier,
|
||||
"segmentCount": len(segments),
|
||||
"segments": [{"index": index, "label": f"Segment {index}"} for index in segments],
|
||||
})
|
||||
except Exception as error:
|
||||
self._error_response(str(error))
|
||||
return
|
||||
if parsed.path.startswith("/api/jobs/"):
|
||||
job_id = parsed.path.removeprefix("/api/jobs/")
|
||||
with IMPORT_JOBS_LOCK:
|
||||
job = IMPORT_JOBS.get(job_id)
|
||||
payload = _public_job(job) if job is not None else None
|
||||
if payload is None:
|
||||
self._error_response("Import job not found", 404)
|
||||
else:
|
||||
self._json_response(payload)
|
||||
return
|
||||
super().do_GET()
|
||||
|
||||
def do_POST(self) -> None:
|
||||
try:
|
||||
payload = self._read_json()
|
||||
if self.path == "/api/import":
|
||||
route_identifier = str(payload.get("route", "")).strip()
|
||||
segment_range = SegmentRange(route_identifier)
|
||||
base_identifier = f"{segment_range.dongle_id}/{segment_range.log_id}"
|
||||
available = SegmentRange(base_identifier).seg_idxs
|
||||
raw_segments = payload.get("segments", available)
|
||||
if not isinstance(raw_segments, list) or any(not isinstance(index, int) for index in raw_segments):
|
||||
raise ValueError("segments must be a list of integer indexes")
|
||||
segments = sorted(set(raw_segments))
|
||||
if not segments or any(index not in available for index in segments):
|
||||
raise ValueError("Select at least one available segment")
|
||||
label_value = payload.get("label")
|
||||
label = str(label_value).strip()[:120] if label_value else None
|
||||
job_id = secrets.token_hex(8)
|
||||
manifest_path = self.cache_dir / "imports" / job_id / "job.json"
|
||||
with IMPORT_JOBS_LOCK:
|
||||
IMPORT_JOBS[job_id] = {
|
||||
"id": job_id,
|
||||
"route": base_identifier,
|
||||
"label": label,
|
||||
"segments": segments,
|
||||
"status": "queued",
|
||||
"stage": "Queued",
|
||||
"percent": 0,
|
||||
"completedSegments": 0,
|
||||
"totalSegments": len(segments),
|
||||
"_manifestPath": str(manifest_path),
|
||||
}
|
||||
_persist_job(IMPORT_JOBS[job_id])
|
||||
threading.Thread(
|
||||
target=_run_import_job,
|
||||
args=(job_id, base_identifier, label, segments, self.cache_dir),
|
||||
daemon=True,
|
||||
).start()
|
||||
self._json_response({"jobId": job_id}, 202)
|
||||
return
|
||||
if self.path == "/api/remove":
|
||||
cache_id = str(payload.get("cacheId", ""))
|
||||
if CACHE_ID_PATTERN.fullmatch(cache_id) is None:
|
||||
raise ValueError("Invalid cache id")
|
||||
with CACHE_WRITE_LOCK:
|
||||
remove_routes([cache_id], self.cache_dir, AnalyzerConfig())
|
||||
self._json_response({"removed": cache_id})
|
||||
return
|
||||
self._error_response("API endpoint not found", 404)
|
||||
except (AssertionError, ValueError, json.JSONDecodeError) as error:
|
||||
self._error_response(str(error))
|
||||
|
||||
|
||||
def import_routes(routes: list[str], cache_dir: Path, label: str | None) -> None:
|
||||
for identifier in routes:
|
||||
print(f"Analyzing {identifier} from full rlogs...")
|
||||
route = analyze_route(identifier, label=label if len(routes) == 1 else None)
|
||||
route_path, geojson_path = write_route_cache(route, cache_dir)
|
||||
counts = route["summary"]["eventCounts"]
|
||||
result = f"Cached {route_path.name}: {route['summary']['distanceM']:.0f} m, "
|
||||
result += f"{counts['impact']} impacts, {counts['roughRoad']} rough windows"
|
||||
print(result)
|
||||
print(f"GeoJSON: {geojson_path}")
|
||||
|
||||
|
||||
def import_device_directory(device_dir: Path, cache_dir: Path, device_key: str) -> None:
|
||||
grouped: dict[str, list[tuple[int, Path]]] = {}
|
||||
for path in sorted(device_dir.glob("*/rlog.*")):
|
||||
match = DEVICE_SEGMENT_PATTERN.fullmatch(path.parent.name)
|
||||
if match is None:
|
||||
continue
|
||||
grouped.setdefault(match.group("route"), []).append((int(match.group("segment")), path))
|
||||
if not grouped:
|
||||
raise ValueError(f"No device rlogs found under {device_dir}")
|
||||
|
||||
checkpoint_root = cache_dir / "device_analysis"
|
||||
for route_name, segment_files in grouped.items():
|
||||
identifier = f"{device_key}/{route_name}"
|
||||
checkpoint_dir = checkpoint_root / hashlib.sha256(identifier.encode()).hexdigest()[:16]
|
||||
checkpoint_dir.mkdir(parents=True, exist_ok=True)
|
||||
analyzed_segments: list[dict] = []
|
||||
analyzed_indexes: list[int] = []
|
||||
print(f"Analyzing device route {identifier} ({len(segment_files)} segments)...")
|
||||
for position, (segment_index, rlog_path) in enumerate(sorted(segment_files), start=1):
|
||||
checkpoint_path = checkpoint_dir / f"{segment_index}.json"
|
||||
try:
|
||||
if checkpoint_path.exists():
|
||||
segment_route = json.loads(checkpoint_path.read_text())
|
||||
else:
|
||||
segment_route = analyze_route(
|
||||
identifier,
|
||||
segment_indexes=[segment_index],
|
||||
log_files=[str(rlog_path)],
|
||||
)
|
||||
temporary_path = checkpoint_path.with_suffix(".tmp")
|
||||
temporary_path.write_text(json.dumps(segment_route, separators=(",", ":")))
|
||||
temporary_path.replace(checkpoint_path)
|
||||
analyzed_segments.append(segment_route)
|
||||
analyzed_indexes.append(segment_index)
|
||||
print(f" [{position}/{len(segment_files)}] segment {segment_index} ready")
|
||||
except Exception as error:
|
||||
print(f" [{position}/{len(segment_files)}] segment {segment_index} skipped: {error}")
|
||||
if not analyzed_segments:
|
||||
print(" No analyzable segments; route skipped.")
|
||||
continue
|
||||
route = merge_segment_routes(identifier, analyzed_segments, segment_indexes=analyzed_indexes)
|
||||
route_path, _ = write_route_cache(route, cache_dir)
|
||||
print(f"Cached {route_path.name}: {route['summary']['distanceM'] / 1000:.2f} km")
|
||||
|
||||
|
||||
def remove_routes(cache_ids: list[str], cache_dir: Path, config: AnalyzerConfig) -> None:
|
||||
for cache_id in cache_ids:
|
||||
if CACHE_ID_PATTERN.fullmatch(cache_id) is None:
|
||||
raise ValueError(f"Invalid cache id {cache_id!r}; use the 16-character id printed by --list")
|
||||
removed = False
|
||||
for path in (cache_dir / "routes" / f"{cache_id}.json", cache_dir / "geojson" / f"{cache_id}.geojson"):
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
removed = True
|
||||
shutil.rmtree(cache_dir / "device_analysis" / cache_id, ignore_errors=True)
|
||||
shutil.rmtree(cache_dir / "route_analysis" / cache_id, ignore_errors=True)
|
||||
for manifest_path in (cache_dir / "imports").glob("*/job.json"):
|
||||
try:
|
||||
manifest = json.loads(manifest_path.read_text())
|
||||
except (json.JSONDecodeError, OSError):
|
||||
continue
|
||||
if manifest.get("cacheId") == cache_id:
|
||||
shutil.rmtree(manifest_path.parent, ignore_errors=True)
|
||||
print(f"{'Removed' if removed else 'Not found'}: {cache_id}")
|
||||
rebuild_index(cache_dir, config)
|
||||
|
||||
|
||||
def list_routes(cache_dir: Path, config: AnalyzerConfig) -> None:
|
||||
index = rebuild_index(cache_dir, config)
|
||||
if not index["routes"]:
|
||||
print("No routes are cached.")
|
||||
return
|
||||
print(f"{index['routeCount']} cached route(s), {index['driverCount']} driver(s):")
|
||||
for route in index["routes"]:
|
||||
summary = route["summary"]
|
||||
segments = route["metadata"].get("segmentCount")
|
||||
segment_text = f"{segments} segment(s), " if segments is not None else ""
|
||||
route_description = (
|
||||
f" {route['id']} {route['label']} {segment_text}"
|
||||
+ f"{summary['durationS'] / 60:.1f} min, {summary['distanceM'] / 1000:.2f} km"
|
||||
)
|
||||
print(route_description)
|
||||
|
||||
|
||||
def serve(port: int) -> None:
|
||||
RoadQualityRequestHandler.cache_dir = DEFAULT_CACHE_DIR
|
||||
_load_and_resume_jobs(DEFAULT_CACHE_DIR)
|
||||
handler = partial(RoadQualityRequestHandler, directory=str(TOOL_ROOT / "web"))
|
||||
with socketserver.ThreadingTCPServer(("127.0.0.1", port), handler) as server:
|
||||
print(f"clustermaps: http://127.0.0.1:{port}")
|
||||
print("Press Ctrl-C to stop.")
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Analyze full rlogs into a multi-route road-quality map cache.",
|
||||
)
|
||||
parser.add_argument("routes", nargs="*", help="Route or segment identifiers. Full rlogs are required.")
|
||||
parser.add_argument("--cache-dir", type=Path, default=DEFAULT_CACHE_DIR, help="Generated JSON/GeoJSON cache directory.")
|
||||
parser.add_argument("--label", help="Friendly label to use when importing one route.")
|
||||
parser.add_argument("--import-device-dir", type=Path, help="Import a previously copied on-device realdata directory.")
|
||||
parser.add_argument("--device-key", help="Stable local device/driver key used with --import-device-dir.")
|
||||
parser.add_argument("--list", action="store_true", help="List cached routes and their removable cache ids.")
|
||||
parser.add_argument("--remove", action="append", default=[], metavar="CACHE_ID", help="Remove one cached route; repeat as needed.")
|
||||
parser.add_argument("--serve", action="store_true", help="Serve the local HTML5 map after importing.")
|
||||
parser.add_argument("--port", type=int, default=8765, help="Local HTTP port used with --serve.")
|
||||
args = parser.parse_args()
|
||||
|
||||
config = AnalyzerConfig()
|
||||
if args.import_device_dir:
|
||||
device_key = args.device_key or f"device-{args.import_device_dir.name}"
|
||||
try:
|
||||
import_device_directory(args.import_device_dir, args.cache_dir, device_key)
|
||||
except ValueError as error:
|
||||
parser.error(str(error))
|
||||
if args.remove:
|
||||
try:
|
||||
remove_routes(args.remove, args.cache_dir, config)
|
||||
except ValueError as error:
|
||||
parser.error(str(error))
|
||||
if args.routes:
|
||||
import_routes(args.routes, args.cache_dir, args.label)
|
||||
elif not args.remove and not args.import_device_dir:
|
||||
rebuild_index(args.cache_dir, config)
|
||||
if args.list:
|
||||
list_routes(args.cache_dir, config)
|
||||
if args.serve:
|
||||
if args.cache_dir.resolve() != DEFAULT_CACHE_DIR.resolve():
|
||||
parser.error("--serve currently requires the default cache directory")
|
||||
serve(args.port)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
@@ -0,0 +1,106 @@
|
||||
import numpy as np
|
||||
|
||||
from openpilot.tools.clustermaps.analyzer import (
|
||||
AnalyzerConfig,
|
||||
LocationInterpolator,
|
||||
_analyze_impacts,
|
||||
_cluster_problem_locations,
|
||||
route_to_geojson,
|
||||
)
|
||||
|
||||
|
||||
def test_location_interpolator():
|
||||
location = LocationInterpolator([
|
||||
{"t": 0.0, "latitude": 40.0, "longitude": -74.0},
|
||||
{"t": 1.0, "latitude": 40.001, "longitude": -73.999},
|
||||
{"t": 2.0, "latitude": 40.002, "longitude": -73.998},
|
||||
])
|
||||
|
||||
midpoint = location.at(0.5)
|
||||
assert midpoint is not None
|
||||
assert midpoint["latitude"] == 40.0005
|
||||
assert midpoint["longitude"] == -73.9995
|
||||
assert location.at(-0.1) is None
|
||||
assert location.at(2.1) is None
|
||||
|
||||
|
||||
def test_impact_detection_finds_synthetic_peak():
|
||||
times = np.arange(0.0, 10.0, 0.01)
|
||||
vertical = -9.81 + 0.03 * np.sin(times * 11.0)
|
||||
vertical[np.argmin(np.abs(times - 5.0))] -= 2.5
|
||||
accelerometer = [(float(t), 0.0, 0.0, float(z)) for t, z in zip(times, vertical, strict=True)]
|
||||
car_state = [(float(t), 12.0, 0.0, False, False) for t in times]
|
||||
track = [{
|
||||
"t": float(t),
|
||||
"latitude": 40.0 + float(t) * 0.0001,
|
||||
"longitude": -74.0,
|
||||
} for t in np.arange(0.0, 10.1, 1.0)]
|
||||
|
||||
impacts, _, diagnostics = _analyze_impacts(
|
||||
accelerometer,
|
||||
car_state,
|
||||
LocationInterpolator(track),
|
||||
start_time=0.0,
|
||||
config=AnalyzerConfig(),
|
||||
)
|
||||
|
||||
assert len(impacts) == 1
|
||||
assert 4.9 <= impacts[0]["t"] <= 5.1
|
||||
assert impacts[0]["peakVerticalMps2"] > 2.0
|
||||
assert diagnostics["thresholdMps2"] >= AnalyzerConfig().impact_min_peak_mps2
|
||||
|
||||
|
||||
def test_repeated_locations_require_independent_routes():
|
||||
def route(route_id, driver_id, latitude):
|
||||
return {
|
||||
"id": route_id,
|
||||
"driverId": driver_id,
|
||||
"layers": {
|
||||
"events": [{
|
||||
"kind": "impact",
|
||||
"latitude": latitude,
|
||||
"longitude": -74.0,
|
||||
"severity": 1.2,
|
||||
}],
|
||||
"roughness": [],
|
||||
},
|
||||
}
|
||||
|
||||
one_route = _cluster_problem_locations([route("a", "driver-a", 40.0)], radius_m=20.0)
|
||||
two_routes = _cluster_problem_locations([
|
||||
route("a", "driver-a", 40.0),
|
||||
route("b", "driver-b", 40.00005),
|
||||
], radius_m=20.0)
|
||||
|
||||
assert one_route == []
|
||||
assert len(two_routes) == 1
|
||||
assert two_routes[0]["routeCount"] == 2
|
||||
assert two_routes[0]["driverCount"] == 2
|
||||
|
||||
|
||||
def test_geojson_contains_route_and_events():
|
||||
route = {
|
||||
"id": "route-a",
|
||||
"label": "Route A",
|
||||
"driverId": "driver-a",
|
||||
"summary": {"distanceM": 10.0},
|
||||
"track": [
|
||||
{"latitude": 40.0, "longitude": -74.0},
|
||||
{"latitude": 40.001, "longitude": -74.001},
|
||||
],
|
||||
"layers": {
|
||||
"events": [{
|
||||
"kind": "impact",
|
||||
"latitude": 40.0005,
|
||||
"longitude": -74.0005,
|
||||
"severity": 1.3,
|
||||
}],
|
||||
"roughness": [],
|
||||
},
|
||||
}
|
||||
|
||||
geojson = route_to_geojson(route)
|
||||
assert geojson["type"] == "FeatureCollection"
|
||||
assert geojson["features"][0]["geometry"]["type"] == "LineString"
|
||||
assert geojson["features"][1]["geometry"]["type"] == "Point"
|
||||
assert geojson["features"][1]["properties"]["kind"] == "impact"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
@@ -0,0 +1,306 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="description" content="An offline-first atlas of road impacts, roughness, body motion, and driving events from openpilot rlogs.">
|
||||
<title>clustermaps</title>
|
||||
<link rel="stylesheet" href="vendor/leaflet/leaflet.css">
|
||||
<link rel="stylesheet" href="styles.css?v=5">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<span class="brand-mark" aria-hidden="true"></span>
|
||||
<div>
|
||||
<h1>clustermaps</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class="atlas-stats" aria-label="Atlas summary">
|
||||
<div><strong id="route-count">—</strong><span>routes</span></div>
|
||||
<div><strong id="driver-count">—</strong><span>drivers</span></div>
|
||||
<div><strong id="distance-total">—</strong><span>mapped</span></div>
|
||||
<div><strong id="observation-count">—</strong><span>signals</span></div>
|
||||
</div>
|
||||
<div class="data-state">
|
||||
<span class="status-dot" aria-hidden="true"></span>
|
||||
<span id="data-status">Loading local cache…</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<aside class="sidebar" aria-label="Map controls">
|
||||
<section class="control-section">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Legend</p>
|
||||
<h2>Sensor layers</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layer-list">
|
||||
<label class="layer-control">
|
||||
<input type="checkbox" data-layer="route" checked>
|
||||
<span class="legend-line route-line" aria-hidden="true"></span>
|
||||
<span><strong>Route traces</strong><small>GPS path by driver</small></span>
|
||||
<output data-count="route">0</output>
|
||||
</label>
|
||||
<label class="layer-control">
|
||||
<input type="checkbox" data-layer="roughness" checked>
|
||||
<span class="legend-gradient" aria-hidden="true"></span>
|
||||
<span><strong>Road roughness</strong><small>2-second vertical vibration</small></span>
|
||||
<output data-count="roughness">0</output>
|
||||
</label>
|
||||
<div class="roughness-scale" aria-label="Road roughness color scale">
|
||||
<span>Smoother</span><i aria-hidden="true"></i><span>Rougher</span>
|
||||
</div>
|
||||
<label class="layer-control">
|
||||
<input type="checkbox" data-layer="impact" checked>
|
||||
<span class="legend-dot impact-dot" aria-hidden="true"></span>
|
||||
<span><strong>Sharp impacts</strong><small>Adaptive IMU peaks</small></span>
|
||||
<output data-count="impact">0</output>
|
||||
</label>
|
||||
<label class="layer-control">
|
||||
<input type="checkbox" data-layer="hardAcceleration" checked>
|
||||
<span class="legend-dot acceleration-dot" aria-hidden="true"></span>
|
||||
<span><strong>Hard acceleration</strong><small>Sustained longitudinal force</small></span>
|
||||
<output data-count="hardAcceleration">0</output>
|
||||
</label>
|
||||
<label class="layer-control">
|
||||
<input type="checkbox" data-layer="hardBraking" checked>
|
||||
<span class="legend-dot braking-dot" aria-hidden="true"></span>
|
||||
<span><strong>Hard braking</strong><small>Sustained deceleration</small></span>
|
||||
<output data-count="hardBraking">0</output>
|
||||
</label>
|
||||
<label class="layer-control">
|
||||
<input type="checkbox" data-layer="bodyMotion">
|
||||
<span class="legend-dot motion-dot" aria-hidden="true"></span>
|
||||
<span><strong>Body motion</strong><small>Roll and pitch activity</small></span>
|
||||
<output data-count="bodyMotion">0</output>
|
||||
</label>
|
||||
<label class="layer-control">
|
||||
<input type="checkbox" data-layer="gps">
|
||||
<span class="legend-dot gps-dot" aria-hidden="true"></span>
|
||||
<span><strong>GPS samples</strong><small>Recorded location cadence</small></span>
|
||||
<output data-count="gps">0</output>
|
||||
</label>
|
||||
<p class="layer-group-title">Deep telemetry</p>
|
||||
<label class="layer-control repeated-control">
|
||||
<input type="checkbox" data-layer="repeated" checked>
|
||||
<span class="legend-ring" aria-hidden="true"></span>
|
||||
<span><strong>Repeated locations</strong><small>Seen on 2+ imported routes</small></span>
|
||||
<output data-count="repeated">0</output>
|
||||
</label>
|
||||
<label class="layer-control">
|
||||
<input type="checkbox" data-layer="imu">
|
||||
<span class="legend-dot imu-dot" aria-hidden="true"></span>
|
||||
<span><strong>Raw IMU vectors</strong><small>Acceleration + rotation at 5 Hz</small></span>
|
||||
<output data-count="imu">0</output>
|
||||
</label>
|
||||
<label class="layer-control">
|
||||
<input type="checkbox" data-layer="vehicle">
|
||||
<span class="legend-dot vehicle-dot" aria-hidden="true"></span>
|
||||
<span><strong>Vehicle dynamics</strong><small>Wheels, steering, pedals, yaw</small></span>
|
||||
<output data-count="vehicle">0</output>
|
||||
</label>
|
||||
<label class="layer-control">
|
||||
<input type="checkbox" data-layer="pose">
|
||||
<span class="legend-dot pose-dot" aria-hidden="true"></span>
|
||||
<span><strong>Fused pose</strong><small>Filtered acceleration + rotation</small></span>
|
||||
<output data-count="pose">0</output>
|
||||
</label>
|
||||
<label class="layer-control">
|
||||
<input type="checkbox" data-layer="visualOdometry">
|
||||
<span class="legend-dot odometry-dot" aria-hidden="true"></span>
|
||||
<span><strong>Visual odometry</strong><small>Camera motion + uncertainty</small></span>
|
||||
<output data-count="visualOdometry">0</output>
|
||||
</label>
|
||||
<label class="layer-control">
|
||||
<input type="checkbox" data-layer="controls">
|
||||
<span class="legend-dot controls-dot" aria-hidden="true"></span>
|
||||
<span><strong>Path curvature</strong><small>Estimated versus desired path</small></span>
|
||||
<output data-count="controls">0</output>
|
||||
</label>
|
||||
<label class="layer-control">
|
||||
<input type="checkbox" data-layer="calibration">
|
||||
<span class="legend-dot calibration-dot" aria-hidden="true"></span>
|
||||
<span><strong>Vehicle calibration</strong><small>Roll, steer ratio, stiffness</small></span>
|
||||
<output data-count="calibration">0</output>
|
||||
</label>
|
||||
<label class="layer-control">
|
||||
<input type="checkbox" data-layer="roadVision">
|
||||
<span class="legend-dot vision-dot" aria-hidden="true"></span>
|
||||
<span><strong>Road vision</strong><small>Lanes, action, frame health</small></span>
|
||||
<output data-count="roadVision">0</output>
|
||||
</label>
|
||||
<label class="layer-control">
|
||||
<input type="checkbox" data-layer="radar">
|
||||
<span class="legend-dot radar-dot" aria-hidden="true"></span>
|
||||
<span><strong>Radar leads</strong><small>Distance + relative motion</small></span>
|
||||
<output data-count="radar">0</output>
|
||||
</label>
|
||||
<label class="layer-control">
|
||||
<input type="checkbox" data-layer="sound">
|
||||
<span class="legend-dot sound-dot" aria-hidden="true"></span>
|
||||
<span><strong>Cabin sound</strong><small>A-weighted sound pressure</small></span>
|
||||
<output data-count="sound">0</output>
|
||||
</label>
|
||||
<label class="layer-control">
|
||||
<input type="checkbox" data-layer="magnetic">
|
||||
<span class="legend-dot magnetic-dot" aria-hidden="true"></span>
|
||||
<span><strong>Magnetic field</strong><small>Three-axis field magnitude</small></span>
|
||||
<output data-count="magnetic">0</output>
|
||||
</label>
|
||||
<label class="layer-control">
|
||||
<input type="checkbox" data-layer="thermal">
|
||||
<span class="legend-dot thermal-dot" aria-hidden="true"></span>
|
||||
<span><strong>Thermal + power</strong><small>IMU/device temperature and load</small></span>
|
||||
<output data-count="thermal">0</output>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="control-section">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Coverage</p>
|
||||
<h2>Imported drives</h2>
|
||||
</div>
|
||||
<div class="route-actions">
|
||||
<button class="text-button" id="select-all-routes" type="button">Select all</button>
|
||||
<button class="text-button" id="unselect-all-routes" type="button">Unselect all</button>
|
||||
<button class="text-button" id="fit-all" type="button">Fit all</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="route-list" class="route-list">
|
||||
<div class="route-skeleton"></div>
|
||||
<div class="route-skeleton short"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="control-section service-section">
|
||||
<details class="service-catalog">
|
||||
<summary>
|
||||
<span>
|
||||
<span class="eyebrow">Rlog inventory</span>
|
||||
<strong>All recorded services</strong>
|
||||
</span>
|
||||
<output id="service-count">0</output>
|
||||
</summary>
|
||||
<p>Every service is listed even when its full payload is too large, redundant, transient, or sensitive for browser export.</p>
|
||||
<div id="service-list" class="service-list"></div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section class="import-help">
|
||||
<p class="eyebrow">Add data</p>
|
||||
<h2>Route manager</h2>
|
||||
<form id="route-manager">
|
||||
<label class="field-label" for="route-input">comma route</label>
|
||||
<div class="field-row">
|
||||
<input id="route-input" name="route" required autocomplete="off" placeholder="dongle/route">
|
||||
<button id="discover-route" type="submit">Load segments</button>
|
||||
</div>
|
||||
<label class="field-label" for="route-label">Local label <span>optional</span></label>
|
||||
<input id="route-label" name="label" maxlength="120" autocomplete="off" placeholder="Morning survey">
|
||||
</form>
|
||||
<div id="segment-picker" class="segment-picker" hidden>
|
||||
<div class="segment-heading">
|
||||
<strong id="segment-summary">0 segments</strong>
|
||||
<span>
|
||||
<button id="select-all-segments" type="button">All</button>
|
||||
<button id="select-no-segments" type="button">None</button>
|
||||
</span>
|
||||
</div>
|
||||
<div id="segment-list" class="segment-list"></div>
|
||||
<button id="import-selected" class="primary-button" type="button">Import selected segments</button>
|
||||
</div>
|
||||
<div id="import-progress" class="import-progress" hidden>
|
||||
<div><strong id="progress-stage">Preparing import</strong><output id="progress-percent">0%</output></div>
|
||||
<progress id="progress-bar" max="100" value="0">0%</progress>
|
||||
<p id="progress-detail">Full rlogs are parsed locally.</p>
|
||||
</div>
|
||||
<p id="route-manager-status" class="route-manager-status" aria-live="polite">Imports run locally and are added to this atlas.</p>
|
||||
</section>
|
||||
|
||||
<section class="method-note">
|
||||
<p class="eyebrow">How to read this</p>
|
||||
<p>Scores are route-adaptive screening signals, not confirmed pothole labels. Repeated observations across independent drives are the strongest evidence.</p>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<section class="map-panel" aria-label="Road quality map">
|
||||
<div id="map" role="application" aria-label="Interactive map of road-quality observations"></div>
|
||||
<div class="map-toolbar">
|
||||
<div class="basemap-picker" role="group" aria-label="Basemap">
|
||||
<button type="button" data-basemap="streets" aria-pressed="true">Streets</button>
|
||||
<button type="button" data-basemap="satellite" aria-pressed="false">Satellite</button>
|
||||
<button type="button" data-basemap="data" aria-pressed="false">Data only</button>
|
||||
</div>
|
||||
<button id="visuals-toggle" type="button" aria-expanded="false" aria-controls="visuals-panel">Visuals</button>
|
||||
<button id="reset-view" type="button">Reset view</button>
|
||||
</div>
|
||||
<section id="visuals-panel" class="visuals-panel" aria-label="Map visualization options" hidden>
|
||||
<div class="visuals-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Map rendering</p>
|
||||
<h2>Visual styles</h2>
|
||||
</div>
|
||||
<button id="visuals-close" type="button" aria-label="Close visualization options">×</button>
|
||||
</div>
|
||||
<div class="visual-presets" role="group" aria-label="Visualization presets">
|
||||
<button type="button" data-viz-preset="signal" aria-pressed="true">Signal</button>
|
||||
<button type="button" data-viz-preset="heat" aria-pressed="false">Heat</button>
|
||||
<button type="button" data-viz-preset="corridor" aria-pressed="false">Corridor</button>
|
||||
<button type="button" data-viz-preset="night" aria-pressed="false">Night</button>
|
||||
</div>
|
||||
<label class="visual-range" for="visual-width">
|
||||
<span>Road width <output id="visual-width-value">1.0×</output></span>
|
||||
<input id="visual-width" type="range" min="0.7" max="3.5" value="1" step="0.1">
|
||||
</label>
|
||||
<label class="visual-range" for="visual-intensity">
|
||||
<span>Signal intensity <output id="visual-intensity-value">80%</output></span>
|
||||
<input id="visual-intensity" type="range" min="0.25" max="1" value="0.8" step="0.05">
|
||||
</label>
|
||||
<div class="visual-switches">
|
||||
<label>
|
||||
<input id="route-glow-toggle" type="checkbox">
|
||||
<span><strong>Coverage glow</strong><small>Bleed routes beyond the road centerline</small></span>
|
||||
</label>
|
||||
<label>
|
||||
<input id="impact-halo-toggle" type="checkbox">
|
||||
<span><strong>Impact heat halos</strong><small>Overlaps become stronger problem areas</small></span>
|
||||
</label>
|
||||
<label>
|
||||
<input id="point-bleed-toggle" type="checkbox">
|
||||
<span><strong>Point bleed</strong><small>Blend neighboring sensor samples into a continuous field</small></span>
|
||||
</label>
|
||||
<label>
|
||||
<input id="night-map-toggle" type="checkbox">
|
||||
<span><strong>High-contrast map</strong><small>Dark background for bright sensor signals</small></span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
<div id="map-scale-note" class="map-scale-note">Street tiles require a connection. Sensor overlays remain available offline.</div>
|
||||
|
||||
<article id="inspector" class="inspector" aria-live="polite">
|
||||
<button id="inspector-close" class="inspector-close" type="button" aria-label="Close observation details">×</button>
|
||||
<p class="eyebrow" id="inspector-kicker">Selected observation</p>
|
||||
<h2 id="inspector-title">Choose a point on the map</h2>
|
||||
<p id="inspector-copy">Impact, roughness, motion, and driving-event markers reveal their measurements here.</p>
|
||||
<dl id="inspector-metrics"></dl>
|
||||
</article>
|
||||
|
||||
<div id="map-message" class="map-message" hidden>
|
||||
<strong>Local cache unavailable</strong>
|
||||
<span>Import an rlog and open this site through the local map server.</span>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<noscript>This map requires JavaScript to render the cached route data.</noscript>
|
||||
<script src="vendor/leaflet/leaflet.js"></script>
|
||||
<script type="module" src="app.js?v=10"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
+26
@@ -0,0 +1,26 @@
|
||||
BSD 2-Clause License
|
||||
|
||||
Copyright (c) 2010-2023, Volodymyr Agafonkin
|
||||
Copyright (c) 2010-2011, CloudMade
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 696 B |
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 618 B |
+661
@@ -0,0 +1,661 @@
|
||||
/* required styles */
|
||||
|
||||
.leaflet-pane,
|
||||
.leaflet-tile,
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow,
|
||||
.leaflet-tile-container,
|
||||
.leaflet-pane > svg,
|
||||
.leaflet-pane > canvas,
|
||||
.leaflet-zoom-box,
|
||||
.leaflet-image-layer,
|
||||
.leaflet-layer {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
.leaflet-container {
|
||||
overflow: hidden;
|
||||
}
|
||||
.leaflet-tile,
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow {
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
/* Prevents IE11 from highlighting tiles in blue */
|
||||
.leaflet-tile::selection {
|
||||
background: transparent;
|
||||
}
|
||||
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
|
||||
.leaflet-safari .leaflet-tile {
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
}
|
||||
/* hack that prevents hw layers "stretching" when loading new tiles */
|
||||
.leaflet-safari .leaflet-tile-container {
|
||||
width: 1600px;
|
||||
height: 1600px;
|
||||
-webkit-transform-origin: 0 0;
|
||||
}
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow {
|
||||
display: block;
|
||||
}
|
||||
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
|
||||
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
|
||||
.leaflet-container .leaflet-overlay-pane svg {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
.leaflet-container .leaflet-marker-pane img,
|
||||
.leaflet-container .leaflet-shadow-pane img,
|
||||
.leaflet-container .leaflet-tile-pane img,
|
||||
.leaflet-container img.leaflet-image-layer,
|
||||
.leaflet-container .leaflet-tile {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
width: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.leaflet-container img.leaflet-tile {
|
||||
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
|
||||
mix-blend-mode: plus-lighter;
|
||||
}
|
||||
|
||||
.leaflet-container.leaflet-touch-zoom {
|
||||
-ms-touch-action: pan-x pan-y;
|
||||
touch-action: pan-x pan-y;
|
||||
}
|
||||
.leaflet-container.leaflet-touch-drag {
|
||||
-ms-touch-action: pinch-zoom;
|
||||
/* Fallback for FF which doesn't support pinch-zoom */
|
||||
touch-action: none;
|
||||
touch-action: pinch-zoom;
|
||||
}
|
||||
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
|
||||
-ms-touch-action: none;
|
||||
touch-action: none;
|
||||
}
|
||||
.leaflet-container {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.leaflet-container a {
|
||||
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
|
||||
}
|
||||
.leaflet-tile {
|
||||
filter: inherit;
|
||||
visibility: hidden;
|
||||
}
|
||||
.leaflet-tile-loaded {
|
||||
visibility: inherit;
|
||||
}
|
||||
.leaflet-zoom-box {
|
||||
width: 0;
|
||||
height: 0;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
z-index: 800;
|
||||
}
|
||||
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
|
||||
.leaflet-overlay-pane svg {
|
||||
-moz-user-select: none;
|
||||
}
|
||||
|
||||
.leaflet-pane { z-index: 400; }
|
||||
|
||||
.leaflet-tile-pane { z-index: 200; }
|
||||
.leaflet-overlay-pane { z-index: 400; }
|
||||
.leaflet-shadow-pane { z-index: 500; }
|
||||
.leaflet-marker-pane { z-index: 600; }
|
||||
.leaflet-tooltip-pane { z-index: 650; }
|
||||
.leaflet-popup-pane { z-index: 700; }
|
||||
|
||||
.leaflet-map-pane canvas { z-index: 100; }
|
||||
.leaflet-map-pane svg { z-index: 200; }
|
||||
|
||||
.leaflet-vml-shape {
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
}
|
||||
.lvml {
|
||||
behavior: url(#default#VML);
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
|
||||
/* control positioning */
|
||||
|
||||
.leaflet-control {
|
||||
position: relative;
|
||||
z-index: 800;
|
||||
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||
pointer-events: auto;
|
||||
}
|
||||
.leaflet-top,
|
||||
.leaflet-bottom {
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
.leaflet-top {
|
||||
top: 0;
|
||||
}
|
||||
.leaflet-right {
|
||||
right: 0;
|
||||
}
|
||||
.leaflet-bottom {
|
||||
bottom: 0;
|
||||
}
|
||||
.leaflet-left {
|
||||
left: 0;
|
||||
}
|
||||
.leaflet-control {
|
||||
float: left;
|
||||
clear: both;
|
||||
}
|
||||
.leaflet-right .leaflet-control {
|
||||
float: right;
|
||||
}
|
||||
.leaflet-top .leaflet-control {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.leaflet-bottom .leaflet-control {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.leaflet-left .leaflet-control {
|
||||
margin-left: 10px;
|
||||
}
|
||||
.leaflet-right .leaflet-control {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
|
||||
/* zoom and fade animations */
|
||||
|
||||
.leaflet-fade-anim .leaflet-popup {
|
||||
opacity: 0;
|
||||
-webkit-transition: opacity 0.2s linear;
|
||||
-moz-transition: opacity 0.2s linear;
|
||||
transition: opacity 0.2s linear;
|
||||
}
|
||||
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
|
||||
opacity: 1;
|
||||
}
|
||||
.leaflet-zoom-animated {
|
||||
-webkit-transform-origin: 0 0;
|
||||
-ms-transform-origin: 0 0;
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
svg.leaflet-zoom-animated {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.leaflet-zoom-anim .leaflet-zoom-animated {
|
||||
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
}
|
||||
.leaflet-zoom-anim .leaflet-tile,
|
||||
.leaflet-pan-anim .leaflet-tile {
|
||||
-webkit-transition: none;
|
||||
-moz-transition: none;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.leaflet-zoom-anim .leaflet-zoom-hide {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
|
||||
/* cursors */
|
||||
|
||||
.leaflet-interactive {
|
||||
cursor: pointer;
|
||||
}
|
||||
.leaflet-grab {
|
||||
cursor: -webkit-grab;
|
||||
cursor: -moz-grab;
|
||||
cursor: grab;
|
||||
}
|
||||
.leaflet-crosshair,
|
||||
.leaflet-crosshair .leaflet-interactive {
|
||||
cursor: crosshair;
|
||||
}
|
||||
.leaflet-popup-pane,
|
||||
.leaflet-control {
|
||||
cursor: auto;
|
||||
}
|
||||
.leaflet-dragging .leaflet-grab,
|
||||
.leaflet-dragging .leaflet-grab .leaflet-interactive,
|
||||
.leaflet-dragging .leaflet-marker-draggable {
|
||||
cursor: move;
|
||||
cursor: -webkit-grabbing;
|
||||
cursor: -moz-grabbing;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* marker & overlays interactivity */
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow,
|
||||
.leaflet-image-layer,
|
||||
.leaflet-pane > svg path,
|
||||
.leaflet-tile-container {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.leaflet-marker-icon.leaflet-interactive,
|
||||
.leaflet-image-layer.leaflet-interactive,
|
||||
.leaflet-pane > svg path.leaflet-interactive,
|
||||
svg.leaflet-image-layer.leaflet-interactive path {
|
||||
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* visual tweaks */
|
||||
|
||||
.leaflet-container {
|
||||
background: #ddd;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.leaflet-container a {
|
||||
color: #0078A8;
|
||||
}
|
||||
.leaflet-zoom-box {
|
||||
border: 2px dotted #38f;
|
||||
background: rgba(255,255,255,0.5);
|
||||
}
|
||||
|
||||
|
||||
/* general typography */
|
||||
.leaflet-container {
|
||||
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
|
||||
font-size: 12px;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
|
||||
/* general toolbar styles */
|
||||
|
||||
.leaflet-bar {
|
||||
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.leaflet-bar a {
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #ccc;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
line-height: 26px;
|
||||
display: block;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
color: black;
|
||||
}
|
||||
.leaflet-bar a,
|
||||
.leaflet-control-layers-toggle {
|
||||
background-position: 50% 50%;
|
||||
background-repeat: no-repeat;
|
||||
display: block;
|
||||
}
|
||||
.leaflet-bar a:hover,
|
||||
.leaflet-bar a:focus {
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
.leaflet-bar a:first-child {
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
}
|
||||
.leaflet-bar a:last-child {
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
border-bottom: none;
|
||||
}
|
||||
.leaflet-bar a.leaflet-disabled {
|
||||
cursor: default;
|
||||
background-color: #f4f4f4;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-bar a {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
}
|
||||
.leaflet-touch .leaflet-bar a:first-child {
|
||||
border-top-left-radius: 2px;
|
||||
border-top-right-radius: 2px;
|
||||
}
|
||||
.leaflet-touch .leaflet-bar a:last-child {
|
||||
border-bottom-left-radius: 2px;
|
||||
border-bottom-right-radius: 2px;
|
||||
}
|
||||
|
||||
/* zoom control */
|
||||
|
||||
.leaflet-control-zoom-in,
|
||||
.leaflet-control-zoom-out {
|
||||
font: bold 18px 'Lucida Console', Monaco, monospace;
|
||||
text-indent: 1px;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
|
||||
/* layers control */
|
||||
|
||||
.leaflet-control-layers {
|
||||
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
|
||||
background: #fff;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.leaflet-control-layers-toggle {
|
||||
background-image: url(images/layers.png);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
.leaflet-retina .leaflet-control-layers-toggle {
|
||||
background-image: url(images/layers-2x.png);
|
||||
background-size: 26px 26px;
|
||||
}
|
||||
.leaflet-touch .leaflet-control-layers-toggle {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
.leaflet-control-layers .leaflet-control-layers-list,
|
||||
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
|
||||
display: none;
|
||||
}
|
||||
.leaflet-control-layers-expanded .leaflet-control-layers-list {
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
.leaflet-control-layers-expanded {
|
||||
padding: 6px 10px 6px 6px;
|
||||
color: #333;
|
||||
background: #fff;
|
||||
}
|
||||
.leaflet-control-layers-scrollbar {
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
padding-right: 5px;
|
||||
}
|
||||
.leaflet-control-layers-selector {
|
||||
margin-top: 2px;
|
||||
position: relative;
|
||||
top: 1px;
|
||||
}
|
||||
.leaflet-control-layers label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-size: 1.08333em;
|
||||
}
|
||||
.leaflet-control-layers-separator {
|
||||
height: 0;
|
||||
border-top: 1px solid #ddd;
|
||||
margin: 5px -10px 5px -6px;
|
||||
}
|
||||
|
||||
/* Default icon URLs */
|
||||
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
|
||||
background-image: url(images/marker-icon.png);
|
||||
}
|
||||
|
||||
|
||||
/* attribution and scale controls */
|
||||
|
||||
.leaflet-container .leaflet-control-attribution {
|
||||
background: #fff;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
margin: 0;
|
||||
}
|
||||
.leaflet-control-attribution,
|
||||
.leaflet-control-scale-line {
|
||||
padding: 0 5px;
|
||||
color: #333;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.leaflet-control-attribution a {
|
||||
text-decoration: none;
|
||||
}
|
||||
.leaflet-control-attribution a:hover,
|
||||
.leaflet-control-attribution a:focus {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.leaflet-attribution-flag {
|
||||
display: inline !important;
|
||||
vertical-align: baseline !important;
|
||||
width: 1em;
|
||||
height: 0.6669em;
|
||||
}
|
||||
.leaflet-left .leaflet-control-scale {
|
||||
margin-left: 5px;
|
||||
}
|
||||
.leaflet-bottom .leaflet-control-scale {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.leaflet-control-scale-line {
|
||||
border: 2px solid #777;
|
||||
border-top: none;
|
||||
line-height: 1.1;
|
||||
padding: 2px 5px 1px;
|
||||
white-space: nowrap;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
text-shadow: 1px 1px #fff;
|
||||
}
|
||||
.leaflet-control-scale-line:not(:first-child) {
|
||||
border-top: 2px solid #777;
|
||||
border-bottom: none;
|
||||
margin-top: -2px;
|
||||
}
|
||||
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
|
||||
border-bottom: 2px solid #777;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-control-attribution,
|
||||
.leaflet-touch .leaflet-control-layers,
|
||||
.leaflet-touch .leaflet-bar {
|
||||
box-shadow: none;
|
||||
}
|
||||
.leaflet-touch .leaflet-control-layers,
|
||||
.leaflet-touch .leaflet-bar {
|
||||
border: 2px solid rgba(0,0,0,0.2);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
|
||||
/* popup */
|
||||
|
||||
.leaflet-popup {
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.leaflet-popup-content-wrapper {
|
||||
padding: 1px;
|
||||
text-align: left;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.leaflet-popup-content {
|
||||
margin: 13px 24px 13px 20px;
|
||||
line-height: 1.3;
|
||||
font-size: 13px;
|
||||
font-size: 1.08333em;
|
||||
min-height: 1px;
|
||||
}
|
||||
.leaflet-popup-content p {
|
||||
margin: 17px 0;
|
||||
margin: 1.3em 0;
|
||||
}
|
||||
.leaflet-popup-tip-container {
|
||||
width: 40px;
|
||||
height: 20px;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
margin-top: -1px;
|
||||
margin-left: -20px;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
.leaflet-popup-tip {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
padding: 1px;
|
||||
|
||||
margin: -10px auto 0;
|
||||
pointer-events: auto;
|
||||
|
||||
-webkit-transform: rotate(45deg);
|
||||
-moz-transform: rotate(45deg);
|
||||
-ms-transform: rotate(45deg);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.leaflet-popup-content-wrapper,
|
||||
.leaflet-popup-tip {
|
||||
background: white;
|
||||
color: #333;
|
||||
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
|
||||
}
|
||||
.leaflet-container a.leaflet-popup-close-button {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
border: none;
|
||||
text-align: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font: 16px/24px Tahoma, Verdana, sans-serif;
|
||||
color: #757575;
|
||||
text-decoration: none;
|
||||
background: transparent;
|
||||
}
|
||||
.leaflet-container a.leaflet-popup-close-button:hover,
|
||||
.leaflet-container a.leaflet-popup-close-button:focus {
|
||||
color: #585858;
|
||||
}
|
||||
.leaflet-popup-scrolled {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.leaflet-oldie .leaflet-popup-content-wrapper {
|
||||
-ms-zoom: 1;
|
||||
}
|
||||
.leaflet-oldie .leaflet-popup-tip {
|
||||
width: 24px;
|
||||
margin: 0 auto;
|
||||
|
||||
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
|
||||
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
|
||||
}
|
||||
|
||||
.leaflet-oldie .leaflet-control-zoom,
|
||||
.leaflet-oldie .leaflet-control-layers,
|
||||
.leaflet-oldie .leaflet-popup-content-wrapper,
|
||||
.leaflet-oldie .leaflet-popup-tip {
|
||||
border: 1px solid #999;
|
||||
}
|
||||
|
||||
|
||||
/* div icon */
|
||||
|
||||
.leaflet-div-icon {
|
||||
background: #fff;
|
||||
border: 1px solid #666;
|
||||
}
|
||||
|
||||
|
||||
/* Tooltip */
|
||||
/* Base styles for the element that has a tooltip */
|
||||
.leaflet-tooltip {
|
||||
position: absolute;
|
||||
padding: 6px;
|
||||
background-color: #fff;
|
||||
border: 1px solid #fff;
|
||||
border-radius: 3px;
|
||||
color: #222;
|
||||
white-space: nowrap;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
|
||||
}
|
||||
.leaflet-tooltip.leaflet-interactive {
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.leaflet-tooltip-top:before,
|
||||
.leaflet-tooltip-bottom:before,
|
||||
.leaflet-tooltip-left:before,
|
||||
.leaflet-tooltip-right:before {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
border: 6px solid transparent;
|
||||
background: transparent;
|
||||
content: "";
|
||||
}
|
||||
|
||||
/* Directions */
|
||||
|
||||
.leaflet-tooltip-bottom {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.leaflet-tooltip-top {
|
||||
margin-top: -6px;
|
||||
}
|
||||
.leaflet-tooltip-bottom:before,
|
||||
.leaflet-tooltip-top:before {
|
||||
left: 50%;
|
||||
margin-left: -6px;
|
||||
}
|
||||
.leaflet-tooltip-top:before {
|
||||
bottom: 0;
|
||||
margin-bottom: -12px;
|
||||
border-top-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-bottom:before {
|
||||
top: 0;
|
||||
margin-top: -12px;
|
||||
margin-left: -6px;
|
||||
border-bottom-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-left {
|
||||
margin-left: -6px;
|
||||
}
|
||||
.leaflet-tooltip-right {
|
||||
margin-left: 6px;
|
||||
}
|
||||
.leaflet-tooltip-left:before,
|
||||
.leaflet-tooltip-right:before {
|
||||
top: 50%;
|
||||
margin-top: -6px;
|
||||
}
|
||||
.leaflet-tooltip-left:before {
|
||||
right: 0;
|
||||
margin-right: -12px;
|
||||
border-left-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-right:before {
|
||||
left: 0;
|
||||
margin-left: -12px;
|
||||
border-right-color: #fff;
|
||||
}
|
||||
|
||||
/* Printing */
|
||||
|
||||
@media print {
|
||||
/* Prevent printers from removing background-images of controls. */
|
||||
.leaflet-control {
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,199 @@
|
||||
# Live UI
|
||||
|
||||
View a comma device's live sunnypilot UI over Wi-Fi. Replay, ADB, and USB are
|
||||
not used.
|
||||
|
||||
| Mode | Output | Device bridge |
|
||||
| --- | --- | --- |
|
||||
| Mimic (default) | Local UI rendered from live device data | Required |
|
||||
| Exact (`--exact`) | Actual pixels shown on the device | Not required |
|
||||
|
||||
Mimic mode does not directly require SSH, but the device bridge must be started
|
||||
somehow; the examples use SSH. Exact mode, touch control, and `--stop-exact`
|
||||
require key-based SSH access.
|
||||
|
||||
Choose **mimic** when developing or inspecting the local UI with live device
|
||||
data. Choose **exact** when you need to see or control what is physically shown
|
||||
on the device.
|
||||
|
||||
## Before the first run
|
||||
|
||||
1. Replace `192.168.43.1` in the examples if your device uses a different IP.
|
||||
2. Connect the computer and device to networks that can reach each other.
|
||||
3. Run local commands from the sunnypilot repository root.
|
||||
4. For exact mode, verify non-interactive SSH access:
|
||||
|
||||
```bash
|
||||
ssh -o BatchMode=yes comma@192.168.43.1 true
|
||||
```
|
||||
|
||||
The script automatically switches to the repository's `.venv` when present.
|
||||
|
||||
## Mimic UI
|
||||
|
||||
Mimic mode receives live cereal messages and encoded road-camera frames, then
|
||||
renders a new UI instance on the computer. It resembles the device UI but is
|
||||
not a pixel-for-pixel screen mirror.
|
||||
|
||||
Build the messaging bridge once on the computer:
|
||||
|
||||
```bash
|
||||
scons -u cereal/messaging/bridge
|
||||
```
|
||||
|
||||
Use two terminals. In the first, start the bridge on the device and leave it
|
||||
running:
|
||||
|
||||
```bash
|
||||
ssh comma@192.168.43.1
|
||||
./cereal/messaging/bridge
|
||||
```
|
||||
|
||||
In the second terminal, start the local UI:
|
||||
|
||||
```bash
|
||||
BIG=0 ./tools/live/ui.py 192.168.43.1
|
||||
```
|
||||
|
||||
`BIG` is inherited from your environment and is not forced by the tool. The
|
||||
first run may generate missing local font assets before opening the window.
|
||||
|
||||
## Exact device screen
|
||||
|
||||
Exact mode streams the actual device display. It does not need either
|
||||
messaging bridge:
|
||||
|
||||
```bash
|
||||
./tools/live/ui.py --exact 192.168.43.1
|
||||
```
|
||||
|
||||
The tool runs a temporary capture helper through SSH using AGNOS's existing
|
||||
DRM broker. It does not install software or modify openpilot source on the
|
||||
device. Exact mode requires a compatible AGNOS DRM writeback connector and
|
||||
captures the post-processed display output when that mode is available.
|
||||
|
||||
Exact mode is experimental. Some Qualcomm/AGNOS combinations can time out
|
||||
during DRM writeback, freezing both the mirrored window and physical device UI.
|
||||
This can happen without `--control`; mouse, keyboard, and shortcut forwarding
|
||||
are not required to trigger the capture failure.
|
||||
The tool stops its verified helper when capture stalls, but the kernel display
|
||||
state may require a full device reboot before exact mode can be tried again.
|
||||
Restarting openpilot alone does not reset the display driver.
|
||||
|
||||
Set the capture rate from 1 to 60 FPS; the default is 15:
|
||||
|
||||
```bash
|
||||
./tools/live/ui.py --exact --exact-fps 30 192.168.43.1
|
||||
./tools/live/ui.py --exact --exact-fps 60 192.168.43.1
|
||||
```
|
||||
|
||||
Start with 15 or 30 FPS. Higher rates use more device CPU and Wi-Fi bandwidth.
|
||||
|
||||
Use `SCALE` to resize the local window:
|
||||
|
||||
```bash
|
||||
SCALE=2 ./tools/live/ui.py --exact 192.168.43.1
|
||||
```
|
||||
|
||||
Only one exact session can run at a time.
|
||||
|
||||
## Touch control
|
||||
|
||||
Touch control is available only with exact mode:
|
||||
|
||||
```bash
|
||||
./tools/live/ui.py --exact --control 192.168.43.1
|
||||
```
|
||||
|
||||
Left-clicks and drags are forwarded to the physical touchscreen over SSH.
|
||||
These inputs affect the real device, including settings and confirmation
|
||||
buttons.
|
||||
|
||||
When the device's on-screen keyboard is visible, type with the computer
|
||||
keyboard. Letters, numbers, symbols, Space, Backspace, and Return are converted
|
||||
to taps on the device keyboard.
|
||||
|
||||
Exact control also provides simulator-style shortcuts:
|
||||
|
||||
| Shortcut | Action |
|
||||
| --- | --- |
|
||||
| `↓` | Go back with a swipe-down gesture |
|
||||
| `←` | Swipe right |
|
||||
| `→` | Swipe left |
|
||||
| `Command`+`K` (macOS) or `Ctrl`+`K` (Linux/Windows) | Force keyboard forwarding on, or return to automatic detection |
|
||||
|
||||
Hold an arrow key to repeat its gesture until the key is released.
|
||||
|
||||
Escape has no action and does not close the program; use `Ctrl-C` or the window
|
||||
close button to exit. `Command`+`K`/`Ctrl`+`K` enables manual keyboard forwarding
|
||||
regardless of exact-screen resolution; touch locations scale to the captured
|
||||
screen. Automatic keyboard detection targets the 536x240 mici UI. Turn forced
|
||||
forwarding back off after typing so ordinary keys cannot produce unintended
|
||||
taps on incompatible keyboard layouts.
|
||||
|
||||
## Connection status
|
||||
|
||||
Status appears in the window title and terminal:
|
||||
|
||||
- `connected`: mimic telemetry and camera frames, or exact screen frames, are
|
||||
arriving.
|
||||
- `keyboard`: exact-mode keyboard forwarding is currently active. This appears
|
||||
only while the device keyboard is detected or forwarding is forced on.
|
||||
- `no camera`: mimic telemetry is arriving, but road-camera frames are not.
|
||||
- `disconnected`: expected mimic-mode live data has stopped.
|
||||
|
||||
At the default capture rate, exact mode reports a stopped screen stream in
|
||||
about one second. Lower `--exact-fps` values allow more time between frames.
|
||||
If Qualcomm DRM writeback stalls, exact mode immediately stops its verified
|
||||
device helper and exits to avoid leaving the physical display frozen.
|
||||
|
||||
## Stop and clean up
|
||||
|
||||
Press `Ctrl-C` or close the window. If an exact helper remains orphaned, run:
|
||||
|
||||
```bash
|
||||
./tools/live/ui.py --stop-exact 192.168.43.1
|
||||
```
|
||||
|
||||
`--kill-exact` is an alias. Stop exact mode before restarting or rebuilding
|
||||
openpilot to avoid blocking the physical UI during a display transition.
|
||||
|
||||
## Options
|
||||
|
||||
```text
|
||||
--exact Show the actual device screen
|
||||
--control Forward mouse and keyboard input; requires --exact
|
||||
--exact-fps FPS Exact capture rate, 1-60 (default: 15)
|
||||
--stop-exact Stop orphaned exact helpers
|
||||
--kill-exact Alias for --stop-exact
|
||||
--ip ADDRESS Alternative to the positional address
|
||||
```
|
||||
|
||||
See the complete command help:
|
||||
|
||||
```bash
|
||||
./tools/live/ui.py --help
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **`cereal/messaging/bridge` is not built:** Run:
|
||||
|
||||
```bash
|
||||
scons -u cereal/messaging/bridge
|
||||
```
|
||||
|
||||
- **Mimic is disconnected:** Confirm the device bridge is still running and
|
||||
the IP is reachable.
|
||||
- **Mimic reports no camera:** Confirm the device is publishing
|
||||
`roadEncodeData`. Camera display may also wait briefly for the next keyframe.
|
||||
- **Exact says another session is running:** Close the other exact window. If
|
||||
none is open, run `--stop-exact`.
|
||||
- **Exact cannot connect:** Recheck key-based SSH and the device IP using the
|
||||
command in "Before the first run."
|
||||
- **Exact stalls or the physical UI freezes:** Stop the exact window and run
|
||||
`--stop-exact`. If the device UI remains unstable or exact immediately stalls
|
||||
again, fully reboot the device; restarting openpilot is not sufficient. If
|
||||
the problem returns after reboot, use mimic mode because continuous DRM
|
||||
writeback is not reliable on that device/AGNOS combination. Removing
|
||||
`--control` does not address this capture-layer failure.
|
||||
Executable
+1410
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user