clustermaps

This commit is contained in:
royjr
2026-07-19 12:23:31 -04:00
parent f76da384cd
commit 2bf5f45ffe
19 changed files with 5443 additions and 0 deletions
+3
View File
@@ -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
+117
View File
@@ -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.
+2
View File
@@ -0,0 +1,2 @@
"""Offline road-quality analysis tools for openpilot routes."""
File diff suppressed because it is too large Load Diff
+395
View File
@@ -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()
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+106
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+306
View File
@@ -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
View File
@@ -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
View File
@@ -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