diff --git a/.gitattributes b/.gitattributes index 507eb73c13..5cb8c432d6 100644 --- a/.gitattributes +++ b/.gitattributes @@ -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 diff --git a/tools/clustermaps/README.md b/tools/clustermaps/README.md new file mode 100644 index 0000000000..3829f3f723 --- /dev/null +++ b/tools/clustermaps/README.md @@ -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/.json`: compact track, layer, event, diagnostic, and source-coverage data. +- `geojson/.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. diff --git a/tools/clustermaps/__init__.py b/tools/clustermaps/__init__.py new file mode 100644 index 0000000000..5799c80f7e --- /dev/null +++ b/tools/clustermaps/__init__.py @@ -0,0 +1,2 @@ +"""Offline road-quality analysis tools for openpilot routes.""" + diff --git a/tools/clustermaps/analyzer.py b/tools/clustermaps/analyzer.py new file mode 100644 index 0000000000..be5b24345e --- /dev/null +++ b/tools/clustermaps/analyzer.py @@ -0,0 +1,1322 @@ +from __future__ import annotations + +import hashlib +import json +import math +from dataclasses import asdict, dataclass +from datetime import UTC, datetime +from pathlib import Path +from collections.abc import Callable +from typing import Any + +import numpy as np + +from openpilot.tools.lib.logreader import LogReader, ReadMode +from openpilot.tools.lib.route import SegmentRange + + +SCHEMA_VERSION = 2 +EARTH_RADIUS_M = 6_371_007.2 + +SERVICE_INFO: dict[str, tuple[str, str, str | None]] = { + "accelerometer": ("motion", "Raw three-axis accelerometer", "imu"), + "gyroscope": ("motion", "Raw three-axis gyroscope", "imu"), + "magnetometer": ("environment", "Raw three-axis magnetic field", "magnetic"), + "temperatureSensor": ("environment", "IMU package temperature", "thermal"), + "gpsLocation": ("position", "On-device GNSS position and velocity", "gps"), + "gpsLocationExternal": ("position", "External GNSS position and velocity", "gps"), + "liveLocationKalman": ("position", "Fused localization position and motion", "gps"), + "qcomGnss": ("position", "Qualcomm raw satellite receiver diagnostics", None), + "ubloxGnss": ("position", "u-blox raw satellite receiver diagnostics", None), + "livePose": ("motion", "Fused device-frame pose and motion", "pose"), + "cameraOdometry": ("vision", "Camera-derived translation and rotation", "visualOdometry"), + "carState": ("vehicle", "Vehicle speed, acceleration, wheels, steering, and pedals", "vehicle"), + "controlsState": ("vehicle", "Estimated and desired path curvature", "controls"), + "liveParameters": ("calibration", "Learned steering, roll, and vehicle parameters", "calibration"), + "radarState": ("objects", "Tracked lead-vehicle state", "radar"), + "liveTracks": ("objects", "Raw radar tracks", None), + "modelV2": ("vision", "Full vision-model path, lanes, edges, and objects", None), + "drivingModelData": ("vision", "Compact path and lane metadata", "roadVision"), + "soundPressure": ("environment", "A-weighted cabin sound pressure", "sound"), + "deviceState": ("diagnostics", "Device thermal, power, storage, and utilization state", "thermal"), + "roadCameraState": ("camera", "Road-camera frame metadata", None), + "wideRoadCameraState": ("camera", "Wide-road-camera frame metadata", None), + "driverCameraState": ("camera", "Driver-camera frame metadata", None), + "roadEncodeIdx": ("camera", "Road-video frame index", None), + "wideRoadEncodeIdx": ("camera", "Wide-road-video frame index", None), + "driverEncodeIdx": ("camera", "Driver-video frame index", None), + "qRoadEncodeIdx": ("camera", "Low-resolution road-video frame index", None), + "can": ("vehicle bus", "Raw incoming vehicle-bus frames", None), + "sendcan": ("vehicle bus", "Raw outgoing vehicle-bus frames", None), + "driverStateV2": ("driver", "Driver-monitoring model output", None), + "driverMonitoringState": ("driver", "Driver-monitoring policy state", None), + "driverMonitoringStateDEPRECATED": ("driver", "Legacy driver-monitoring policy state", None), + "pandaStates": ("diagnostics", "Vehicle-interface hardware state", None), + "peripheralState": ("diagnostics", "Peripheral voltage, fan, and power state", None), + "managerState": ("diagnostics", "Process manager state", None), + "procLog": ("diagnostics", "Process resource telemetry", None), + "clocks": ("diagnostics", "System clock correlation", None), + "logMessage": ("diagnostics", "Software log messages", None), + "androidLog": ("diagnostics", "Operating-system log messages", None), +} + + +@dataclass(frozen=True) +class AnalyzerConfig: + minimum_speed_mps: float = 2.5 + impact_min_peak_mps2: float = 0.75 + impact_sigma: float = 6.0 + impact_merge_gap_s: float = 0.35 + rough_window_s: float = 2.0 + rough_step_s: float = 0.5 + rough_min_rms_mps2: float = 0.22 + rough_sigma: float = 2.5 + hard_accel_mps2: float = 2.0 + hard_brake_mps2: float = -2.5 + longitudinal_min_duration_s: float = 0.4 + body_motion_min_rad_s: float = 0.08 + body_motion_sigma: float = 3.0 + repeat_radius_m: float = 20.0 + + +def _robust_sigma(values: np.ndarray) -> float: + if len(values) == 0: + return 0.0 + median = float(np.median(values)) + return 1.4826 * float(np.median(np.abs(values - median))) + + +def _lowpass(times: np.ndarray, values: np.ndarray, cutoff_hz: float) -> np.ndarray: + if len(values) == 0: + return values.copy() + + output = np.empty_like(values, dtype=float) + output[0] = values[0] + rc = 1.0 / (2.0 * math.pi * cutoff_hz) + for i in range(1, len(values)): + dt = float(np.clip(times[i] - times[i - 1], 1e-4, 0.25)) + alpha = dt / (rc + dt) + output[i] = output[i - 1] + alpha * (values[i] - output[i - 1]) + return output + + +def _haversine_m(lat_a: float, lon_a: float, lat_b: float, lon_b: float) -> float: + dlat = math.radians(lat_b - lat_a) + dlon = math.radians(lon_b - lon_a) + a = math.sin(dlat / 2.0) ** 2 + a += math.cos(math.radians(lat_a)) * math.cos(math.radians(lat_b)) * math.sin(dlon / 2.0) ** 2 + return 2.0 * EARTH_RADIUS_M * math.asin(math.sqrt(a)) + + +def _route_id(identifier: str) -> str: + return hashlib.sha256(identifier.encode()).hexdigest()[:16] + + +def _driver_id(identifier: str) -> str: + dongle_id = identifier.replace("|", "/").split("/", 1)[0] + return hashlib.sha256(dongle_id.encode()).hexdigest()[:8] + + +def _is_valid_position(latitude: float, longitude: float) -> bool: + return -90.0 <= latitude <= 90.0 and -180.0 <= longitude <= 180.0 and (latitude != 0.0 or longitude != 0.0) + + +def _message_rate(times: list[float]) -> float: + if len(times) < 2: + return 0.0 + duration = times[-1] - times[0] + return (len(times) - 1) / duration if duration > 0.0 else 0.0 + + +def _service_catalog(service_times: dict[str, list[float]]) -> list[dict[str, Any]]: + catalog = [] + for service, times in sorted(service_times.items()): + category, description, layer = SERVICE_INFO.get(service, ("other", "Logged openpilot service", None)) + catalog.append({ + "service": service, + "category": category, + "description": description, + "count": len(times), + "rateHz": round(_message_rate(times), 3), + "mapLayer": layer, + "browserExported": layer is not None, + "exportNote": ( + f"Downsampled into the {layer} map layer" + if layer is not None + else "Inventoried only; payload is bulky, redundant, transient, or privacy-sensitive" + ), + }) + return catalog + + +def _thin_points(points: list[dict[str, Any]], minimum_interval_s: float) -> list[dict[str, Any]]: + if not points: + return [] + output = [points[0]] + for point in points[1:-1]: + if point["t"] - output[-1]["t"] >= minimum_interval_s: + output.append(point) + if len(points) > 1 and points[-1] is not output[-1]: + output.append(points[-1]) + return output + + +class LocationInterpolator: + def __init__(self, points: list[dict[str, Any]]): + self.points = points + self.times = np.asarray([point["t"] for point in points], dtype=float) + if len(self.times) > 1: + median_dt = float(np.median(np.diff(self.times))) + self.maximum_gap_s = max(1.0, median_dt * 2.5) + else: + self.maximum_gap_s = 0.0 + + def at(self, timestamp: float) -> dict[str, float] | None: + if len(self.points) < 2 or timestamp < self.times[0] or timestamp > self.times[-1]: + return None + + right = int(np.searchsorted(self.times, timestamp, side="right")) + right = min(max(right, 1), len(self.points) - 1) + left = right - 1 + t0, t1 = self.times[left], self.times[right] + if t1 - t0 <= 0.0 or t1 - t0 > self.maximum_gap_s: + return None + + fraction = float((timestamp - t0) / (t1 - t0)) + p0, p1 = self.points[left], self.points[right] + return { + "latitude": float(p0["latitude"] + fraction * (p1["latitude"] - p0["latitude"])), + "longitude": float(p0["longitude"] + fraction * (p1["longitude"] - p0["longitude"])), + } + + +def _window_metrics(times: np.ndarray, values: np.ndarray, window_s: float, step_s: float) -> list[dict[str, float]]: + if len(times) < 2: + return [] + + output: list[dict[str, float]] = [] + start = float(times[0]) + final_start = float(times[-1] - window_s) + while start <= final_start: + end = start + window_s + left = int(np.searchsorted(times, start, side="left")) + right = int(np.searchsorted(times, end, side="right")) + window = values[left:right] + if len(window) >= 4: + output.append({ + "t": start + window_s / 2.0, + "rms": float(np.sqrt(np.mean(np.square(window)))), + "peak": float(np.max(np.abs(window))), + }) + start += step_s + return output + + +def _candidate_groups(times: np.ndarray, mask: np.ndarray, merge_gap_s: float) -> list[np.ndarray]: + candidate_indices = np.flatnonzero(mask) + if len(candidate_indices) == 0: + return [] + + groups: list[list[int]] = [[int(candidate_indices[0])]] + for index in candidate_indices[1:]: + index = int(index) + if times[index] - times[groups[-1][-1]] > merge_gap_s: + groups.append([index]) + else: + groups[-1].append(index) + return [np.asarray(group, dtype=int) for group in groups] + + +def _event_with_location( + location: LocationInterpolator, + timestamp: float, + start_time: float, + kind: str, + severity: float, + properties: dict[str, Any], +) -> dict[str, Any] | None: + coordinates = location.at(timestamp) + if coordinates is None: + return None + return { + "kind": kind, + "t": round(timestamp - start_time, 3), + "latitude": round(coordinates["latitude"], 7), + "longitude": round(coordinates["longitude"], 7), + "severity": round(float(np.clip(severity, 0.0, 2.0)), 3), + **properties, + } + + +def _messages_with_progress( + reader: LogReader, + progress_callback: Callable[[int, int], None] | None, +): + segment_paths = reader.logreader_identifiers + for index, segment_path in enumerate(segment_paths): + yield from LogReader( + segment_path, + default_mode=ReadMode.RLOG, + sort_by_time=True, + only_union_types=True, + ) + if progress_callback is not None: + progress_callback(index + 1, len(segment_paths)) + + +def _extract_streams( + identifier: str | list[str], + progress_callback: Callable[[int, int], None] | None = None, +) -> dict[str, Any]: + streams: dict[str, Any] = { + "accelerometer": [], + "gyroscope": [], + "magnetometer": [], + "temperatureSensor": [], + "soundPressure": [], + "carState": [], + "livePose": [], + "cameraOdometry": [], + "liveParameters": [], + "controlsState": [], + "radarState": [], + "drivingModelData": [], + "deviceState": [], + "gpsLocation": [], + "gpsLocationExternal": [], + "liveLocationKalman": [], + } + service_times: dict[str, list[float]] = {} + metadata: dict[str, Any] = {} + first_log_time: float | None = None + last_log_time: float | None = None + + reader = LogReader(identifier, default_mode=ReadMode.RLOG, sort_by_time=True, only_union_types=True) + for message in _messages_with_progress(reader, progress_callback): + which = message.which() + timestamp = message.logMonoTime * 1e-9 + first_log_time = timestamp if first_log_time is None else min(first_log_time, timestamp) + last_log_time = timestamp if last_log_time is None else max(last_log_time, timestamp) + service_times.setdefault(which, []).append(timestamp) + + if which == "accelerometer" and message.accelerometer.which() == "acceleration": + raw = message.accelerometer.acceleration.v + if len(raw) == 3: + # This is the same sensor-to-device transform used by locationd. + streams["accelerometer"].append((timestamp, -float(raw[2]), -float(raw[1]), -float(raw[0]))) + elif which == "gyroscope" and message.gyroscope.which() == "gyroUncalibrated": + raw = message.gyroscope.gyroUncalibrated.v + if len(raw) == 3: + streams["gyroscope"].append((timestamp, -float(raw[2]), -float(raw[1]), -float(raw[0]))) + elif which == "magnetometer" and message.magnetometer.which() == "magneticUncalibrated": + raw = message.magnetometer.magneticUncalibrated.v + if len(raw) >= 3: + streams["magnetometer"].append((timestamp, float(raw[0]), float(raw[1]), float(raw[2]))) + elif which == "temperatureSensor" and message.temperatureSensor.which() == "temperature": + streams["temperatureSensor"].append((timestamp, float(message.temperatureSensor.temperature))) + elif which == "soundPressure": + sound = message.soundPressure + streams["soundPressure"].append(( + timestamp, + float(sound.soundPressureWeightedDb), + float(sound.soundPressureWeighted), + )) + elif which == "carState": + state = message.carState + streams["carState"].append(( + timestamp, + float(state.vEgo), + float(state.aEgo), + bool(state.gasPressed), + bool(state.brakePressed), + float(state.yawRate), + float(state.steeringAngleDeg), + float(state.steeringRateDeg), + float(state.steeringTorque), + float(state.wheelSpeeds.fl), + float(state.wheelSpeeds.fr), + float(state.wheelSpeeds.rl), + float(state.wheelSpeeds.rr), + bool(state.leftBlinker), + bool(state.rightBlinker), + bool(state.leftBlindspot), + bool(state.rightBlindspot), + )) + elif which == "livePose": + pose = message.livePose + if pose.accelerationDevice.valid and pose.angularVelocityDevice.valid: + streams["livePose"].append(( + timestamp, + float(pose.accelerationDevice.x), + float(pose.accelerationDevice.y), + float(pose.accelerationDevice.z), + float(pose.angularVelocityDevice.x), + float(pose.angularVelocityDevice.y), + float(pose.angularVelocityDevice.z), + bool(pose.inputsOK and pose.sensorsOK), + )) + elif which == "cameraOdometry": + odometry = message.cameraOdometry + if len(odometry.trans) == 3 and len(odometry.rot) == 3: + streams["cameraOdometry"].append(( + timestamp, + *map(float, odometry.trans), + *map(float, odometry.rot), + float(np.linalg.norm(odometry.transStd)) if len(odometry.transStd) == 3 else 0.0, + float(np.linalg.norm(odometry.rotStd)) if len(odometry.rotStd) == 3 else 0.0, + )) + elif which == "liveParameters": + parameters = message.liveParameters + streams["liveParameters"].append(( + timestamp, + float(parameters.roll), + float(parameters.steerRatio), + float(parameters.stiffnessFactor), + float(parameters.angleOffsetDeg), + float(parameters.angleOffsetAverageDeg), + bool(parameters.valid and parameters.sensorValid), + )) + elif which == "controlsState": + controls = message.controlsState + streams["controlsState"].append(( + timestamp, + float(controls.curvature), + float(controls.desiredCurvature), + bool(controls.forceDecel), + )) + elif which == "radarState": + lead = message.radarState.leadOne + streams["radarState"].append(( + timestamp, + bool(lead.status), + float(lead.dRel), + float(lead.yRel), + float(lead.vRel), + float(lead.aRel), + float(lead.modelProb), + bool(lead.radar), + )) + elif which == "drivingModelData": + model = message.drivingModelData + lane = model.laneLineMeta + streams["drivingModelData"].append(( + timestamp, + float(lane.leftY), + float(lane.rightY), + float(lane.leftProb), + float(lane.rightProb), + float(model.action.desiredCurvature), + float(model.action.desiredAcceleration), + bool(model.action.shouldStop), + float(model.frameDropPerc), + )) + elif which == "deviceState": + device = message.deviceState + streams["deviceState"].append(( + timestamp, + float(device.maxTempC), + float(device.powerDrawW), + float(device.somPowerDrawW), + float(device.freeSpacePercent), + float(device.memoryUsagePercent), + )) + elif which in ("gpsLocation", "gpsLocationExternal"): + gps = getattr(message, which) + latitude, longitude = float(gps.latitude), float(gps.longitude) + if bool(gps.hasFix) and _is_valid_position(latitude, longitude): + streams[which].append({ + "t": timestamp, + "latitude": latitude, + "longitude": longitude, + "altitude": float(gps.altitude), + "speed": float(gps.speed), + "bearing": float(gps.bearingDeg), + "horizontalAccuracy": float(gps.horizontalAccuracy) if gps.horizontalAccuracy > 0.0 else None, + "verticalAccuracy": float(gps.verticalAccuracy) if gps.verticalAccuracy > 0.0 else None, + "speedAccuracy": float(gps.speedAccuracy) if gps.speedAccuracy > 0.0 else None, + "bearingAccuracy": float(gps.bearingAccuracyDeg) if gps.bearingAccuracyDeg > 0.0 else None, + "satelliteCount": int(gps.satelliteCount), + "velocityNED": [round(float(value), 4) for value in gps.vNED], + "source": which, + }) + elif which == "liveLocationKalman": + location = message.liveLocationKalman + position = location.positionGeodetic + if position.valid and len(position.value) == 3 and str(location.status) == "valid": + latitude, longitude, altitude = map(float, position.value) + if _is_valid_position(latitude, longitude): + velocity = list(location.velocityNED.value) + speed = math.hypot(float(velocity[0]), float(velocity[1])) if len(velocity) >= 2 else 0.0 + bearing = math.degrees(math.atan2(float(velocity[1]), float(velocity[0]))) % 360.0 if speed > 0.1 else 0.0 + horizontal_accuracy = None + if len(position.std) >= 2: + horizontal_accuracy = math.hypot(float(position.std[0]), float(position.std[1])) + streams["liveLocationKalman"].append({ + "t": timestamp, + "latitude": latitude, + "longitude": longitude, + "altitude": altitude, + "speed": speed, + "bearing": bearing, + "horizontalAccuracy": horizontal_accuracy, + "verticalAccuracy": float(position.std[2]) if len(position.std) >= 3 else None, + "speedAccuracy": None, + "bearingAccuracy": None, + "satelliteCount": None, + "velocityNED": [round(float(value), 4) for value in velocity], + "source": which, + }) + elif which == "carParams" and "platform" not in metadata: + metadata["platform"] = str(message.carParams.carFingerprint) + elif which == "initData" and "softwareVersion" not in metadata: + metadata["softwareVersion"] = str(message.initData.version) + + if first_log_time is None or last_log_time is None: + raise ValueError(f"No messages found in rlog for {identifier}") + + analysis_times: list[float] = [] + for service in ("accelerometer", "gyroscope", "carState", "livePose", + "gpsLocationExternal", "gpsLocation", "liveLocationKalman"): + values = streams[service] + if not values: + continue + analysis_times.extend(( + float(values[0]["t"] if isinstance(values[0], dict) else values[0][0]), + float(values[-1]["t"] if isinstance(values[-1], dict) else values[-1][0]), + )) + if analysis_times: + first_log_time = min(analysis_times) + last_log_time = max(analysis_times) + + rates = { + service: { + "count": len(times), + "rateHz": round(_message_rate(times), 3), + } + for service, times in service_times.items() + } + return { + "streams": streams, + "rates": rates, + "serviceCatalog": _service_catalog(service_times), + "metadata": metadata, + "startTime": first_log_time, + "endTime": last_log_time, + } + + +def _select_track(streams: dict[str, Any]) -> tuple[str, list[dict[str, Any]]]: + for source, minimum_interval in ( + ("liveLocationKalman", 0.2), + ("gpsLocationExternal", 0.1), + ("gpsLocation", 0.2), + ): + points = streams[source] + if len(points) >= 2: + return source, _thin_points(points, minimum_interval) + raise ValueError("The rlog has no usable absolute-position stream") + + +def _downsample_rows(rows: list[tuple[Any, ...]], minimum_interval_s: float) -> list[tuple[Any, ...]]: + output: list[tuple[Any, ...]] = [] + last_time = -math.inf + for row in rows: + if float(row[0]) - last_time < minimum_interval_s: + continue + output.append(row) + last_time = float(row[0]) + return output + + +def _mapped_sample( + row: tuple[Any, ...], + location: LocationInterpolator, + start_time: float, + properties: dict[str, Any], +) -> dict[str, Any] | None: + coordinates = location.at(float(row[0])) + if coordinates is None: + return None + return { + "t": round(float(row[0]) - start_time, 3), + "latitude": round(coordinates["latitude"], 7), + "longitude": round(coordinates["longitude"], 7), + **properties, + } + + +def _build_telemetry_layers( + streams: dict[str, Any], + location: LocationInterpolator, + start_time: float, +) -> dict[str, list[dict[str, Any]]]: + telemetry: dict[str, list[dict[str, Any]]] = { + "imu": [], + "vehicle": [], + "pose": [], + "visualOdometry": [], + "controls": [], + "calibration": [], + "roadVision": [], + "radar": [], + "sound": [], + "magnetic": [], + "thermal": [], + } + + accelerometer = np.asarray(streams["accelerometer"], dtype=float) + gyroscope = np.asarray(streams["gyroscope"], dtype=float) + if len(accelerometer): + times = accelerometer[:, 0] + baseline = np.column_stack([_lowpass(times, accelerometer[:, axis], 0.8) for axis in range(1, 4)]) + linear = accelerometer[:, 1:4] - baseline + gyro_values = np.zeros((len(times), 3), dtype=float) + if len(gyroscope): + for axis in range(3): + gyro_values[:, axis] = np.interp(times, gyroscope[:, 0], gyroscope[:, axis + 1]) + last_time = -math.inf + for index, row in enumerate(accelerometer): + if row[0] - last_time < 0.2: + continue + sample = _mapped_sample(tuple(row), location, start_time, { + "service": "accelerometer + gyroscope", + "linearAccelX": round(float(linear[index, 0]), 3), + "linearAccelY": round(float(linear[index, 1]), 3), + "linearAccelZ": round(float(linear[index, 2]), 3), + "rawAccelMagnitude": round(float(np.linalg.norm(row[1:4])), 3), + "gyroX": round(float(gyro_values[index, 0]), 4), + "gyroY": round(float(gyro_values[index, 1]), 4), + "gyroZ": round(float(gyro_values[index, 2]), 4), + }) + if sample is not None: + telemetry["imu"].append(sample) + last_time = float(row[0]) + + for row in _downsample_rows(streams["carState"], 0.5): + sample = _mapped_sample(row, location, start_time, { + "service": "carState", + "speedMps": round(float(row[1]), 3), + "accelerationMps2": round(float(row[2]), 3), + "gasPressed": bool(row[3]), + "brakePressed": bool(row[4]), + "yawRateRadS": round(float(row[5]), 4), + "steeringAngleDeg": round(float(row[6]), 2), + "steeringRateDegS": round(float(row[7]), 2), + "steeringTorque": round(float(row[8]), 2), + "wheelSpeedsMps": [round(float(value), 3) for value in row[9:13]], + "leftBlinker": bool(row[13]), + "rightBlinker": bool(row[14]), + "leftBlindspot": bool(row[15]), + "rightBlindspot": bool(row[16]), + }) + if sample is not None: + telemetry["vehicle"].append(sample) + + for row in _downsample_rows(streams["livePose"], 0.5): + sample = _mapped_sample(row, location, start_time, { + "service": "livePose", + "accelerationDevice": [round(float(value), 4) for value in row[1:4]], + "angularVelocityDevice": [round(float(value), 5) for value in row[4:7]], + "inputsValid": bool(row[7]), + }) + if sample is not None: + telemetry["pose"].append(sample) + + for row in _downsample_rows(streams["cameraOdometry"], 0.5): + sample = _mapped_sample(row, location, start_time, { + "service": "cameraOdometry", + "translationDeviceMps": [round(float(value), 4) for value in row[1:4]], + "rotationDeviceRadS": [round(float(value), 5) for value in row[4:7]], + "translationStdNorm": round(float(row[7]), 4), + "rotationStdNorm": round(float(row[8]), 5), + }) + if sample is not None: + telemetry["visualOdometry"].append(sample) + + for row in _downsample_rows(streams["controlsState"], 0.5): + sample = _mapped_sample(row, location, start_time, { + "service": "controlsState", + "curvaturePerM": round(float(row[1]), 7), + "desiredCurvaturePerM": round(float(row[2]), 7), + "forceDecel": bool(row[3]), + }) + if sample is not None: + telemetry["controls"].append(sample) + + for row in _downsample_rows(streams["liveParameters"], 1.0): + sample = _mapped_sample(row, location, start_time, { + "service": "liveParameters", + "rollRad": round(float(row[1]), 5), + "steerRatio": round(float(row[2]), 4), + "stiffnessFactor": round(float(row[3]), 4), + "angleOffsetDeg": round(float(row[4]), 4), + "averageAngleOffsetDeg": round(float(row[5]), 4), + "valid": bool(row[6]), + }) + if sample is not None: + telemetry["calibration"].append(sample) + + for row in _downsample_rows(streams["drivingModelData"], 0.5): + sample = _mapped_sample(row, location, start_time, { + "service": "drivingModelData", + "leftLaneY": round(float(row[1]), 3), + "rightLaneY": round(float(row[2]), 3), + "leftLaneProbability": round(float(row[3]), 3), + "rightLaneProbability": round(float(row[4]), 3), + "desiredCurvaturePerM": round(float(row[5]), 7), + "desiredAccelerationMps2": round(float(row[6]), 3), + "shouldStop": bool(row[7]), + "frameDropPercent": round(float(row[8]), 3), + }) + if sample is not None: + telemetry["roadVision"].append(sample) + + for row in _downsample_rows(streams["radarState"], 1.0): + sample = _mapped_sample(row, location, start_time, { + "service": "radarState", + "leadDetected": bool(row[1]), + "leadDistanceM": round(float(row[2]), 2), + "leadLateralM": round(float(row[3]), 2), + "leadRelativeSpeedMps": round(float(row[4]), 3), + "leadRelativeAccelerationMps2": round(float(row[5]), 3), + "modelProbability": round(float(row[6]), 3), + "radarConfirmed": bool(row[7]), + }) + if sample is not None: + telemetry["radar"].append(sample) + + for row in _downsample_rows(streams["soundPressure"], 0.5): + sample = _mapped_sample(row, location, start_time, { + "service": "soundPressure", + "weightedDb": round(float(row[1]), 2), + "weightedPressure": round(float(row[2]), 6), + }) + if sample is not None: + telemetry["sound"].append(sample) + + for row in _downsample_rows(streams["magnetometer"], 1.0): + vector = np.asarray(row[1:4], dtype=float) + sample = _mapped_sample(row, location, start_time, { + "service": "magnetometer", + "magnetic": [round(float(value), 3) for value in vector], + "magnitude": round(float(np.linalg.norm(vector)), 3), + }) + if sample is not None: + telemetry["magnetic"].append(sample) + + for row in _downsample_rows(streams["temperatureSensor"], 2.0): + sample = _mapped_sample(row, location, start_time, { + "service": "temperatureSensor", + "imuTemperatureC": round(float(row[1]), 2), + }) + if sample is not None: + telemetry["thermal"].append(sample) + for row in _downsample_rows(streams["deviceState"], 2.0): + sample = _mapped_sample(row, location, start_time, { + "service": "deviceState", + "maximumTemperatureC": round(float(row[1]), 2), + "powerDrawW": round(float(row[2]), 2), + "somPowerDrawW": round(float(row[3]), 2), + "freeSpacePercent": round(float(row[4]), 2), + "memoryUsagePercent": round(float(row[5]), 2), + }) + if sample is not None: + telemetry["thermal"].append(sample) + + return telemetry + + +def _analyze_impacts( + accelerometer: list[tuple[float, ...]], + car_state: list[tuple[float, ...]], + location: LocationInterpolator, + start_time: float, + config: AnalyzerConfig, +) -> tuple[list[dict[str, Any]], list[dict[str, Any]], dict[str, float]]: + if len(accelerometer) < 4: + return [], [], {"thresholdMps2": 0.0, "noiseSigmaMps2": 0.0} + + accelerometer_array = np.asarray(accelerometer, dtype=float) + times = accelerometer_array[:, 0] + vertical = accelerometer_array[:, 3] + vertical_highpass = vertical - _lowpass(times, vertical, cutoff_hz=0.8) + impact_amplitude = np.abs(vertical_highpass) + noise_sigma = _robust_sigma(impact_amplitude) + threshold = max( + config.impact_min_peak_mps2, + float(np.median(impact_amplitude)) + config.impact_sigma * noise_sigma, + ) + + if car_state: + car_state_array = np.asarray(car_state, dtype=float) + speeds = np.interp(times, car_state_array[:, 0], car_state_array[:, 1]) + else: + speeds = np.full_like(times, config.minimum_speed_mps) + + mask = (impact_amplitude >= threshold) & (speeds >= config.minimum_speed_mps) + events: list[dict[str, Any]] = [] + for group in _candidate_groups(times, mask, config.impact_merge_gap_s): + peak_index = int(group[np.argmax(impact_amplitude[group])]) + peak = float(impact_amplitude[peak_index]) + event = _event_with_location( + location, + float(times[peak_index]), + start_time, + "impact", + peak / threshold, + { + "peakVerticalMps2": round(peak, 3), + "speedMps": round(float(speeds[peak_index]), 3), + "thresholdMps2": round(threshold, 3), + }, + ) + if event is not None: + events.append(event) + + rough_windows = _window_metrics(times, vertical_highpass, config.rough_window_s, config.rough_step_s) + rough_values = np.asarray([window["rms"] for window in rough_windows], dtype=float) + rough_sigma = _robust_sigma(rough_values) + rough_threshold = max( + config.rough_min_rms_mps2, + (float(np.median(rough_values)) + config.rough_sigma * rough_sigma) if len(rough_values) else 0.0, + ) + roughness: list[dict[str, Any]] = [] + for window in rough_windows: + coordinates = location.at(window["t"]) + if coordinates is None: + continue + speed = float(np.interp(window["t"], times, speeds)) + roughness.append({ + "t": round(window["t"] - start_time, 3), + "latitude": round(coordinates["latitude"], 7), + "longitude": round(coordinates["longitude"], 7), + "rmsMps2": round(window["rms"], 3), + "peakMps2": round(window["peak"], 3), + "score": round(window["rms"] / rough_threshold if rough_threshold > 0.0 else 0.0, 3), + "speedMps": round(speed, 3), + "isRough": bool(window["rms"] >= rough_threshold and speed >= config.minimum_speed_mps), + }) + + diagnostics = { + "thresholdMps2": round(threshold, 3), + "noiseSigmaMps2": round(noise_sigma, 3), + "roughThresholdRmsMps2": round(rough_threshold, 3), + } + return events, roughness, diagnostics + + +def _analyze_longitudinal( + car_state: list[tuple[float, ...]], + location: LocationInterpolator, + start_time: float, + config: AnalyzerConfig, +) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + if len(car_state) < 4: + return [], [] + + state = np.asarray(car_state, dtype=float) + times, speeds, acceleration = state[:, 0], state[:, 1], state[:, 2] + filtered_acceleration = _lowpass(times, acceleration, cutoff_hz=1.0) + events: list[dict[str, Any]] = [] + definitions = ( + ("hardAcceleration", filtered_acceleration >= config.hard_accel_mps2, config.hard_accel_mps2, np.argmax), + ("hardBraking", filtered_acceleration <= config.hard_brake_mps2, abs(config.hard_brake_mps2), np.argmin), + ) + for kind, mask, threshold, peak_function in definitions: + for group in _candidate_groups(times, mask, merge_gap_s=0.2): + duration = float(times[group[-1]] - times[group[0]]) + if duration < config.longitudinal_min_duration_s: + continue + peak_index = int(group[int(peak_function(filtered_acceleration[group]))]) + peak = float(filtered_acceleration[peak_index]) + event = _event_with_location( + location, + float(times[peak_index]), + start_time, + kind, + abs(peak) / threshold, + { + "peakAccelerationMps2": round(peak, 3), + "durationS": round(duration, 3), + "speedMps": round(float(speeds[peak_index]), 3), + }, + ) + if event is not None: + events.append(event) + + samples: list[dict[str, Any]] = [] + for window in _window_metrics(times, filtered_acceleration, window_s=1.0, step_s=1.0): + coordinates = location.at(window["t"]) + if coordinates is None: + continue + left = int(np.searchsorted(times, window["t"] - 0.5, side="left")) + right = int(np.searchsorted(times, window["t"] + 0.5, side="right")) + values = filtered_acceleration[left:right] + if len(values) == 0: + continue + samples.append({ + "t": round(window["t"] - start_time, 3), + "latitude": round(coordinates["latitude"], 7), + "longitude": round(coordinates["longitude"], 7), + "meanMps2": round(float(np.mean(values)), 3), + "minimumMps2": round(float(np.min(values)), 3), + "maximumMps2": round(float(np.max(values)), 3), + }) + return events, samples + + +def _analyze_body_motion( + live_pose: list[tuple[float, ...]], + location: LocationInterpolator, + start_time: float, + config: AnalyzerConfig, +) -> tuple[list[dict[str, Any]], list[dict[str, Any]], dict[str, float]]: + if len(live_pose) < 4: + return [], [], {"thresholdRadS": 0.0} + + pose = np.asarray(live_pose, dtype=float) + times = pose[:, 0] + roll_pitch_rate = np.hypot(pose[:, 4], pose[:, 5]) + windows = _window_metrics(times, roll_pitch_rate, window_s=1.0, step_s=0.5) + rms_values = np.asarray([window["rms"] for window in windows], dtype=float) + threshold = max( + config.body_motion_min_rad_s, + (float(np.median(rms_values)) + config.body_motion_sigma * _robust_sigma(rms_values)) if len(rms_values) else 0.0, + ) + + samples: list[dict[str, Any]] = [] + for window in windows: + coordinates = location.at(window["t"]) + if coordinates is None: + continue + samples.append({ + "t": round(window["t"] - start_time, 3), + "latitude": round(coordinates["latitude"], 7), + "longitude": round(coordinates["longitude"], 7), + "rmsRadS": round(window["rms"], 4), + "peakRadS": round(window["peak"], 4), + "score": round(window["rms"] / threshold if threshold > 0.0 else 0.0, 3), + "isExcessive": bool(window["rms"] >= threshold), + }) + + mask = roll_pitch_rate >= threshold + events: list[dict[str, Any]] = [] + for group in _candidate_groups(times, mask, merge_gap_s=0.5): + peak_index = int(group[np.argmax(roll_pitch_rate[group])]) + peak = float(roll_pitch_rate[peak_index]) + event = _event_with_location( + location, + float(times[peak_index]), + start_time, + "bodyMotion", + peak / threshold, + {"peakRollPitchRateRadS": round(peak, 4)}, + ) + if event is not None: + events.append(event) + + return events, samples, {"thresholdRadS": round(threshold, 4)} + + +def _selected_identifiers(identifier: str, segment_indexes: list[int] | None) -> tuple[str | list[str], list[int], str]: + segment_range = SegmentRange(identifier) + base_identifier = f"{segment_range.dongle_id}/{segment_range.log_id}" + available_indexes = segment_range.seg_idxs + if segment_indexes is None: + return identifier, available_indexes, identifier + + selected_indexes = sorted(set(segment_indexes)) + if not selected_indexes or any(index not in available_indexes for index in selected_indexes): + raise ValueError("Selected segments must be a non-empty subset of the route's available segments") + if selected_indexes == available_indexes: + return base_identifier, selected_indexes, base_identifier + + groups: list[list[int]] = [] + for index in selected_indexes: + if not groups or index != groups[-1][-1] + 1: + groups.append([index]) + else: + groups[-1].append(index) + identifiers = [ + f"{base_identifier}/{group[0]}" if len(group) == 1 else f"{base_identifier}/{group[0]}:{group[-1] + 1}" + for group in groups + ] + source_identifier: str | list[str] = identifiers[0] if len(identifiers) == 1 else identifiers + cache_key = f"{base_identifier}/segments={','.join(map(str, selected_indexes))}" + return source_identifier, selected_indexes, cache_key + + +def analyze_route( + identifier: str, + label: str | None = None, + config: AnalyzerConfig | None = None, + segment_indexes: list[int] | None = None, + progress_callback: Callable[[int, int], None] | None = None, + log_files: list[str] | None = None, +) -> dict[str, Any]: + config = config or AnalyzerConfig() + if log_files is None: + source_identifier, selected_indexes, cache_key = _selected_identifiers(identifier, segment_indexes) + else: + if not log_files: + raise ValueError("At least one local rlog file is required") + source_identifier = log_files + selected_indexes = segment_indexes if segment_indexes is not None else list(range(len(log_files))) + cache_key = identifier + cache_id = _route_id(cache_key) + segment_count = len(selected_indexes) + extracted = _extract_streams(source_identifier, progress_callback) + streams = extracted["streams"] + track_source, track = _select_track(streams) + location = LocationInterpolator(track) + start_time = extracted["startTime"] + + impacts, roughness, impact_diagnostics = _analyze_impacts( + streams["accelerometer"], streams["carState"], location, start_time, config, + ) + longitudinal_events, longitudinal_samples = _analyze_longitudinal( + streams["carState"], location, start_time, config, + ) + body_events, body_samples, body_diagnostics = _analyze_body_motion( + streams["livePose"], location, start_time, config, + ) + telemetry = _build_telemetry_layers(streams, location, start_time) + events = sorted(impacts + longitudinal_events + body_events, key=lambda event: event["t"]) + + distance_m = sum( + _haversine_m(a["latitude"], a["longitude"], b["latitude"], b["longitude"]) + for a, b in zip(track, track[1:], strict=False) + ) + bounds = { + "south": min(point["latitude"] for point in track), + "west": min(point["longitude"] for point in track), + "north": max(point["latitude"] for point in track), + "east": max(point["longitude"] for point in track), + } + event_counts = { + kind: sum(event["kind"] == kind for event in events) + for kind in ("impact", "hardAcceleration", "hardBraking", "bodyMotion") + } + event_counts["roughRoad"] = sum(point["isRough"] for point in roughness) + + normalized_track = [ + { + **point, + "t": round(point["t"] - start_time, 3), + "latitude": round(point["latitude"], 7), + "longitude": round(point["longitude"], 7), + "altitude": round(point["altitude"], 2), + "speed": round(point["speed"], 3), + "bearing": round(point["bearing"], 2), + "horizontalAccuracy": round(point["horizontalAccuracy"], 2) if point["horizontalAccuracy"] is not None else None, + "verticalAccuracy": round(point["verticalAccuracy"], 2) if point["verticalAccuracy"] is not None else None, + "speedAccuracy": round(point["speedAccuracy"], 3) if point["speedAccuracy"] is not None else None, + "bearingAccuracy": round(point["bearingAccuracy"], 2) if point["bearingAccuracy"] is not None else None, + } + for point in track + ] + + return { + "schemaVersion": SCHEMA_VERSION, + "id": cache_id, + "routeName": identifier, + "label": label or identifier, + "driverId": _driver_id(identifier), + "metadata": {**extracted["metadata"], "segmentCount": segment_count}, + "source": { + "logType": "rlog", + "locationService": track_source, + "serviceStats": extracted["rates"], + "serviceCatalog": extracted["serviceCatalog"], + }, + "summary": { + "durationS": round(extracted["endTime"] - start_time, 2), + "distanceM": round(distance_m, 1), + "maximumSpeedMps": round(max((point["speed"] for point in track), default=0.0), 2), + "eventCounts": event_counts, + }, + "bounds": {key: round(value, 7) for key, value in bounds.items()}, + "track": normalized_track, + "layers": { + "roughness": roughness, + "longitudinal": longitudinal_samples, + "bodyMotion": body_samples, + "events": events, + "telemetry": telemetry, + }, + "diagnostics": { + "impact": impact_diagnostics, + "bodyMotion": body_diagnostics, + "config": asdict(config), + }, + } + + +def merge_segment_routes( + identifier: str, + segment_routes: list[dict[str, Any]], + label: str | None = None, + cache_key: str | None = None, + segment_indexes: list[int] | None = None, +) -> dict[str, Any]: + if not segment_routes: + raise ValueError("At least one analyzed segment is required") + if segment_indexes is None: + segment_indexes = list(range(len(segment_routes))) + if len(segment_indexes) != len(segment_routes): + raise ValueError("segment_indexes must match the number of analyzed segments") + + merged_track: list[dict[str, Any]] = [] + merged_layers: dict[str, Any] = { + "roughness": [], + "longitudinal": [], + "bodyMotion": [], + "events": [], + "telemetry": {key: [] for key in segment_routes[0]["layers"]["telemetry"]}, + } + offsets: list[float] = [] + elapsed = 0.0 + for segment in segment_routes: + offsets.append(elapsed) + for point in segment["track"]: + merged_track.append({**point, "t": round(point["t"] + elapsed, 3)}) + for layer_name in ("roughness", "longitudinal", "bodyMotion", "events"): + merged_layers[layer_name].extend( + {**point, "t": round(point["t"] + elapsed, 3)} + for point in segment["layers"][layer_name] + ) + for layer_name, points in segment["layers"]["telemetry"].items(): + merged_layers["telemetry"].setdefault(layer_name, []).extend( + {**point, "t": round(point["t"] + elapsed, 3)} + for point in points + ) + elapsed += float(segment["summary"]["durationS"]) + + service_stats: dict[str, dict[str, Any]] = {} + service_catalog: dict[str, dict[str, Any]] = {} + for segment in segment_routes: + for service, stats in segment["source"]["serviceStats"].items(): + aggregate = service_stats.setdefault(service, {"count": 0, "rateHz": 0.0}) + aggregate["count"] += stats["count"] + aggregate["rateHz"] = max(aggregate["rateHz"], stats["rateHz"]) + for entry in segment["source"]["serviceCatalog"]: + aggregate = service_catalog.setdefault(entry["service"], {**entry, "count": 0, "rateHz": 0.0}) + aggregate["count"] += entry["count"] + aggregate["rateHz"] = max(aggregate["rateHz"], entry["rateHz"]) + + event_counts = { + kind: sum(segment["summary"]["eventCounts"][kind] for segment in segment_routes) + for kind in ("impact", "hardAcceleration", "hardBraking", "bodyMotion", "roughRoad") + } + location_services = {segment["source"]["locationService"] for segment in segment_routes} + return { + "schemaVersion": SCHEMA_VERSION, + "id": _route_id(cache_key or identifier), + "routeName": identifier, + "label": label or identifier, + "driverId": _driver_id(identifier), + "metadata": { + **segment_routes[0]["metadata"], + "segmentCount": len(segment_routes), + }, + "source": { + "logType": "rlog", + "locationService": next(iter(location_services)) if len(location_services) == 1 else "mixed", + "serviceStats": service_stats, + "serviceCatalog": sorted(service_catalog.values(), key=lambda entry: entry["service"]), + }, + "summary": { + "durationS": round(elapsed, 2), + "distanceM": round(sum(segment["summary"]["distanceM"] for segment in segment_routes), 1), + "maximumSpeedMps": max(segment["summary"]["maximumSpeedMps"] for segment in segment_routes), + "eventCounts": event_counts, + }, + "bounds": { + "south": min(segment["bounds"]["south"] for segment in segment_routes), + "west": min(segment["bounds"]["west"] for segment in segment_routes), + "north": max(segment["bounds"]["north"] for segment in segment_routes), + "east": max(segment["bounds"]["east"] for segment in segment_routes), + }, + "track": merged_track, + "layers": merged_layers, + "diagnostics": { + "config": segment_routes[0]["diagnostics"]["config"], + "segments": [ + { + "index": index, + "offsetS": round(offset, 2), + "impact": segment["diagnostics"]["impact"], + "bodyMotion": segment["diagnostics"]["bodyMotion"], + } + for index, offset, segment in zip(segment_indexes, offsets, segment_routes, strict=True) + ], + }, + } + + +def route_to_geojson(route: dict[str, Any]) -> dict[str, Any]: + features: list[dict[str, Any]] = [{ + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [[point["longitude"], point["latitude"]] for point in route["track"]], + }, + "properties": { + "featureType": "route", + "routeId": route["id"], + "label": route["label"], + "driverId": route["driverId"], + **route["summary"], + }, + }] + + for event in route["layers"]["events"]: + properties = {key: value for key, value in event.items() if key not in ("latitude", "longitude")} + features.append({ + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [event["longitude"], event["latitude"]]}, + "properties": {"featureType": "event", "routeId": route["id"], **properties}, + }) + + for roughness in route["layers"]["roughness"]: + if not roughness["isRough"]: + continue + properties = {key: value for key, value in roughness.items() if key not in ("latitude", "longitude")} + features.append({ + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [roughness["longitude"], roughness["latitude"]]}, + "properties": {"featureType": "roughRoad", "routeId": route["id"], **properties}, + }) + + return {"type": "FeatureCollection", "features": features} + + +def _cluster_problem_locations(routes: list[dict[str, Any]], radius_m: float) -> list[dict[str, Any]]: + observations: list[dict[str, Any]] = [] + for route in routes: + for event in route["layers"]["events"]: + if event["kind"] == "impact": + observations.append({ + **event, + "routeId": route["id"], + "driverId": route["driverId"], + "problemType": "impact", + }) + for sample in route["layers"]["roughness"]: + if sample["isRough"]: + observations.append({ + **sample, + "severity": sample["score"], + "routeId": route["id"], + "driverId": route["driverId"], + "problemType": "roughRoad", + }) + + clusters: list[dict[str, Any]] = [] + for observation in sorted(observations, key=lambda item: item["severity"], reverse=True): + best_cluster = None + best_distance = float("inf") + for cluster in clusters: + distance = _haversine_m( + observation["latitude"], observation["longitude"], cluster["latitude"], cluster["longitude"], + ) + if distance <= radius_m and distance < best_distance: + best_cluster = cluster + best_distance = distance + if best_cluster is None: + clusters.append({ + "latitude": observation["latitude"], + "longitude": observation["longitude"], + "observations": [observation], + }) + else: + best_cluster["observations"].append(observation) + count = len(best_cluster["observations"]) + best_cluster["latitude"] += (observation["latitude"] - best_cluster["latitude"]) / count + best_cluster["longitude"] += (observation["longitude"] - best_cluster["longitude"]) / count + + repeated: list[dict[str, Any]] = [] + for cluster in clusters: + route_ids = sorted({observation["routeId"] for observation in cluster["observations"]}) + if len(route_ids) < 2: + continue + driver_ids = sorted({observation["driverId"] for observation in cluster["observations"]}) + problem_types = sorted({observation["problemType"] for observation in cluster["observations"]}) + repeated.append({ + "latitude": round(cluster["latitude"], 7), + "longitude": round(cluster["longitude"], 7), + "observationCount": len(cluster["observations"]), + "routeCount": len(route_ids), + "driverCount": len(driver_ids), + "routeIds": route_ids, + "driverIds": driver_ids, + "problemTypes": problem_types, + "maximumSeverity": round(max(observation["severity"] for observation in cluster["observations"]), 3), + }) + return repeated + + +def rebuild_index(cache_dir: Path, config: AnalyzerConfig | None = None) -> dict[str, Any]: + config = config or AnalyzerConfig() + route_files = sorted((cache_dir / "routes").glob("*.json")) + routes = [json.loads(path.read_text()) for path in route_files] + repeated_locations = _cluster_problem_locations(routes, config.repeat_radius_m) + index_routes = [{ + "id": route["id"], + "file": f"routes/{route['id']}.json", + "geojsonFile": f"geojson/{route['id']}.geojson", + "label": route["label"], + "driverId": route["driverId"], + "metadata": route["metadata"], + "summary": route["summary"], + "bounds": route["bounds"], + } for route in routes] + + index = { + "schemaVersion": SCHEMA_VERSION, + "generatedAt": datetime.now(UTC).isoformat(), + "routeCount": len(routes), + "driverCount": len({route["driverId"] for route in routes}), + "routes": index_routes, + "repeatedLocations": repeated_locations, + } + cache_dir.mkdir(parents=True, exist_ok=True) + (cache_dir / "index.json").write_text(json.dumps(index, separators=(",", ":"))) + + cluster_geojson = { + "type": "FeatureCollection", + "features": [{ + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [cluster["longitude"], cluster["latitude"]]}, + "properties": {"featureType": "repeatedProblem", **{ + key: value for key, value in cluster.items() if key not in ("latitude", "longitude") + }}, + } for cluster in repeated_locations], + } + (cache_dir / "repeated_locations.geojson").write_text(json.dumps(cluster_geojson, separators=(",", ":"))) + return index + + +def _json_safe(value: Any) -> Any: + if isinstance(value, float) and not math.isfinite(value): + return None + if isinstance(value, dict): + return {key: _json_safe(item) for key, item in value.items()} + if isinstance(value, list): + return [_json_safe(item) for item in value] + return value + + +def write_route_cache(route: dict[str, Any], cache_dir: Path, config: AnalyzerConfig | None = None) -> tuple[Path, Path]: + routes_dir = cache_dir / "routes" + geojson_dir = cache_dir / "geojson" + routes_dir.mkdir(parents=True, exist_ok=True) + geojson_dir.mkdir(parents=True, exist_ok=True) + + route_path = routes_dir / f"{route['id']}.json" + geojson_path = geojson_dir / f"{route['id']}.geojson" + safe_route = _json_safe(route) + route_path.write_text(json.dumps(safe_route, separators=(",", ":"), allow_nan=False)) + geojson_path.write_text(json.dumps(route_to_geojson(safe_route), separators=(",", ":"), allow_nan=False)) + rebuild_index(cache_dir, config) + return route_path, geojson_path diff --git a/tools/clustermaps/build_map.py b/tools/clustermaps/build_map.py new file mode 100755 index 0000000000..9be1d517c7 --- /dev/null +++ b/tools/clustermaps/build_map.py @@ -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.+)--(?P[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() diff --git a/tools/clustermaps/private/.gitignore b/tools/clustermaps/private/.gitignore new file mode 100644 index 0000000000..d6b7ef32c8 --- /dev/null +++ b/tools/clustermaps/private/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/tools/clustermaps/tests/test_analyzer.py b/tools/clustermaps/tests/test_analyzer.py new file mode 100644 index 0000000000..c3983fda87 --- /dev/null +++ b/tools/clustermaps/tests/test_analyzer.py @@ -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" diff --git a/tools/clustermaps/web/app.js b/tools/clustermaps/web/app.js new file mode 100644 index 0000000000..7879b12866 --- /dev/null +++ b/tools/clustermaps/web/app.js @@ -0,0 +1,1237 @@ +const ROUTE_COLORS = ["#00a78e", "#3578c8", "#7656c8", "#ed6a3c", "#d34a73", "#578d3b", "#b17a20"]; +const TELEMETRY_KEYS = [ + "imu", "vehicle", "pose", "visualOdometry", "controls", "calibration", + "roadVision", "radar", "sound", "magnetic", "thermal", +]; +const LAYER_KEYS = [ + "route", "roughness", "impact", "hardAcceleration", "hardBraking", "bodyMotion", "gps", + ...TELEMETRY_KEYS, +]; +const TELEMETRY_COLORS = { + imu: "#008a78", + vehicle: "#245f97", + pose: "#6b5bb8", + visualOdometry: "#447caa", + controls: "#c48428", + calibration: "#8a659b", + roadVision: "#3f8b54", + radar: "#d05d42", + sound: "#b84775", + magnetic: "#5368a8", + thermal: "#a55432", +}; + +const map = L.map("map", { + zoomControl: false, + preferCanvas: true, + minZoom: 2, + maxZoom: 20, +}); +L.control.zoom({ position: "bottomright" }).addTo(map); +map.createPane("atlasGlow"); +map.getPane("atlasGlow").style.zIndex = "390"; +map.getPane("atlasGlow").style.pointerEvents = "none"; +const glowRenderer = L.canvas({ pane: "atlasGlow", padding: 0.5 }); +map.createPane("atlasBleed"); +map.getPane("atlasBleed").style.zIndex = "385"; +map.getPane("atlasBleed").style.pointerEvents = "none"; +const bleedRenderer = L.canvas({ pane: "atlasBleed", padding: 0.5 }); +map.createPane("atlasHeat"); +map.getPane("atlasHeat").style.zIndex = "392"; +map.getPane("atlasHeat").style.pointerEvents = "none"; + +const streetTiles = L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { + attribution: "© OpenStreetMap", + maxZoom: 19, +}); +streetTiles.addTo(map); + +const satelliteTiles = L.tileLayer( + "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", + { + attribution: "Imagery © Esri", + maxZoom: 19, + }, +); + +const SensorCanvasLayer = L.GridLayer.extend({ + createTile(coords) { + const tile = document.createElement("canvas"); + const size = this.getTileSize(); + tile.width = size.x; + tile.height = size.y; + tile.className = "sensor-grid-tile"; + const context = tile.getContext("2d"); + context.fillStyle = "#e8efeb"; + context.fillRect(0, 0, size.x, size.y); + + context.strokeStyle = "rgba(16, 42, 45, 0.11)"; + context.lineWidth = 1; + for (let position = 0; position <= size.x; position += size.x / 4) { + context.beginPath(); + context.moveTo(position, 0); + context.lineTo(position, size.y); + context.stroke(); + context.beginPath(); + context.moveTo(0, position); + context.lineTo(size.x, position); + context.stroke(); + } + + const center = map.unproject( + L.point(coords.x * size.x + size.x / 2, coords.y * size.y + size.y / 2), + coords.z, + ); + context.fillStyle = "rgba(16, 42, 45, 0.48)"; + context.font = "600 10px ui-monospace, SFMono-Regular, Menlo, monospace"; + context.fillText(`${center.lat.toFixed(4)}°, ${center.lng.toFixed(4)}°`, 10, 18); + return tile; + }, +}); +const sensorCanvas = new SensorCanvasLayer({ + attribution: "Sensor data canvas", + minZoom: 2, + maxZoom: 20, +}); + +const repeatedLayer = L.layerGroup().addTo(map); +const routeLayers = new Map(); +const routeVisibility = new Map(); +const layerVisibility = Object.fromEntries(LAYER_KEYS.map((key) => [key, true])); +layerVisibility.bodyMotion = false; +layerVisibility.gps = false; +for (const key of TELEMETRY_KEYS) layerVisibility[key] = false; +let loadedRoutes = []; +let atlasIndex = null; +let fullBounds = null; +const VISUAL_PRESETS = { + signal: { width: 1, intensity: 0.8, routeGlow: false, impactHalos: false, pointBleed: false, nightMap: false }, + heat: { width: 1.7, intensity: 0.92, routeGlow: false, impactHalos: true, pointBleed: true, nightMap: false }, + corridor: { width: 3, intensity: 0.58, routeGlow: false, impactHalos: false, pointBleed: true, nightMap: false }, + night: { width: 1.55, intensity: 1, routeGlow: false, impactHalos: true, pointBleed: true, nightMap: true }, +}; +let visualizationState = { preset: "signal", ...VISUAL_PRESETS.signal }; +let visualizationFrame = null; + +const $ = (selector) => document.querySelector(selector); +const $$ = (selector) => [...document.querySelectorAll(selector)]; + +const HEAT_FIELD_SCALE = 0.5; +const HEAT_COLOR_STOPS = [ + [0.38, [90, 154, 62]], + [0.7, [191, 190, 50]], + [0.98, [242, 169, 59]], + [1.25, [237, 106, 60]], + [1.65, [218, 59, 80]], +]; + +function interpolateHeatColor(score) { + for (let index = 1; index < HEAT_COLOR_STOPS.length; index += 1) { + const [upperScore, upperColor] = HEAT_COLOR_STOPS[index]; + const [lowerScore, lowerColor] = HEAT_COLOR_STOPS[index - 1]; + if (score > upperScore) continue; + const mix = Math.max(0, Math.min(1, (score - lowerScore) / (upperScore - lowerScore))); + return lowerColor.map((value, channel) => Math.round( + value + (upperColor[channel] - value) * mix, + )); + } + return HEAT_COLOR_STOPS.at(-1)[1]; +} + +const RoughnessHeatLayer = L.Layer.extend({ + options: { pane: "atlasHeat" }, + + onAdd(mapInstance) { + this._map = mapInstance; + this._canvas = L.DomUtil.create("canvas", "roughness-heat-canvas"); + this.getPane().append(this._canvas); + mapInstance.on("moveend zoomend resize", this.scheduleRedraw, this); + this.scheduleRedraw(); + }, + + onRemove(mapInstance) { + mapInstance.off("moveend zoomend resize", this.scheduleRedraw, this); + this._canvas.remove(); + window.cancelAnimationFrame(this._frame); + this._canvas = null; + }, + + scheduleRedraw() { + if (!this._canvas || this._frame) return; + this._frame = window.requestAnimationFrame(() => { + this._frame = null; + this.redraw(); + }); + }, + + redraw() { + const size = this._map.getSize(); + const topLeft = this._map.containerPointToLayerPoint([0, 0]); + L.DomUtil.setPosition(this._canvas, topLeft); + this._canvas.style.width = `${size.x}px`; + this._canvas.style.height = `${size.y}px`; + + const width = Math.max(1, Math.ceil(size.x * HEAT_FIELD_SCALE)); + const height = Math.max(1, Math.ceil(size.y * HEAT_FIELD_SCALE)); + if (this._canvas.width !== width) this._canvas.width = width; + if (this._canvas.height !== height) this._canvas.height = height; + const context = this._canvas.getContext("2d"); + context.clearRect(0, 0, width, height); + if (!visualizationState.pointBleed || !layerVisibility.roughness) return; + + const fieldWeight = new Float32Array(width * height); + const fieldValue = new Float32Array(width * height); + const radiusCss = 11 + Number(visualizationState.width) * 10; + const radius = Math.max(5, Math.ceil(radiusCss * HEAT_FIELD_SCALE)); + const radiusSquared = radius * radius; + const kernel = []; + for (let y = -radius; y <= radius; y += 1) { + for (let x = -radius; x <= radius; x += 1) { + const distanceSquared = x * x + y * y; + if (distanceSquared > radiusSquared) continue; + kernel.push([x, y, Math.exp(-5 * distanceSquared / radiusSquared)]); + } + } + + const stamp = (centerX, centerY, score) => { + const originX = Math.round(centerX); + const originY = Math.round(centerY); + for (const [offsetX, offsetY, weight] of kernel) { + const x = originX + offsetX; + const y = originY + offsetY; + if (x < 0 || x >= width || y < 0 || y >= height) continue; + const index = y * width + x; + fieldWeight[index] += weight; + fieldValue[index] += weight * score; + } + }; + + for (const route of loadedRoutes) { + if (!routeVisibility.get(route.id)) continue; + const samples = route.layers.roughness ?? []; + for (let index = 1; index < samples.length; index += 1) { + const previous = samples[index - 1]; + const sample = samples[index]; + if (sample.t - previous.t > 3) continue; + const start = this._map.latLngToContainerPoint([previous.latitude, previous.longitude]) + .multiplyBy(HEAT_FIELD_SCALE); + const end = this._map.latLngToContainerPoint([sample.latitude, sample.longitude]) + .multiplyBy(HEAT_FIELD_SCALE); + if ( + Math.max(start.x, end.x) < -radius || Math.min(start.x, end.x) > width + radius + || Math.max(start.y, end.y) < -radius || Math.min(start.y, end.y) > height + radius + ) continue; + const distance = start.distanceTo(end); + const steps = Math.max(1, Math.ceil(distance / Math.max(2, radius * 0.28))); + for (let step = 0; step < steps; step += 1) { + const fraction = (step + 0.5) / steps; + stamp( + start.x + (end.x - start.x) * fraction, + start.y + (end.y - start.y) * fraction, + previous.score + (sample.score - previous.score) * fraction, + ); + } + } + } + + const image = context.createImageData(width, height); + const intensity = Number(visualizationState.intensity); + for (let index = 0; index < fieldWeight.length; index += 1) { + const weight = fieldWeight[index]; + if (weight < 0.002) continue; + const color = interpolateHeatColor(fieldValue[index] / weight); + const pixel = index * 4; + image.data[pixel] = color[0]; + image.data[pixel + 1] = color[1]; + image.data[pixel + 2] = color[2]; + image.data[pixel + 3] = Math.round( + 255 * intensity * 0.88 * (1 - Math.exp(-weight * 0.42)), + ); + } + context.putImageData(image, 0, 0); + }, +}); +const roughnessHeatLayer = new RoughnessHeatLayer().addTo(map); + +function formatDistance(meters) { + const miles = meters / 1609.344; + return miles < 10 ? `${miles.toFixed(1)} mi` : `${Math.round(miles)} mi`; +} + +function formatDuration(seconds) { + if (seconds < 90) return `${Math.round(seconds)} sec`; + return `${Math.round(seconds / 60)} min`; +} + +function formatSpeed(metersPerSecond) { + return `${(metersPerSecond * 2.236936).toFixed(1)} mph`; +} + +function titleForKind(kind) { + return { + impact: "Sharp impact", + hardAcceleration: "Hard acceleration", + hardBraking: "Hard braking", + bodyMotion: "Excessive body motion", + roughness: "Road roughness window", + gps: "GPS sample", + route: "Imported route", + repeated: "Repeated problem location", + imu: "Raw IMU vectors", + vehicle: "Vehicle dynamics", + pose: "Fused device pose", + visualOdometry: "Visual odometry", + controls: "Path curvature", + calibration: "Vehicle calibration", + roadVision: "Road-vision estimate", + radar: "Radar lead state", + sound: "Cabin sound pressure", + magnetic: "Magnetic field", + thermal: "Thermal and power state", + }[kind] ?? kind; +} + +function colorForRoughness(score) { + if (score >= 1.25) return "#da3b50"; + if (score >= 1.0) return "#ed6a3c"; + if (score >= 0.72) return "#f2a93b"; + return "#91b63c"; +} + +function markerStyle(kind, severity = 1) { + const color = { + impact: "#ed6a3c", + hardAcceleration: "#00a78e", + hardBraking: "#da3b50", + bodyMotion: "#7656c8", + gps: "#3578c8", + }[kind] ?? "#102a2d"; + return { + radius: Math.min(9, 4 + severity * 2.2), + color: "#ffffff", + weight: 1.5, + fillColor: color, + fillOpacity: 0.9, + }; +} + +function addPointBleed(group, latitude, longitude, color, radius = 12, strength = 1, source = "") { + L.circleMarker([latitude, longitude], { + renderer: bleedRenderer, + interactive: false, + radius, + stroke: false, + fillColor: color, + fillOpacity: 0, + atlasVizType: "pointBleed", + atlasBaseRadius: radius, + atlasStrength: strength, + atlasSource: source, + }).addTo(group); +} + +function setMetricList(metrics) { + const container = $("#inspector-metrics"); + container.replaceChildren(); + for (const [label, value] of metrics) { + if (value === null || value === undefined) continue; + const wrapper = document.createElement("div"); + const term = document.createElement("dt"); + const description = document.createElement("dd"); + term.textContent = label; + description.textContent = value; + wrapper.append(term, description); + container.append(wrapper); + } +} + +function inspectObservation(kicker, title, copy, metrics) { + $("#inspector-kicker").textContent = kicker; + $("#inspector-title").textContent = title; + $("#inspector-copy").textContent = copy; + setMetricList(metrics); + $("#inspector").classList.add("visible"); +} + +function inspectEvent(event, route) { + const metrics = [ + ["Drive", route.label], + ["Route time", `${event.t.toFixed(1)} s`], + ["Severity", `${event.severity.toFixed(2)} × threshold`], + ]; + if (event.speedMps !== undefined) metrics.push(["Vehicle speed", formatSpeed(event.speedMps)]); + if (event.peakVerticalMps2 !== undefined) metrics.push(["Vertical peak", `${event.peakVerticalMps2.toFixed(2)} m/s²`]); + if (event.peakAccelerationMps2 !== undefined) metrics.push(["Longitudinal peak", `${event.peakAccelerationMps2.toFixed(2)} m/s²`]); + if (event.peakRollPitchRateRadS !== undefined) metrics.push(["Roll/pitch rate", `${event.peakRollPitchRateRadS.toFixed(3)} rad/s`]); + if (event.durationS !== undefined) metrics.push(["Duration", `${event.durationS.toFixed(2)} s`]); + + inspectObservation( + `Driver ${route.driverId}`, + titleForKind(event.kind), + event.kind === "impact" + ? "A short vertical acceleration peak exceeded this route’s adaptive noise threshold." + : "This interval crossed the configured screening threshold.", + metrics, + ); +} + +function inspectRoughness(sample, route) { + inspectObservation( + `Driver ${route.driverId}`, + titleForKind("roughness"), + sample.isRough + ? "This two-second window crossed the route-adaptive roughness threshold." + : "This point shows the local vertical-vibration baseline.", + [ + ["Drive", route.label], + ["Route time", `${sample.t.toFixed(1)} s`], + ["RMS vibration", `${sample.rmsMps2.toFixed(3)} m/s²`], + ["Peak vibration", `${sample.peakMps2.toFixed(2)} m/s²`], + ["Relative score", `${sample.score.toFixed(2)} × threshold`], + ["Vehicle speed", formatSpeed(sample.speedMps)], + ], + ); +} + +function inspectBodyMotion(sample, route) { + inspectObservation( + `Driver ${route.driverId}`, + titleForKind("bodyMotion"), + "Roll and pitch angular velocity summarize vehicle-body activity without counting normal yaw motion.", + [ + ["Drive", route.label], + ["Route time", `${sample.t.toFixed(1)} s`], + ["RMS rate", `${sample.rmsRadS.toFixed(3)} rad/s`], + ["Peak rate", `${sample.peakRadS.toFixed(3)} rad/s`], + ["Relative score", `${sample.score.toFixed(2)} × threshold`], + ], + ); +} + +function inspectGps(point, route) { + inspectObservation( + `Driver ${route.driverId}`, + titleForKind("gps"), + `Location was recorded by ${route.source.locationService}; event positions between fixes are linearly interpolated.`, + [ + ["Drive", route.label], + ["Route time", `${point.t.toFixed(1)} s`], + ["Speed", formatSpeed(point.speed)], + ["Bearing", `${point.bearing.toFixed(0)}°`], + ["Accuracy", point.horizontalAccuracy ? `${point.horizontalAccuracy.toFixed(1)} m` : "Not reported"], + ["Vertical accuracy", point.verticalAccuracy ? `${point.verticalAccuracy.toFixed(1)} m` : "Not reported"], + ["Speed accuracy", point.speedAccuracy ? `${point.speedAccuracy.toFixed(2)} m/s` : "Not reported"], + ["Satellites", point.satelliteCount ?? "Not reported"], + ], + ); +} + +function humanizeKey(key) { + return key + .replace(/([A-Z])/g, " $1") + .replace(/^./, (character) => character.toUpperCase()) + .replace("Mps2", "m/s²") + .replace("Mps", "m/s") + .replace("Rad S", "rad/s") + .replace("Deg S", "deg/s") + .replace("Per M", "1/m"); +} + +function formatTelemetryValue(value) { + if (Array.isArray(value)) return value.map((item) => typeof item === "number" ? item.toFixed(3) : item).join(", "); + if (typeof value === "boolean") return value ? "Yes" : "No"; + if (typeof value === "number") return Number.isInteger(value) ? String(value) : value.toFixed(4); + return String(value); +} + +function inspectTelemetry(kind, sample, route) { + const metrics = [ + ["Drive", route.label], + ["Route time", `${sample.t.toFixed(1)} s`], + ["Source", sample.service], + ]; + for (const [key, value] of Object.entries(sample)) { + if (["t", "latitude", "longitude", "service"].includes(key)) continue; + metrics.push([humanizeKey(key), formatTelemetryValue(value)]); + } + inspectObservation( + `Driver ${route.driverId}`, + titleForKind(kind), + "A compact, geolocated sample from the original rlog. High-rate streams are downsampled for browser performance; full-rate analysis remains in Python.", + metrics, + ); +} + +function createRouteLayers(route, routeIndex) { + const color = ROUTE_COLORS[routeIndex % ROUTE_COLORS.length]; + const layers = Object.fromEntries(LAYER_KEYS.map((key) => [key, L.layerGroup()])); + const latLngs = route.track.map((point) => [point.latitude, point.longitude]); + + const traceGlow = L.polyline(latLngs, { + renderer: glowRenderer, + interactive: false, + color, + weight: 18, + opacity: 0, + lineCap: "round", + atlasVizType: "routeGlow", + }); + traceGlow.addTo(layers.route); + + const trace = L.polyline(latLngs, { + color, + weight: 4, + opacity: 0.82, + lineCap: "round", + atlasVizType: "routeTrace", + }).bindTooltip(route.label, { className: "road-tooltip", sticky: true }); + trace.on("click", () => { + inspectObservation( + `Driver ${route.driverId}`, + titleForKind("route"), + `${route.source.locationService} provided the absolute position trace for this cached rlog.`, + [ + ["Distance", formatDistance(route.summary.distanceM)], + ["Duration", formatDuration(route.summary.durationS)], + ["Top GPS speed", formatSpeed(route.summary.maximumSpeedMps)], + ["Platform", route.metadata.platform || "Unknown"], + ], + ); + }); + trace.addTo(layers.route); + + let previousRoughness = null; + for (const sample of route.layers.roughness) { + if (previousRoughness && sample.t - previousRoughness.t <= 3) { + const heatSegment = L.polyline([ + [previousRoughness.latitude, previousRoughness.longitude], + [sample.latitude, sample.longitude], + ], { + color: colorForRoughness((previousRoughness.score + sample.score) / 2), + weight: 6, + opacity: 0.78, + lineCap: "round", + atlasVizType: "roughnessSegment", + atlasScore: (previousRoughness.score + sample.score) / 2, + }); + heatSegment.bindTooltip(`${sample.rmsMps2.toFixed(2)} m/s² RMS`, { className: "road-tooltip" }); + heatSegment.on("click", () => inspectRoughness(sample, route)); + heatSegment.addTo(layers.roughness); + } + previousRoughness = sample; + if (!sample.isRough) continue; + addPointBleed( + layers.roughness, + sample.latitude, + sample.longitude, + colorForRoughness(sample.score), + 13, + Math.min(1.5, sample.score), + "roughness", + ); + const marker = L.circleMarker([sample.latitude, sample.longitude], { + radius: 4.8, + color: colorForRoughness(sample.score), + weight: 2, + opacity: 0.95, + fillColor: colorForRoughness(sample.score), + fillOpacity: 0.9, + atlasVizType: "roughnessPoint", + atlasScore: sample.score, + }); + marker.bindTooltip(`${sample.rmsMps2.toFixed(2)} m/s² RMS`, { className: "road-tooltip" }); + marker.on("click", () => inspectRoughness(sample, route)); + marker.addTo(layers.roughness); + } + + for (const event of route.layers.events) { + if (!(event.kind in layers)) continue; + const style = markerStyle(event.kind, event.severity); + addPointBleed( + layers[event.kind], + event.latitude, + event.longitude, + style.fillColor, + 14 + Math.min(8, event.severity * 3), + Math.min(1.6, event.severity), + ); + if (event.kind === "impact") { + const halo = L.circleMarker([event.latitude, event.longitude], { + renderer: glowRenderer, + interactive: false, + radius: 13 + event.severity * 8, + stroke: false, + fillColor: style.fillColor, + fillOpacity: 0, + atlasVizType: "impactHalo", + atlasSeverity: event.severity, + }); + halo.addTo(layers.impact); + } + const marker = L.circleMarker([event.latitude, event.longitude], { + ...style, + atlasVizType: "eventPoint", + atlasSeverity: event.severity, + }); + marker.bindTooltip(titleForKind(event.kind), { className: "road-tooltip" }); + marker.on("click", () => inspectEvent(event, route)); + marker.addTo(layers[event.kind]); + } + + for (const sample of route.layers.bodyMotion) { + if (sample.score < 0.18) continue; + addPointBleed( + layers.bodyMotion, + sample.latitude, + sample.longitude, + markerStyle("bodyMotion").fillColor, + 11, + Math.min(1.4, 0.6 + sample.score), + ); + const marker = L.circleMarker([sample.latitude, sample.longitude], { + ...markerStyle("bodyMotion", sample.score), + radius: Math.min(6.5, 2.4 + sample.score * 2), + fillOpacity: Math.min(0.78, 0.3 + sample.score * 0.35), + weight: sample.isExcessive ? 2 : 0.7, + atlasVizType: "samplePoint", + atlasBaseFillOpacity: Math.min(0.78, 0.3 + sample.score * 0.35), + atlasBaseOpacity: 1, + }); + marker.bindTooltip(`${sample.rmsRadS.toFixed(3)} rad/s RMS`, { className: "road-tooltip" }); + marker.on("click", () => inspectBodyMotion(sample, route)); + marker.addTo(layers.bodyMotion); + } + + for (const point of route.track) { + addPointBleed(layers.gps, point.latitude, point.longitude, markerStyle("gps").fillColor, 9, 0.55); + const marker = L.circleMarker([point.latitude, point.longitude], { + ...markerStyle("gps", 0.4), + radius: 2.7, + fillOpacity: 0.72, + weight: 0.7, + atlasVizType: "samplePoint", + atlasBaseFillOpacity: 0.72, + atlasBaseOpacity: 1, + }); + marker.on("click", () => inspectGps(point, route)); + marker.addTo(layers.gps); + } + + for (const kind of TELEMETRY_KEYS) { + for (const sample of route.layers.telemetry?.[kind] ?? []) { + const emphasized = kind === "radar" && sample.leadDetected; + addPointBleed( + layers[kind], + sample.latitude, + sample.longitude, + TELEMETRY_COLORS[kind], + emphasized ? 13 : 9, + emphasized ? 1.3 : 0.55, + ); + const marker = L.circleMarker([sample.latitude, sample.longitude], { + radius: emphasized ? 4.6 : 2.8, + color: "#ffffff", + weight: emphasized ? 1.5 : 0.6, + fillColor: TELEMETRY_COLORS[kind], + fillOpacity: emphasized ? 0.9 : 0.62, + atlasVizType: "samplePoint", + atlasBaseFillOpacity: emphasized ? 0.9 : 0.62, + atlasBaseOpacity: 1, + }); + marker.bindTooltip(`${titleForKind(kind)} · ${sample.service}`, { className: "road-tooltip" }); + marker.on("click", () => inspectTelemetry(kind, sample, route)); + marker.addTo(layers[kind]); + } + } + + routeLayers.set(route.id, layers); + routeVisibility.set(route.id, true); + return color; +} + +function syncLayers() { + for (const [routeId, layers] of routeLayers) { + const routeIsVisible = routeVisibility.get(routeId); + for (const key of LAYER_KEYS) { + const shouldShow = routeIsVisible && layerVisibility[key]; + if (shouldShow && !map.hasLayer(layers[key])) { + layers[key].addTo(map); + } else if (!shouldShow && map.hasLayer(layers[key])) { + map.removeLayer(layers[key]); + } + } + } + + const repeatedEnabled = $("[data-layer=\"repeated\"]").checked; + if (repeatedEnabled && !map.hasLayer(repeatedLayer)) repeatedLayer.addTo(map); + if (!repeatedEnabled && map.hasLayer(repeatedLayer)) map.removeLayer(repeatedLayer); + roughnessHeatLayer.scheduleRedraw(); +} + +function applyVisualizationStyles(save = true) { + const width = Number(visualizationState.width); + const intensity = Number(visualizationState.intensity); + for (const layers of routeLayers.values()) { + for (const group of Object.values(layers)) { + group.eachLayer((layer) => { + const vizType = layer.options?.atlasVizType; + if (!vizType || typeof layer.setStyle !== "function") return; + if (vizType === "routeTrace") { + layer.setStyle({ + weight: 4 * width, + opacity: Math.min(1, 0.35 + intensity * 0.6), + }); + } else if (vizType === "routeGlow") { + layer.setStyle({ + weight: 18 * width, + opacity: visualizationState.routeGlow ? 0.2 * intensity : 0, + }); + } else if (vizType === "roughnessSegment") { + layer.setStyle({ + weight: 6 * width, + opacity: visualizationState.pointBleed + ? 0 + : Math.min(1, 0.18 + intensity * 0.72), + lineCap: "round", + }); + } else if (vizType === "roughnessPoint") { + layer.setRadius(4.8 * Math.sqrt(width)); + layer.setStyle({ + opacity: visualizationState.pointBleed ? 0 : 0.95, + fillOpacity: visualizationState.pointBleed ? 0 : Math.min(1, 0.28 + intensity * 0.72), + }); + } else if (vizType === "pointBleed") { + const baseRadius = Number(layer.options.atlasBaseRadius ?? 12); + const strength = Number(layer.options.atlasStrength ?? 1); + layer.setRadius(baseRadius * Math.sqrt(width)); + layer.setStyle({ + fillOpacity: visualizationState.pointBleed && layer.options.atlasSource !== "roughness" + ? Math.min(0.22, 0.08 * intensity * strength) + : 0, + }); + } else if (vizType === "samplePoint") { + layer.setStyle({ + opacity: visualizationState.pointBleed + ? 0 + : Number(layer.options.atlasBaseOpacity ?? 1), + fillOpacity: visualizationState.pointBleed + ? 0 + : Number(layer.options.atlasBaseFillOpacity ?? 0.7), + }); + } else if (vizType === "impactHalo") { + const severity = Number(layer.options.atlasSeverity ?? 1); + layer.setRadius((13 + severity * 8) * Math.sqrt(width)); + layer.setStyle({ fillOpacity: visualizationState.impactHalos ? 0.13 * intensity : 0 }); + } else if (vizType === "eventPoint") { + const severity = Number(layer.options.atlasSeverity ?? 1); + layer.setRadius(Math.min(12, (4 + severity * 2.2) * Math.sqrt(width))); + layer.setStyle({ + opacity: visualizationState.pointBleed ? 0 : 1, + fillOpacity: visualizationState.pointBleed ? 0 : Math.min(1, 0.34 + intensity * 0.66), + }); + } + }); + } + } + + $(".map-panel").classList.toggle("viz-night", visualizationState.nightMap); + roughnessHeatLayer.scheduleRedraw(); + $("#visual-width").value = String(width); + $("#visual-intensity").value = String(intensity); + $("#visual-width-value").textContent = `${width.toFixed(1)}×`; + $("#visual-intensity-value").textContent = `${Math.round(intensity * 100)}%`; + $("#route-glow-toggle").checked = visualizationState.routeGlow; + $("#impact-halo-toggle").checked = visualizationState.impactHalos; + $("#point-bleed-toggle").checked = visualizationState.pointBleed; + $("#night-map-toggle").checked = visualizationState.nightMap; + $$("[data-viz-preset]").forEach((button) => { + button.setAttribute("aria-pressed", String(button.dataset.vizPreset === visualizationState.preset)); + }); + if (save) { + window.localStorage.setItem("clusterMapsVisualization", JSON.stringify(visualizationState)); + } +} + +function chooseVisualizationPreset(name) { + const preset = VISUAL_PRESETS[name]; + if (!preset) return; + visualizationState = { preset: name, ...preset }; + applyVisualizationStyles(); +} + +function updateVisualizationSetting(key, value) { + visualizationState = { ...visualizationState, preset: "custom", [key]: value }; + if (visualizationFrame !== null) return; + visualizationFrame = window.requestAnimationFrame(() => { + visualizationFrame = null; + applyVisualizationStyles(); + }); +} + +function restoreVisualizationState() { + try { + const stored = JSON.parse(window.localStorage.getItem("clusterMapsVisualization")); + if (!stored || typeof stored !== "object") return; + if (stored.preset in VISUAL_PRESETS) { + visualizationState = { preset: stored.preset, ...VISUAL_PRESETS[stored.preset] }; + return; + } + visualizationState = { + preset: typeof stored.preset === "string" ? stored.preset : "custom", + width: Math.min(3.5, Math.max(0.7, Number(stored.width) || 1)), + intensity: Math.min(1, Math.max(0.25, Number(stored.intensity) || 0.8)), + routeGlow: Boolean(stored.routeGlow), + impactHalos: Boolean(stored.impactHalos), + pointBleed: Boolean(stored.pointBleed), + nightMap: Boolean(stored.nightMap), + }; + } catch { + window.localStorage.removeItem("clusterMapsVisualization"); + } +} + +function createRouteCard(route, color) { + const card = document.createElement("div"); + card.className = "route-card"; + + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.checked = routeVisibility.get(route.id); + checkbox.dataset.routeId = route.id; + checkbox.setAttribute("aria-label", `Show ${route.label}`); + checkbox.addEventListener("change", () => { + routeVisibility.set(route.id, checkbox.checked); + syncLayers(); + }); + + const swatch = document.createElement("span"); + swatch.className = "route-swatch"; + swatch.style.background = color; + + const copy = document.createElement("button"); + copy.type = "button"; + copy.className = "route-copy"; + const title = document.createElement("strong"); + const routeName = route.routeName || route.label; + title.textContent = routeName; + const details = document.createElement("small"); + const segmentText = route.metadata.segmentCount ? `${route.metadata.segmentCount} segments · ` : ""; + const labelText = route.label !== routeName ? `${route.label} · ` : ""; + const hasRoadMotion = route.summary.maximumSpeedMps >= 2.5 && route.summary.distanceM >= 100; + const motionText = hasRoadMotion ? "" : "stationary session · "; + details.textContent = `${labelText}Driver ${route.driverId} · ${segmentText}${motionText}${formatDistance(route.summary.distanceM)} · ${route.summary.eventCounts.impact} impacts`; + copy.append(title, details); + copy.addEventListener("click", () => { + const bounds = L.latLngBounds(route.track.map((point) => [point.latitude, point.longitude])); + map.fitBounds(bounds, { padding: [45, 45], maxZoom: 17 }); + }); + + const remove = document.createElement("button"); + remove.type = "button"; + remove.className = "route-delete"; + remove.textContent = "×"; + remove.setAttribute("aria-label", `Delete ${routeName}`); + remove.addEventListener("click", async () => { + if (!window.confirm(`Delete ${routeName} from this local atlas? The source rlogs are not changed.`)) return; + remove.disabled = true; + try { + const response = await fetch("/api/remove", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Road-Quality-Request": "1", + }, + body: JSON.stringify({ cacheId: route.id }), + }); + const payload = await response.json(); + if (!response.ok) throw new Error(payload.error || `Delete returned ${response.status}`); + window.location.reload(); + } catch (error) { + remove.disabled = false; + $("#route-manager-status").textContent = error.message; + } + }); + + card.append(checkbox, swatch, copy, remove); + return card; +} + +function renderRepeatedLocations(clusters) { + repeatedLayer.clearLayers(); + for (const cluster of clusters) { + const marker = L.circleMarker([cluster.latitude, cluster.longitude], { + radius: Math.min(14, 7 + cluster.routeCount), + color: "#da3b50", + weight: 3, + fillColor: "#ffffff", + fillOpacity: 0.76, + dashArray: "2 3", + }); + marker.bindTooltip(`${cluster.routeCount} routes · ${cluster.observationCount} observations`, { className: "road-tooltip" }); + marker.on("click", () => { + inspectObservation( + `${cluster.driverCount} independent driver${cluster.driverCount === 1 ? "" : "s"}`, + titleForKind("repeated"), + "Nearby impact or roughness observations were clustered across independently imported routes.", + [ + ["Routes", String(cluster.routeCount)], + ["Drivers", String(cluster.driverCount)], + ["Observations", String(cluster.observationCount)], + ["Signal types", cluster.problemTypes.join(", ")], + ["Max severity", `${cluster.maximumSeverity.toFixed(2)} × threshold`], + ], + ); + }); + marker.addTo(repeatedLayer); + } +} + +function updateCounts(index, routes) { + const totalDistance = routes.reduce((sum, route) => sum + route.summary.distanceM, 0); + const eventCounts = Object.fromEntries(LAYER_KEYS.map((key) => [key, 0])); + eventCounts.route = routes.length; + eventCounts.gps = routes.reduce((sum, route) => sum + route.track.length, 0); + + let roughObservations = 0; + for (const route of routes) { + roughObservations += route.layers.roughness.length; + for (const event of route.layers.events) { + if (event.kind !== "bodyMotion") eventCounts[event.kind] += 1; + } + eventCounts.bodyMotion += route.layers.bodyMotion.filter((point) => point.isExcessive).length; + for (const key of TELEMETRY_KEYS) { + eventCounts[key] += route.layers.telemetry?.[key]?.length ?? 0; + } + } + eventCounts.roughness = roughObservations; + + $("#route-count").textContent = String(index.routeCount); + $("#driver-count").textContent = String(index.driverCount); + $("#distance-total").textContent = formatDistance(totalDistance); + $("#observation-count").textContent = String( + eventCounts.roughness + eventCounts.impact + eventCounts.hardAcceleration + + eventCounts.hardBraking + eventCounts.bodyMotion, + ); + for (const [key, count] of Object.entries(eventCounts)) { + const output = $(`[data-count="${key}"]`); + if (output) output.textContent = String(count); + } + $("[data-count=\"repeated\"]").textContent = String(index.repeatedLocations.length); +} + +function renderServiceCatalog(routes) { + const services = new Map(); + for (const route of routes) { + for (const entry of route.source.serviceCatalog ?? []) { + const aggregate = services.get(entry.service) ?? { + ...entry, + count: 0, + rateHz: 0, + routeCount: 0, + }; + aggregate.count += entry.count; + aggregate.rateHz = Math.max(aggregate.rateHz, entry.rateHz); + aggregate.routeCount += 1; + aggregate.browserExported ||= entry.browserExported; + services.set(entry.service, aggregate); + } + } + + const entries = [...services.values()].sort((a, b) => { + if (a.browserExported !== b.browserExported) return a.browserExported ? -1 : 1; + return a.service.localeCompare(b.service); + }); + $("#service-count").textContent = String(entries.length); + const list = $("#service-list"); + list.replaceChildren(); + for (const entry of entries) { + const item = document.createElement("div"); + item.className = "service-item"; + const name = document.createElement("strong"); + name.textContent = entry.service; + const rate = document.createElement("output"); + rate.textContent = entry.rateHz > 0 ? `${entry.rateHz.toFixed(1)} Hz` : `${entry.count} msg`; + const description = document.createElement("span"); + description.textContent = `${entry.description} · `; + const state = document.createElement("em"); + state.textContent = entry.browserExported ? `mapped as ${entry.mapLayer}` : "inventory only"; + description.append(state); + item.append(name, rate, description); + list.append(item); + } +} + +function fitAll() { + if (fullBounds?.isValid()) { + map.fitBounds(fullBounds, { padding: [42, 42], maxZoom: 16 }); + } +} + +function setAllRouteVisibility(visible) { + for (const route of loadedRoutes) routeVisibility.set(route.id, visible); + $$("#route-list input[data-route-id]").forEach((checkbox) => { + checkbox.checked = visible; + }); + syncLayers(); +} + +async function loadAtlas() { + try { + const indexResponse = await fetch("data/index.json", { cache: "no-store" }); + if (!indexResponse.ok) throw new Error(`index.json returned ${indexResponse.status}`); + atlasIndex = await indexResponse.json(); + if (atlasIndex.schemaVersion !== 2) throw new Error("Unsupported cache schema"); + + const routeResponses = await Promise.all(atlasIndex.routes.map(async (routeEntry) => { + const response = await fetch(`data/${routeEntry.file}`, { cache: "no-store" }); + if (!response.ok) throw new Error(`${routeEntry.file} returned ${response.status}`); + return response.json(); + })); + loadedRoutes = routeResponses; + + const list = $("#route-list"); + list.replaceChildren(); + fullBounds = L.latLngBounds([]); + loadedRoutes.forEach((route, index) => { + const color = createRouteLayers(route, index); + list.append(createRouteCard(route, color)); + if (routeVisibility.get(route.id)) { + for (const point of route.track) fullBounds.extend([point.latitude, point.longitude]); + } + }); + if (!fullBounds.isValid()) { + for (const route of loadedRoutes) { + for (const point of route.track) fullBounds.extend([point.latitude, point.longitude]); + } + } + renderRepeatedLocations(atlasIndex.repeatedLocations); + updateCounts(atlasIndex, loadedRoutes); + renderServiceCatalog(loadedRoutes); + applyVisualizationStyles(false); + syncLayers(); + fitAll(); + + $(".data-state").classList.add("ready"); + $("#data-status").textContent = `Cache ready · ${new Date(atlasIndex.generatedAt).toLocaleString()}`; + if (loadedRoutes.length === 0) { + $("#map-message").hidden = false; + $("#map-message strong").textContent = "No routes imported yet"; + } + } catch (error) { + console.error(error); + $(".data-state").classList.add("error"); + $("#data-status").textContent = "Cache unavailable"; + $("#map-message").hidden = false; + $("#map-message span").textContent = `The local cache could not be loaded: ${error.message}`; + map.setView([39.5, -98.35], 4); + } +} + +function selectedSegments() { + return $$("#segment-list input:checked").map((input) => Number(input.value)); +} + +async function discoverSegments(event) { + event.preventDefault(); + const route = $("#route-input").value.trim(); + if (!route) return; + $("#discover-route").disabled = true; + $("#route-manager-status").textContent = "Looking up available segments…"; + try { + const response = await fetch(`/api/segments?route=${encodeURIComponent(route)}`, { cache: "no-store" }); + const payload = await response.json(); + if (!response.ok) throw new Error(payload.error || `Segment lookup returned ${response.status}`); + $("#route-input").value = payload.route; + const list = $("#segment-list"); + list.replaceChildren(); + for (const segment of payload.segments) { + const option = document.createElement("label"); + option.className = "segment-option"; + const input = document.createElement("input"); + input.type = "checkbox"; + input.value = String(segment.index); + input.checked = true; + const text = document.createElement("span"); + text.textContent = String(segment.index); + option.append(input, text); + list.append(option); + } + $("#segment-summary").textContent = `${payload.segmentCount} available segments`; + $("#segment-picker").hidden = false; + $("#route-manager-status").textContent = "Choose segments, then start the local import."; + } catch (error) { + $("#segment-picker").hidden = true; + $("#route-manager-status").textContent = error.message; + } finally { + $("#discover-route").disabled = false; + } +} + +async function pollImport(jobId) { + const response = await fetch(`/api/jobs/${jobId}`, { cache: "no-store" }); + const job = await response.json(); + if (!response.ok) { + if (response.status === 404) { + window.localStorage.removeItem("roadQualityImportJob"); + $("#import-progress").hidden = true; + } + throw new Error(job.error || `Progress check returned ${response.status}`); + } + $("#progress-bar").value = job.percent; + $("#progress-bar").textContent = `${job.percent}%`; + $("#progress-percent").textContent = `${job.percent}%`; + $("#progress-stage").textContent = job.stage; + $("#progress-detail").textContent = job.totalSegments + ? `${job.completedSegments} of ${job.totalSegments} segments parsed` + : "Preparing full-rlog analysis"; + if (job.status === "complete") { + window.localStorage.removeItem("roadQualityImportJob"); + $("#route-manager-status").textContent = "Import complete. Refreshing the atlas…"; + window.setTimeout(() => window.location.reload(), 650); + return; + } + if (job.status === "error") throw new Error(job.error || "Import failed"); + window.setTimeout(() => pollImport(jobId).catch(showImportError), 500); +} + +function showImportError(error) { + $("#route-manager-status").textContent = error.message; + $("#progress-stage").textContent = "Import failed"; + $("#import-selected").disabled = false; +} + +async function importSelectedSegments() { + const segments = selectedSegments(); + if (!segments.length) { + $("#route-manager-status").textContent = "Select at least one segment."; + return; + } + $("#import-selected").disabled = true; + $("#import-progress").hidden = false; + $("#progress-bar").value = 0; + $("#progress-percent").textContent = "0%"; + $("#progress-stage").textContent = "Queuing import"; + $("#route-manager-status").textContent = "The page can stay open while full rlogs are processed."; + try { + const response = await fetch("/api/import", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Road-Quality-Request": "1", + }, + body: JSON.stringify({ + route: $("#route-input").value.trim(), + label: $("#route-label").value.trim(), + segments, + }), + }); + const payload = await response.json(); + if (!response.ok) throw new Error(payload.error || `Import request returned ${response.status}`); + window.localStorage.setItem("roadQualityImportJob", payload.jobId); + await pollImport(payload.jobId); + } catch (error) { + showImportError(error); + } +} + +$$("[data-layer]").forEach((input) => { + input.addEventListener("change", () => { + if (input.dataset.layer !== "repeated") layerVisibility[input.dataset.layer] = input.checked; + syncLayers(); + }); +}); + +const BASEMAPS = { + streets: { + layer: streetTiles, + note: "OpenStreetMap streets · sensor overlays remain interactive.", + }, + satellite: { + layer: satelliteTiles, + note: "Esri World Imagery · sensor overlays remain interactive.", + }, + data: { + layer: sensorCanvas, + note: "Basemap hidden · coordinate grid and sensor overlays only.", + }, +}; + +function setBasemap(mode, save = true) { + const selectedMode = mode in BASEMAPS ? mode : "streets"; + for (const { layer } of Object.values(BASEMAPS)) { + if (map.hasLayer(layer)) map.removeLayer(layer); + } + BASEMAPS[selectedMode].layer.addTo(map); + $("#map").classList.toggle("sensor-canvas", selectedMode === "data"); + $("#map-scale-note").textContent = BASEMAPS[selectedMode].note; + $$("[data-basemap]").forEach((button) => { + button.setAttribute("aria-pressed", String(button.dataset.basemap === selectedMode)); + }); + if (save) window.localStorage.setItem("clusterMapsBasemap", selectedMode); +} + +$$("[data-basemap]").forEach((button) => { + button.addEventListener("click", () => setBasemap(button.dataset.basemap)); +}); +$("#fit-all").addEventListener("click", fitAll); +$("#select-all-routes").addEventListener("click", () => setAllRouteVisibility(true)); +$("#unselect-all-routes").addEventListener("click", () => setAllRouteVisibility(false)); +$("#reset-view").addEventListener("click", fitAll); +$("#visuals-toggle").addEventListener("click", () => { + const panel = $("#visuals-panel"); + panel.hidden = !panel.hidden; + $("#visuals-toggle").setAttribute("aria-expanded", String(!panel.hidden)); +}); +$("#visuals-close").addEventListener("click", () => { + $("#visuals-panel").hidden = true; + $("#visuals-toggle").setAttribute("aria-expanded", "false"); +}); +$$("[data-viz-preset]").forEach((button) => { + button.addEventListener("click", () => chooseVisualizationPreset(button.dataset.vizPreset)); +}); +$("#visual-width").addEventListener("input", (event) => { + updateVisualizationSetting("width", Number(event.target.value)); +}); +$("#visual-intensity").addEventListener("input", (event) => { + updateVisualizationSetting("intensity", Number(event.target.value)); +}); +$("#route-glow-toggle").addEventListener("change", (event) => { + updateVisualizationSetting("routeGlow", event.target.checked); +}); +$("#impact-halo-toggle").addEventListener("change", (event) => { + updateVisualizationSetting("impactHalos", event.target.checked); +}); +$("#point-bleed-toggle").addEventListener("change", (event) => { + updateVisualizationSetting("pointBleed", event.target.checked); +}); +$("#night-map-toggle").addEventListener("change", (event) => { + updateVisualizationSetting("nightMap", event.target.checked); +}); +$("#inspector-close").addEventListener("click", () => $("#inspector").classList.remove("visible")); +$("#route-manager").addEventListener("submit", discoverSegments); +$("#select-all-segments").addEventListener("click", () => { + $$("#segment-list input").forEach((input) => { input.checked = true; }); +}); +$("#select-no-segments").addEventListener("click", () => { + $$("#segment-list input").forEach((input) => { input.checked = false; }); +}); +$("#import-selected").addEventListener("click", importSelectedSegments); +document.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + $("#inspector").classList.remove("visible"); + $("#visuals-panel").hidden = true; + $("#visuals-toggle").setAttribute("aria-expanded", "false"); + } +}); + +restoreVisualizationState(); +applyVisualizationStyles(false); +setBasemap(window.localStorage.getItem("clusterMapsBasemap") || "streets", false); +loadAtlas(); + +const resumableJobId = window.localStorage.getItem("roadQualityImportJob"); +if (resumableJobId) { + $("#import-progress").hidden = false; + $("#route-manager-status").textContent = "Reconnecting to the saved import job…"; + pollImport(resumableJobId).catch(showImportError); +} diff --git a/tools/clustermaps/web/data/.gitignore b/tools/clustermaps/web/data/.gitignore new file mode 100644 index 0000000000..d6b7ef32c8 --- /dev/null +++ b/tools/clustermaps/web/data/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/tools/clustermaps/web/index.html b/tools/clustermaps/web/index.html new file mode 100644 index 0000000000..d1118f38db --- /dev/null +++ b/tools/clustermaps/web/index.html @@ -0,0 +1,306 @@ + + + + + + + clustermaps + + + + +
+
+
+ +
+

clustermaps

+
+
+
+
routes
+
drivers
+
mapped
+
signals
+
+
+ + Loading local cache… +
+
+ +
+ + +
+
+
+
+ + + +
+ + +
+ +
Street tiles require a connection. Sensor overlays remain available offline.
+ +
+ +

Selected observation

+

Choose a point on the map

+

Impact, roughness, motion, and driving-event markers reveal their measurements here.

+
+
+ + +
+
+
+ + + + + + diff --git a/tools/clustermaps/web/styles.css b/tools/clustermaps/web/styles.css new file mode 100644 index 0000000000..7120765600 --- /dev/null +++ b/tools/clustermaps/web/styles.css @@ -0,0 +1,1258 @@ +:root { + color-scheme: light; + --ink: #132226; + --muted: #66797d; + --faint: #eef2ef; + --panel: #f8faf7; + --white: #ffffff; + --navy: #102a2d; + --teal: #00a78e; + --lime: #b7d847; + --amber: #f2a93b; + --orange: #ed6a3c; + --red: #da3b50; + --violet: #7656c8; + --blue: #3578c8; + --border: rgba(16, 42, 45, 0.12); + --shadow: 0 18px 50px rgba(15, 37, 39, 0.13); + font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + width: 100%; + height: 100%; + overflow: hidden; + background: var(--panel); + color: var(--ink); +} + +button, +input { + font: inherit; +} + +button { + color: inherit; +} + +.app-shell { + display: grid; + grid-template-rows: 76px minmax(0, 1fr); + height: 100%; + min-height: 100%; + min-width: 0; + overflow: hidden; +} + +.topbar { + position: relative; + z-index: 1000; + display: grid; + grid-template-columns: minmax(290px, 1fr) auto minmax(230px, 1fr); + align-items: center; + gap: 30px; + min-width: 0; + padding: 0 24px; + border-bottom: 1px solid var(--border); + background: rgba(255, 255, 255, 0.96); + box-shadow: 0 4px 18px rgba(17, 44, 47, 0.06); +} + +.brand { + display: flex; + align-items: center; + gap: 13px; + min-width: 0; +} + +.brand-mark { + width: 28px; + height: 36px; + flex: 0 0 auto; + border: 2px solid var(--navy); + border-radius: 18px 18px 9px 9px; + background: + radial-gradient(circle at 50% 22%, var(--lime) 0 3px, transparent 4px), + linear-gradient(160deg, transparent 45%, var(--teal) 46% 54%, transparent 55%); + box-shadow: inset 0 -8px 0 rgba(0, 167, 142, 0.1); +} + +.eyebrow { + margin: 0 0 3px; + color: var(--teal); + font-size: 10px; + font-weight: 800; + letter-spacing: 0.13em; + line-height: 1.2; + text-transform: uppercase; +} + +h1, +h2, +p { + margin-top: 0; +} + +h1 { + overflow: hidden; + margin-bottom: 0; + font-size: 20px; + letter-spacing: -0.025em; + line-height: 1.1; + text-overflow: ellipsis; + white-space: nowrap; +} + +h2 { + margin-bottom: 0; + font-size: 15px; + letter-spacing: -0.015em; +} + +.atlas-stats { + display: grid; + grid-template-columns: repeat(4, minmax(78px, auto)); + align-items: center; + gap: 0; +} + +.atlas-stats div { + display: flex; + flex-direction: column; + min-width: 82px; + padding: 0 18px; + border-left: 1px solid var(--border); +} + +.atlas-stats div:last-child { + border-right: 1px solid var(--border); +} + +.atlas-stats strong { + font-size: 17px; + font-variant-numeric: tabular-nums; + letter-spacing: -0.025em; +} + +.atlas-stats span { + color: var(--muted); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.data-state { + display: flex; + align-items: center; + justify-self: end; + gap: 8px; + color: var(--muted); + font-size: 12px; + font-weight: 650; +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--amber); + box-shadow: 0 0 0 4px rgba(242, 169, 59, 0.15); +} + +.data-state.ready .status-dot { + background: var(--teal); + box-shadow: 0 0 0 4px rgba(0, 167, 142, 0.14); +} + +.data-state.error .status-dot { + background: var(--red); + box-shadow: 0 0 0 4px rgba(218, 59, 80, 0.14); +} + +main { + display: grid; + grid-template-columns: 330px minmax(0, 1fr); + min-height: 0; + overflow: hidden; +} + +.sidebar { + position: relative; + z-index: 700; + min-height: 0; + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; + -webkit-overflow-scrolling: touch; + border-right: 1px solid var(--border); + background: var(--panel); +} + +.control-section { + padding: 22px 20px; + border-bottom: 1px solid var(--border); +} + +.section-heading { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 16px; + margin-bottom: 15px; +} + +.text-button { + padding: 4px 0; + border: 0; + background: transparent; + color: var(--teal); + cursor: pointer; + font-size: 11px; + font-weight: 800; +} + +.text-button:hover { + color: var(--navy); +} + +.route-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 3px 9px; + max-width: 150px; +} + +.route-list { + display: grid; + gap: 8px; +} + +.route-card { + display: grid; + grid-template-columns: auto auto minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + min-width: 0; + padding: 11px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--white); + box-shadow: 0 4px 14px rgba(21, 55, 58, 0.04); +} + +.route-card input { + width: 16px; + height: 16px; + margin: 0; + accent-color: var(--teal); +} + +.route-swatch { + width: 5px; + height: 34px; + border-radius: 4px; +} + +.route-copy { + min-width: 0; + border: 0; + background: transparent; + cursor: pointer; + text-align: left; +} + +.route-copy strong, +.route-copy small { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.route-delete { + width: 25px; + height: 25px; + padding: 0; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--muted); + cursor: pointer; + font-size: 16px; + line-height: 1; +} + +.route-delete:hover { + background: rgba(218, 59, 80, 0.09); + color: var(--red); +} + +.route-copy strong { + margin-bottom: 3px; + font-size: 12px; + line-height: 1.25; + overflow-wrap: anywhere; + white-space: normal; +} + +.route-copy small { + color: var(--muted); + font-size: 10px; +} + +.route-skeleton { + height: 56px; + border-radius: 10px; + background: linear-gradient(100deg, #edf1ed 20%, #f8faf7 40%, #edf1ed 60%); + background-size: 200% 100%; + animation: shimmer 1.4s infinite; +} + +.route-skeleton.short { + width: 82%; +} + +@keyframes shimmer { + to { + background-position-x: -200%; + } +} + +.layer-list { + display: grid; + gap: 5px; +} + +.layer-control { + display: grid; + grid-template-columns: 16px 24px minmax(0, 1fr) auto; + align-items: center; + gap: 9px; + min-height: 41px; + padding: 5px 2px; + border-radius: 8px; + cursor: pointer; +} + +.layer-control:hover { + background: rgba(16, 42, 45, 0.04); +} + +.layer-control input { + width: 15px; + height: 15px; + margin: 0; + accent-color: var(--teal); +} + +.layer-control strong, +.layer-control small { + display: block; +} + +.layer-control strong { + font-size: 11px; + line-height: 1.25; +} + +.layer-control small { + margin-top: 2px; + color: var(--muted); + font-size: 9px; + line-height: 1.25; +} + +.layer-control output { + min-width: 22px; + padding: 3px 6px; + border-radius: 12px; + background: #e9efea; + color: var(--muted); + font-size: 9px; + font-weight: 800; + text-align: center; +} + +.legend-line { + width: 23px; + height: 4px; + border-radius: 3px; +} + +.route-line { + background: linear-gradient(90deg, var(--teal), var(--blue), var(--violet)); +} + +.legend-gradient { + width: 23px; + height: 6px; + border-radius: 4px; + background: linear-gradient(90deg, var(--lime), var(--amber), var(--red)); +} + +.legend-dot { + width: 11px; + height: 11px; + justify-self: center; + border: 2px solid var(--white); + border-radius: 50%; + box-shadow: 0 0 0 1px currentColor; +} + +.impact-dot { + color: var(--orange); + background: var(--orange); +} + +.acceleration-dot { + color: var(--teal); + background: var(--teal); +} + +.braking-dot { + color: var(--red); + background: var(--red); +} + +.motion-dot { + color: var(--violet); + background: var(--violet); +} + +.gps-dot { + color: var(--blue); + background: var(--blue); +} + +.imu-dot { + color: #008a78; + background: #008a78; +} + +.vehicle-dot { + color: #245f97; + background: #245f97; +} + +.pose-dot { + color: #6b5bb8; + background: #6b5bb8; +} + +.odometry-dot { + color: #447caa; + background: #447caa; +} + +.controls-dot { + color: #c48428; + background: #c48428; +} + +.calibration-dot { + color: #8a659b; + background: #8a659b; +} + +.vision-dot { + color: #3f8b54; + background: #3f8b54; +} + +.radar-dot { + color: #d05d42; + background: #d05d42; +} + +.sound-dot { + color: #b84775; + background: #b84775; +} + +.magnetic-dot { + color: #5368a8; + background: #5368a8; +} + +.thermal-dot { + color: #a55432; + background: #a55432; +} + +.layer-group-title { + margin: 10px 0 2px; + padding: 10px 2px 4px; + border-top: 1px dashed var(--border); + color: var(--muted); + font-size: 9px; + font-weight: 800; + letter-spacing: 0.11em; + text-transform: uppercase; +} + +.legend-ring { + width: 15px; + height: 15px; + justify-self: center; + border: 3px double var(--red); + border-radius: 50%; + background: rgba(218, 59, 80, 0.12); +} + +.repeated-control { + margin-top: 5px; + padding-top: 10px; + border-top: 1px dashed var(--border); +} + +.method-note { + margin: 18px 20px 28px; + padding: 15px; + border: 1px solid rgba(0, 167, 142, 0.17); + border-radius: 10px; + background: rgba(0, 167, 142, 0.06); +} + +.import-help { + margin: 18px 20px; + padding: 15px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--white); +} + +.import-help p, +.import-help li { + color: var(--muted); + font-size: 10px; + line-height: 1.5; +} + +.import-help h2 { + margin-bottom: 13px; +} + +.field-label { + display: flex; + justify-content: space-between; + margin: 10px 0 5px; + color: var(--ink); + font-size: 10px; + font-weight: 750; +} + +.field-label span { + color: var(--muted); + font-weight: 500; +} + +.import-help input { + min-width: 0; + width: 100%; + padding: 8px 9px; + border: 1px solid var(--border); + border-radius: 7px; + outline: none; + background: var(--panel); + color: var(--ink); + font: 9px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.import-help input:focus { + border-color: var(--teal); + box-shadow: 0 0 0 3px rgba(0, 167, 142, 0.1); +} + +.field-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 6px; +} + +.field-row button, +.segment-heading button { + border: 0; + background: transparent; + color: var(--teal); + cursor: pointer; + font-size: 9px; + font-weight: 800; +} + +.roughness-scale { + display: grid; + grid-template-columns: auto minmax(44px, 1fr) auto; + align-items: center; + gap: 6px; + margin: -2px 4px 5px 49px; + color: var(--muted); + font-size: 8px; +} + +.roughness-scale i { + height: 4px; + border-radius: 4px; + background: linear-gradient(90deg, #91b63c, #f2a93b 55%, #ed6a3c 78%, #da3b50); +} + +.field-row button { + padding: 0 7px; + border-radius: 7px; + background: rgba(0, 167, 142, 0.1); +} + +.segment-picker, +.import-progress { + margin-top: 13px; + padding-top: 12px; + border-top: 1px solid var(--border); +} + +.segment-heading, +.import-progress > div { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + font-size: 10px; +} + +.segment-list { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 5px; + max-height: 126px; + margin: 9px 0; + overflow-y: auto; +} + +.segment-option { + display: flex; + align-items: center; + gap: 5px; + padding: 6px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--panel); + cursor: pointer; + font: 9px/1 ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.segment-option input { + width: 12px; + height: 12px; + accent-color: var(--teal); +} + +.primary-button { + width: 100%; + padding: 9px 10px; + border: 0; + border-radius: 7px; + background: var(--teal); + color: var(--white); + cursor: pointer; + font-size: 10px; + font-weight: 800; +} + +.primary-button:disabled { + cursor: wait; + opacity: 0.55; +} + +.import-progress progress { + width: 100%; + height: 8px; + margin-top: 9px; + accent-color: var(--teal); +} + +.route-manager-status { + margin: 10px 0 0; +} + +.service-section { + padding-top: 17px; +} + +.service-catalog summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + cursor: pointer; + list-style: none; +} + +.service-catalog summary::-webkit-details-marker { + display: none; +} + +.service-catalog summary strong { + display: block; + font-size: 13px; +} + +.service-catalog summary output { + min-width: 28px; + padding: 5px 8px; + border-radius: 14px; + background: var(--navy); + color: var(--white); + font-size: 10px; + font-weight: 800; + text-align: center; +} + +.service-catalog > p { + margin: 13px 0; + color: var(--muted); + font-size: 10px; + line-height: 1.45; +} + +.service-list { + display: grid; + gap: 6px; +} + +.service-item { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 4px 10px; + padding: 8px 9px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--white); +} + +.service-item strong { + overflow: hidden; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 9px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.service-item output { + color: var(--muted); + font-size: 9px; + font-variant-numeric: tabular-nums; + font-weight: 750; +} + +.service-item span { + grid-column: 1 / -1; + color: var(--muted); + font-size: 9px; + line-height: 1.35; +} + +.service-item em { + color: var(--teal); + font-style: normal; + font-weight: 750; +} + +.method-note p:last-child { + margin-bottom: 0; + color: #486166; + font-size: 11px; + line-height: 1.5; +} + +.map-panel { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + linear-gradient(rgba(16, 42, 45, 0.055) 1px, transparent 1px), + linear-gradient(90deg, rgba(16, 42, 45, 0.055) 1px, transparent 1px), + #e9eee9; + background-size: 32px 32px; +} + +#map { + width: 100%; + height: 100%; + background: transparent; +} + +#map.sensor-canvas { + background: + radial-gradient(circle at center, rgba(0, 167, 142, 0.08), transparent 58%), + #e8efeb; +} + +.sensor-grid-tile { + image-rendering: auto; +} + +.leaflet-container { + font-family: inherit; +} + +.leaflet-tile-pane { + filter: saturate(0.58) contrast(0.95) brightness(1.02); +} + +.leaflet-atlasBleed-pane { + filter: blur(7px); +} + +.roughness-heat-canvas { + image-rendering: auto; + pointer-events: none; +} + +.leaflet-control-attribution { + border-radius: 5px 0 0; + background: rgba(255, 255, 255, 0.8) !important; + color: #65777a; + font-size: 9px; +} + +.leaflet-control-attribution a { + color: #2e6f68; +} + +.leaflet-bar { + overflow: hidden; + border: 1px solid var(--border) !important; + border-radius: 9px !important; + box-shadow: var(--shadow) !important; +} + +.leaflet-bar a { + color: var(--navy) !important; +} + +.map-toolbar { + position: absolute; + z-index: 650; + top: 16px; + right: 16px; + display: flex; + align-items: center; + gap: 8px; + padding: 7px; + border: 1px solid var(--border); + border-radius: 11px; + background: rgba(255, 255, 255, 0.94); + box-shadow: var(--shadow); + backdrop-filter: blur(12px); +} + +.map-toolbar button { + height: 31px; + border: 0; + border-radius: 7px; + font-size: 10px; + font-weight: 800; +} + +.map-toolbar button { + padding: 0 11px; + background: var(--faint); + cursor: pointer; +} + +.map-toolbar button:hover { + background: #dfe8e1; +} + +.map-toolbar button[aria-expanded="true"] { + background: var(--navy); + color: var(--white); +} + +.basemap-picker { + display: flex; + gap: 2px; + padding: 2px; + border-radius: 8px; + background: var(--faint); +} + +.map-toolbar .basemap-picker button { + height: 27px; + padding: 0 9px; + background: transparent; + color: var(--muted); +} + +.map-toolbar .basemap-picker button:hover { + background: rgba(255, 255, 255, 0.72); +} + +.map-toolbar .basemap-picker button[aria-pressed="true"] { + background: var(--white); + color: var(--navy); + box-shadow: 0 1px 4px rgba(16, 42, 45, 0.14); +} + +.visuals-panel { + position: absolute; + z-index: 640; + top: 71px; + right: 16px; + width: min(280px, calc(100% - 32px)); + padding: 15px; + border: 1px solid var(--border); + border-radius: 12px; + background: rgba(255, 255, 255, 0.96); + box-shadow: var(--shadow); + backdrop-filter: blur(16px); +} + +.visuals-panel[hidden] { + display: none; +} + +.visuals-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.visuals-heading button { + width: 27px; + height: 27px; + padding: 0; + border: 0; + border-radius: 7px; + background: var(--faint); + color: var(--muted); + cursor: pointer; + font-size: 17px; +} + +.visual-presets { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 5px; + margin: 14px 0; +} + +.visual-presets button { + min-width: 0; + padding: 7px 3px; + border: 1px solid var(--border); + border-radius: 7px; + background: var(--panel); + color: var(--muted); + cursor: pointer; + font-size: 9px; + font-weight: 800; +} + +.visual-presets button[aria-pressed="true"] { + border-color: var(--navy); + background: var(--navy); + color: var(--white); +} + +.visual-range { + display: block; + margin-top: 11px; +} + +.visual-range > span { + display: flex; + justify-content: space-between; + gap: 12px; + color: var(--muted); + font-size: 10px; + font-weight: 750; +} + +.visual-range output { + color: var(--ink); + font-variant-numeric: tabular-nums; +} + +.visual-range input { + width: 100%; + margin: 6px 0 0; + accent-color: var(--teal); +} + +.visual-switches { + display: grid; + gap: 7px; + margin-top: 13px; + padding-top: 12px; + border-top: 1px solid var(--border); +} + +.visual-switches label { + display: grid; + grid-template-columns: 15px minmax(0, 1fr); + align-items: start; + gap: 9px; + cursor: pointer; +} + +.visual-switches input { + width: 15px; + height: 15px; + margin: 2px 0 0; + accent-color: var(--teal); +} + +.visual-switches strong, +.visual-switches small { + display: block; +} + +.visual-switches strong { + font-size: 10px; +} + +.visual-switches small { + margin-top: 2px; + color: var(--muted); + font-size: 9px; + line-height: 1.35; +} + +.viz-night { + background: #122024; +} + +.viz-night .leaflet-tile-pane { + filter: grayscale(0.88) invert(0.92) hue-rotate(165deg) brightness(0.58) contrast(1.35); +} + +.viz-night .leaflet-control-attribution, +.viz-night .leaflet-bar { + filter: none; +} + +.map-scale-note { + position: absolute; + z-index: 620; + right: 17px; + bottom: 18px; + max-width: 250px; + padding: 6px 9px; + border-radius: 6px; + background: rgba(16, 42, 45, 0.78); + color: rgba(255, 255, 255, 0.83); + font-size: 9px; + line-height: 1.35; + pointer-events: none; +} + +.inspector { + position: absolute; + z-index: 640; + left: 18px; + bottom: 18px; + width: min(370px, calc(100% - 36px)); + max-height: 42%; + overflow: auto; + padding: 18px 20px; + border: 1px solid var(--border); + border-radius: 12px; + background: rgba(255, 255, 255, 0.96); + box-shadow: var(--shadow); + backdrop-filter: blur(14px); + transform: translateY(calc(100% + 30px)); + opacity: 0; + pointer-events: none; + transition: transform 190ms ease, opacity 190ms ease; +} + +.inspector.visible { + transform: translateY(0); + opacity: 1; + pointer-events: auto; +} + +.inspector h2 { + padding-right: 24px; + font-size: 18px; +} + +.inspector > p:not(.eyebrow) { + margin: 9px 0 14px; + color: var(--muted); + font-size: 11px; + line-height: 1.45; +} + +.inspector-close { + position: absolute; + top: 10px; + right: 11px; + width: 26px; + height: 26px; + border: 0; + border-radius: 50%; + background: var(--faint); + cursor: pointer; + color: var(--muted); + font-size: 18px; + line-height: 1; +} + +.inspector dl { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + margin: 0; +} + +.inspector dl div { + padding: 9px 10px; + border-radius: 8px; + background: var(--faint); +} + +.inspector dt { + color: var(--muted); + font-size: 8px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.inspector dd { + margin: 3px 0 0; + font-size: 12px; + font-weight: 800; + font-variant-numeric: tabular-nums; +} + +.map-message { + position: absolute; + z-index: 700; + top: 50%; + left: 50%; + display: grid; + gap: 7px; + width: min(390px, calc(100% - 40px)); + padding: 24px; + border: 1px solid rgba(218, 59, 80, 0.2); + border-radius: 13px; + background: rgba(255, 255, 255, 0.97); + box-shadow: var(--shadow); + text-align: center; + transform: translate(-50%, -50%); +} + +.map-message[hidden] { + display: none; +} + +.map-message strong { + font-size: 17px; +} + +.map-message span { + color: var(--muted); + font-size: 12px; + line-height: 1.45; +} + +.leaflet-tooltip.road-tooltip { + padding: 7px 9px; + border: 0; + border-radius: 7px; + background: var(--navy); + box-shadow: 0 7px 20px rgba(15, 37, 39, 0.2); + color: var(--white); + font-size: 10px; + font-weight: 750; +} + +.leaflet-tooltip.road-tooltip::before { + border-top-color: var(--navy); +} + +@media (max-width: 980px) { + .topbar { + grid-template-columns: minmax(240px, 1fr) auto; + } + + .data-state { + display: none; + } + + .atlas-stats div { + min-width: 65px; + padding: 0 10px; + } + + main { + grid-template-columns: 290px minmax(0, 1fr); + } +} + +@media (max-width: 720px) { + html, + body { + overflow: auto; + } + + .app-shell { + grid-template-rows: auto auto; + height: auto; + min-height: 100%; + } + + .topbar { + grid-template-columns: 1fr; + gap: 12px; + padding: 15px 17px; + } + + .atlas-stats { + grid-template-columns: repeat(4, 1fr); + width: 100%; + } + + .atlas-stats div { + padding: 0 8px; + } + + main { + grid-template-columns: 1fr; + } + + .sidebar { + max-height: none; + border-right: 0; + } + + .map-panel { + min-height: 68vh; + } + + .method-note { + display: none; + } + + .map-toolbar { + left: 10px; + right: 10px; + overflow-x: auto; + } + + .map-scale-note { + display: none; + } +} diff --git a/tools/clustermaps/web/vendor/leaflet/LICENSE b/tools/clustermaps/web/vendor/leaflet/LICENSE new file mode 100644 index 0000000000..7b41f200ab --- /dev/null +++ b/tools/clustermaps/web/vendor/leaflet/LICENSE @@ -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. diff --git a/tools/clustermaps/web/vendor/leaflet/images/layers-2x.png b/tools/clustermaps/web/vendor/leaflet/images/layers-2x.png new file mode 100644 index 0000000000..200c333dca Binary files /dev/null and b/tools/clustermaps/web/vendor/leaflet/images/layers-2x.png differ diff --git a/tools/clustermaps/web/vendor/leaflet/images/layers.png b/tools/clustermaps/web/vendor/leaflet/images/layers.png new file mode 100644 index 0000000000..1a72e5784b Binary files /dev/null and b/tools/clustermaps/web/vendor/leaflet/images/layers.png differ diff --git a/tools/clustermaps/web/vendor/leaflet/images/marker-icon-2x.png b/tools/clustermaps/web/vendor/leaflet/images/marker-icon-2x.png new file mode 100644 index 0000000000..88f9e50188 Binary files /dev/null and b/tools/clustermaps/web/vendor/leaflet/images/marker-icon-2x.png differ diff --git a/tools/clustermaps/web/vendor/leaflet/images/marker-icon.png b/tools/clustermaps/web/vendor/leaflet/images/marker-icon.png new file mode 100644 index 0000000000..950edf2467 Binary files /dev/null and b/tools/clustermaps/web/vendor/leaflet/images/marker-icon.png differ diff --git a/tools/clustermaps/web/vendor/leaflet/images/marker-shadow.png b/tools/clustermaps/web/vendor/leaflet/images/marker-shadow.png new file mode 100644 index 0000000000..9fd2979532 Binary files /dev/null and b/tools/clustermaps/web/vendor/leaflet/images/marker-shadow.png differ diff --git a/tools/clustermaps/web/vendor/leaflet/leaflet.css b/tools/clustermaps/web/vendor/leaflet/leaflet.css new file mode 100644 index 0000000000..9ade8dc49b --- /dev/null +++ b/tools/clustermaps/web/vendor/leaflet/leaflet.css @@ -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; + } + } diff --git a/tools/clustermaps/web/vendor/leaflet/leaflet.js b/tools/clustermaps/web/vendor/leaflet/leaflet.js new file mode 100644 index 0000000000..a3bf693d0f --- /dev/null +++ b/tools/clustermaps/web/vendor/leaflet/leaflet.js @@ -0,0 +1,6 @@ +/* @preserve + * Leaflet 1.9.4, a JS library for interactive maps. https://leafletjs.com + * (c) 2010-2023 Vladimir Agafonkin, (c) 2010-2011 CloudMade + */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).leaflet={})}(this,function(t){"use strict";function l(t){for(var e,i,n=1,o=arguments.length;n=this.min.x&&i.x<=this.max.x&&e.y>=this.min.y&&i.y<=this.max.y},intersects:function(t){t=_(t);var e=this.min,i=this.max,n=t.min,t=t.max,o=t.x>=e.x&&n.x<=i.x,t=t.y>=e.y&&n.y<=i.y;return o&&t},overlaps:function(t){t=_(t);var e=this.min,i=this.max,n=t.min,t=t.max,o=t.x>e.x&&n.xe.y&&n.y=n.lat&&i.lat<=o.lat&&e.lng>=n.lng&&i.lng<=o.lng},intersects:function(t){t=g(t);var e=this._southWest,i=this._northEast,n=t.getSouthWest(),t=t.getNorthEast(),o=t.lat>=e.lat&&n.lat<=i.lat,t=t.lng>=e.lng&&n.lng<=i.lng;return o&&t},overlaps:function(t){t=g(t);var e=this._southWest,i=this._northEast,n=t.getSouthWest(),t=t.getNorthEast(),o=t.lat>e.lat&&n.late.lng&&n.lng","http://www.w3.org/2000/svg"===(Wt.firstChild&&Wt.firstChild.namespaceURI));function y(t){return 0<=navigator.userAgent.toLowerCase().indexOf(t)}var b={ie:pt,ielt9:mt,edge:n,webkit:ft,android:gt,android23:vt,androidStock:yt,opera:xt,chrome:wt,gecko:bt,safari:Pt,phantom:Lt,opera12:o,win:Tt,ie3d:Mt,webkit3d:zt,gecko3d:_t,any3d:Ct,mobile:Zt,mobileWebkit:St,mobileWebkit3d:Et,msPointer:kt,pointer:Ot,touch:Bt,touchNative:At,mobileOpera:It,mobileGecko:Rt,retina:Nt,passiveEvents:Dt,canvas:jt,svg:Ht,vml:!Ht&&function(){try{var t=document.createElement("div"),e=(t.innerHTML='',t.firstChild);return e.style.behavior="url(#default#VML)",e&&"object"==typeof e.adj}catch(t){return!1}}(),inlineSvg:Wt,mac:0===navigator.platform.indexOf("Mac"),linux:0===navigator.platform.indexOf("Linux")},Ft=b.msPointer?"MSPointerDown":"pointerdown",Ut=b.msPointer?"MSPointerMove":"pointermove",Vt=b.msPointer?"MSPointerUp":"pointerup",qt=b.msPointer?"MSPointerCancel":"pointercancel",Gt={touchstart:Ft,touchmove:Ut,touchend:Vt,touchcancel:qt},Kt={touchstart:function(t,e){e.MSPOINTER_TYPE_TOUCH&&e.pointerType===e.MSPOINTER_TYPE_TOUCH&&O(e);ee(t,e)},touchmove:ee,touchend:ee,touchcancel:ee},Yt={},Xt=!1;function Jt(t,e,i){return"touchstart"!==e||Xt||(document.addEventListener(Ft,$t,!0),document.addEventListener(Ut,Qt,!0),document.addEventListener(Vt,te,!0),document.addEventListener(qt,te,!0),Xt=!0),Kt[e]?(i=Kt[e].bind(this,i),t.addEventListener(Gt[e],i,!1),i):(console.warn("wrong event specified:",e),u)}function $t(t){Yt[t.pointerId]=t}function Qt(t){Yt[t.pointerId]&&(Yt[t.pointerId]=t)}function te(t){delete Yt[t.pointerId]}function ee(t,e){if(e.pointerType!==(e.MSPOINTER_TYPE_MOUSE||"mouse")){for(var i in e.touches=[],Yt)e.touches.push(Yt[i]);e.changedTouches=[e],t(e)}}var ie=200;function ne(t,i){t.addEventListener("dblclick",i);var n,o=0;function e(t){var e;1!==t.detail?n=t.detail:"mouse"===t.pointerType||t.sourceCapabilities&&!t.sourceCapabilities.firesTouchEvents||((e=Ne(t)).some(function(t){return t instanceof HTMLLabelElement&&t.attributes.for})&&!e.some(function(t){return t instanceof HTMLInputElement||t instanceof HTMLSelectElement})||((e=Date.now())-o<=ie?2===++n&&i(function(t){var e,i,n={};for(i in t)e=t[i],n[i]=e&&e.bind?e.bind(t):e;return(t=n).type="dblclick",n.detail=2,n.isTrusted=!1,n._simulated=!0,n}(t)):n=1,o=e))}return t.addEventListener("click",e),{dblclick:i,simDblclick:e}}var oe,se,re,ae,he,le,ue=we(["transform","webkitTransform","OTransform","MozTransform","msTransform"]),ce=we(["webkitTransition","transition","OTransition","MozTransition","msTransition"]),de="webkitTransition"===ce||"OTransition"===ce?ce+"End":"transitionend";function _e(t){return"string"==typeof t?document.getElementById(t):t}function pe(t,e){var i=t.style[e]||t.currentStyle&&t.currentStyle[e];return"auto"===(i=i&&"auto"!==i||!document.defaultView?i:(t=document.defaultView.getComputedStyle(t,null))?t[e]:null)?null:i}function P(t,e,i){t=document.createElement(t);return t.className=e||"",i&&i.appendChild(t),t}function T(t){var e=t.parentNode;e&&e.removeChild(t)}function me(t){for(;t.firstChild;)t.removeChild(t.firstChild)}function fe(t){var e=t.parentNode;e&&e.lastChild!==t&&e.appendChild(t)}function ge(t){var e=t.parentNode;e&&e.firstChild!==t&&e.insertBefore(t,e.firstChild)}function ve(t,e){return void 0!==t.classList?t.classList.contains(e):0<(t=xe(t)).length&&new RegExp("(^|\\s)"+e+"(\\s|$)").test(t)}function M(t,e){var i;if(void 0!==t.classList)for(var n=F(e),o=0,s=n.length;othis.options.maxZoom)?this.setZoom(t):this},panInsideBounds:function(t,e){this._enforcingBounds=!0;var i=this.getCenter(),t=this._limitCenter(i,this._zoom,g(t));return i.equals(t)||this.panTo(t,e),this._enforcingBounds=!1,this},panInside:function(t,e){var i=m((e=e||{}).paddingTopLeft||e.padding||[0,0]),n=m(e.paddingBottomRight||e.padding||[0,0]),o=this.project(this.getCenter()),t=this.project(t),s=this.getPixelBounds(),i=_([s.min.add(i),s.max.subtract(n)]),s=i.getSize();return i.contains(t)||(this._enforcingBounds=!0,n=t.subtract(i.getCenter()),i=i.extend(t).getSize().subtract(s),o.x+=n.x<0?-i.x:i.x,o.y+=n.y<0?-i.y:i.y,this.panTo(this.unproject(o),e),this._enforcingBounds=!1),this},invalidateSize:function(t){if(!this._loaded)return this;t=l({animate:!1,pan:!0},!0===t?{animate:!0}:t);var e=this.getSize(),i=(this._sizeChanged=!0,this._lastCenter=null,this.getSize()),n=e.divideBy(2).round(),o=i.divideBy(2).round(),n=n.subtract(o);return n.x||n.y?(t.animate&&t.pan?this.panBy(n):(t.pan&&this._rawPanBy(n),this.fire("move"),t.debounceMoveend?(clearTimeout(this._sizeTimer),this._sizeTimer=setTimeout(a(this.fire,this,"moveend"),200)):this.fire("moveend")),this.fire("resize",{oldSize:e,newSize:i})):this},stop:function(){return this.setZoom(this._limitZoom(this._zoom)),this.options.zoomSnap||this.fire("viewreset"),this._stop()},locate:function(t){var e,i;return t=this._locateOptions=l({timeout:1e4,watch:!1},t),"geolocation"in navigator?(e=a(this._handleGeolocationResponse,this),i=a(this._handleGeolocationError,this),t.watch?this._locationWatchId=navigator.geolocation.watchPosition(e,i,t):navigator.geolocation.getCurrentPosition(e,i,t)):this._handleGeolocationError({code:0,message:"Geolocation not supported."}),this},stopLocate:function(){return navigator.geolocation&&navigator.geolocation.clearWatch&&navigator.geolocation.clearWatch(this._locationWatchId),this._locateOptions&&(this._locateOptions.setView=!1),this},_handleGeolocationError:function(t){var e;this._container._leaflet_id&&(e=t.code,t=t.message||(1===e?"permission denied":2===e?"position unavailable":"timeout"),this._locateOptions.setView&&!this._loaded&&this.fitWorld(),this.fire("locationerror",{code:e,message:"Geolocation error: "+t+"."}))},_handleGeolocationResponse:function(t){if(this._container._leaflet_id){var e,i,n=new v(t.coords.latitude,t.coords.longitude),o=n.toBounds(2*t.coords.accuracy),s=this._locateOptions,r=(s.setView&&(e=this.getBoundsZoom(o),this.setView(n,s.maxZoom?Math.min(e,s.maxZoom):e)),{latlng:n,bounds:o,timestamp:t.timestamp});for(i in t.coords)"number"==typeof t.coords[i]&&(r[i]=t.coords[i]);this.fire("locationfound",r)}},addHandler:function(t,e){return e&&(e=this[t]=new e(this),this._handlers.push(e),this.options[t]&&e.enable()),this},remove:function(){if(this._initEvents(!0),this.options.maxBounds&&this.off("moveend",this._panInsideMaxBounds),this._containerId!==this._container._leaflet_id)throw new Error("Map container is being reused by another instance");try{delete this._container._leaflet_id,delete this._containerId}catch(t){this._container._leaflet_id=void 0,this._containerId=void 0}for(var t in void 0!==this._locationWatchId&&this.stopLocate(),this._stop(),T(this._mapPane),this._clearControlPos&&this._clearControlPos(),this._resizeRequest&&(r(this._resizeRequest),this._resizeRequest=null),this._clearHandlers(),this._loaded&&this.fire("unload"),this._layers)this._layers[t].remove();for(t in this._panes)T(this._panes[t]);return this._layers=[],this._panes=[],delete this._mapPane,delete this._renderer,this},createPane:function(t,e){e=P("div","leaflet-pane"+(t?" leaflet-"+t.replace("Pane","")+"-pane":""),e||this._mapPane);return t&&(this._panes[t]=e),e},getCenter:function(){return this._checkIfLoaded(),this._lastCenter&&!this._moved()?this._lastCenter.clone():this.layerPointToLatLng(this._getCenterLayerPoint())},getZoom:function(){return this._zoom},getBounds:function(){var t=this.getPixelBounds();return new s(this.unproject(t.getBottomLeft()),this.unproject(t.getTopRight()))},getMinZoom:function(){return void 0===this.options.minZoom?this._layersMinZoom||0:this.options.minZoom},getMaxZoom:function(){return void 0===this.options.maxZoom?void 0===this._layersMaxZoom?1/0:this._layersMaxZoom:this.options.maxZoom},getBoundsZoom:function(t,e,i){t=g(t),i=m(i||[0,0]);var n=this.getZoom()||0,o=this.getMinZoom(),s=this.getMaxZoom(),r=t.getNorthWest(),t=t.getSouthEast(),i=this.getSize().subtract(i),t=_(this.project(t,n),this.project(r,n)).getSize(),r=b.any3d?this.options.zoomSnap:1,a=i.x/t.x,i=i.y/t.y,t=e?Math.max(a,i):Math.min(a,i),n=this.getScaleZoom(t,n);return r&&(n=Math.round(n/(r/100))*(r/100),n=e?Math.ceil(n/r)*r:Math.floor(n/r)*r),Math.max(o,Math.min(s,n))},getSize:function(){return this._size&&!this._sizeChanged||(this._size=new p(this._container.clientWidth||0,this._container.clientHeight||0),this._sizeChanged=!1),this._size.clone()},getPixelBounds:function(t,e){t=this._getTopLeftPoint(t,e);return new f(t,t.add(this.getSize()))},getPixelOrigin:function(){return this._checkIfLoaded(),this._pixelOrigin},getPixelWorldBounds:function(t){return this.options.crs.getProjectedBounds(void 0===t?this.getZoom():t)},getPane:function(t){return"string"==typeof t?this._panes[t]:t},getPanes:function(){return this._panes},getContainer:function(){return this._container},getZoomScale:function(t,e){var i=this.options.crs;return e=void 0===e?this._zoom:e,i.scale(t)/i.scale(e)},getScaleZoom:function(t,e){var i=this.options.crs,t=(e=void 0===e?this._zoom:e,i.zoom(t*i.scale(e)));return isNaN(t)?1/0:t},project:function(t,e){return e=void 0===e?this._zoom:e,this.options.crs.latLngToPoint(w(t),e)},unproject:function(t,e){return e=void 0===e?this._zoom:e,this.options.crs.pointToLatLng(m(t),e)},layerPointToLatLng:function(t){t=m(t).add(this.getPixelOrigin());return this.unproject(t)},latLngToLayerPoint:function(t){return this.project(w(t))._round()._subtract(this.getPixelOrigin())},wrapLatLng:function(t){return this.options.crs.wrapLatLng(w(t))},wrapLatLngBounds:function(t){return this.options.crs.wrapLatLngBounds(g(t))},distance:function(t,e){return this.options.crs.distance(w(t),w(e))},containerPointToLayerPoint:function(t){return m(t).subtract(this._getMapPanePos())},layerPointToContainerPoint:function(t){return m(t).add(this._getMapPanePos())},containerPointToLatLng:function(t){t=this.containerPointToLayerPoint(m(t));return this.layerPointToLatLng(t)},latLngToContainerPoint:function(t){return this.layerPointToContainerPoint(this.latLngToLayerPoint(w(t)))},mouseEventToContainerPoint:function(t){return De(t,this._container)},mouseEventToLayerPoint:function(t){return this.containerPointToLayerPoint(this.mouseEventToContainerPoint(t))},mouseEventToLatLng:function(t){return this.layerPointToLatLng(this.mouseEventToLayerPoint(t))},_initContainer:function(t){t=this._container=_e(t);if(!t)throw new Error("Map container not found.");if(t._leaflet_id)throw new Error("Map container is already initialized.");S(t,"scroll",this._onScroll,this),this._containerId=h(t)},_initLayout:function(){var t=this._container,e=(this._fadeAnimated=this.options.fadeAnimation&&b.any3d,M(t,"leaflet-container"+(b.touch?" leaflet-touch":"")+(b.retina?" leaflet-retina":"")+(b.ielt9?" leaflet-oldie":"")+(b.safari?" leaflet-safari":"")+(this._fadeAnimated?" leaflet-fade-anim":"")),pe(t,"position"));"absolute"!==e&&"relative"!==e&&"fixed"!==e&&"sticky"!==e&&(t.style.position="relative"),this._initPanes(),this._initControlPos&&this._initControlPos()},_initPanes:function(){var t=this._panes={};this._paneRenderers={},this._mapPane=this.createPane("mapPane",this._container),Z(this._mapPane,new p(0,0)),this.createPane("tilePane"),this.createPane("overlayPane"),this.createPane("shadowPane"),this.createPane("markerPane"),this.createPane("tooltipPane"),this.createPane("popupPane"),this.options.markerZoomAnimation||(M(t.markerPane,"leaflet-zoom-hide"),M(t.shadowPane,"leaflet-zoom-hide"))},_resetView:function(t,e,i){Z(this._mapPane,new p(0,0));var n=!this._loaded,o=(this._loaded=!0,e=this._limitZoom(e),this.fire("viewprereset"),this._zoom!==e);this._moveStart(o,i)._move(t,e)._moveEnd(o),this.fire("viewreset"),n&&this.fire("load")},_moveStart:function(t,e){return t&&this.fire("zoomstart"),e||this.fire("movestart"),this},_move:function(t,e,i,n){void 0===e&&(e=this._zoom);var o=this._zoom!==e;return this._zoom=e,this._lastCenter=t,this._pixelOrigin=this._getNewPixelOrigin(t),n?i&&i.pinch&&this.fire("zoom",i):((o||i&&i.pinch)&&this.fire("zoom",i),this.fire("move",i)),this},_moveEnd:function(t){return t&&this.fire("zoomend"),this.fire("moveend")},_stop:function(){return r(this._flyToFrame),this._panAnim&&this._panAnim.stop(),this},_rawPanBy:function(t){Z(this._mapPane,this._getMapPanePos().subtract(t))},_getZoomSpan:function(){return this.getMaxZoom()-this.getMinZoom()},_panInsideMaxBounds:function(){this._enforcingBounds||this.panInsideBounds(this.options.maxBounds)},_checkIfLoaded:function(){if(!this._loaded)throw new Error("Set map center and zoom first.")},_initEvents:function(t){this._targets={};var e=t?k:S;e((this._targets[h(this._container)]=this)._container,"click dblclick mousedown mouseup mouseover mouseout mousemove contextmenu keypress keydown keyup",this._handleDOMEvent,this),this.options.trackResize&&e(window,"resize",this._onResize,this),b.any3d&&this.options.transform3DLimit&&(t?this.off:this.on).call(this,"moveend",this._onMoveEnd)},_onResize:function(){r(this._resizeRequest),this._resizeRequest=x(function(){this.invalidateSize({debounceMoveend:!0})},this)},_onScroll:function(){this._container.scrollTop=0,this._container.scrollLeft=0},_onMoveEnd:function(){var t=this._getMapPanePos();Math.max(Math.abs(t.x),Math.abs(t.y))>=this.options.transform3DLimit&&this._resetView(this.getCenter(),this.getZoom())},_findEventTargets:function(t,e){for(var i,n=[],o="mouseout"===e||"mouseover"===e,s=t.target||t.srcElement,r=!1;s;){if((i=this._targets[h(s)])&&("click"===e||"preclick"===e)&&this._draggableMoved(i)){r=!0;break}if(i&&i.listens(e,!0)){if(o&&!We(s,t))break;if(n.push(i),o)break}if(s===this._container)break;s=s.parentNode}return n=n.length||r||o||!this.listens(e,!0)?n:[this]},_isClickDisabled:function(t){for(;t&&t!==this._container;){if(t._leaflet_disable_click)return!0;t=t.parentNode}},_handleDOMEvent:function(t){var e,i=t.target||t.srcElement;!this._loaded||i._leaflet_disable_events||"click"===t.type&&this._isClickDisabled(i)||("mousedown"===(e=t.type)&&Me(i),this._fireDOMEvent(t,e))},_mouseEvents:["click","dblclick","mouseover","mouseout","contextmenu"],_fireDOMEvent:function(t,e,i){"click"===t.type&&((a=l({},t)).type="preclick",this._fireDOMEvent(a,a.type,i));var n=this._findEventTargets(t,e);if(i){for(var o=[],s=0;sthis.options.zoomAnimationThreshold)return!1;var n=this.getZoomScale(e),n=this._getCenterOffset(t)._divideBy(1-1/n);if(!0!==i.animate&&!this.getSize().contains(n))return!1;x(function(){this._moveStart(!0,i.noMoveStart||!1)._animateZoom(t,e,!0)},this)}return!0},_animateZoom:function(t,e,i,n){this._mapPane&&(i&&(this._animatingZoom=!0,this._animateToCenter=t,this._animateToZoom=e,M(this._mapPane,"leaflet-zoom-anim")),this.fire("zoomanim",{center:t,zoom:e,noUpdate:n}),this._tempFireZoomEvent||(this._tempFireZoomEvent=this._zoom!==this._animateToZoom),this._move(this._animateToCenter,this._animateToZoom,void 0,!0),setTimeout(a(this._onZoomTransitionEnd,this),250))},_onZoomTransitionEnd:function(){this._animatingZoom&&(this._mapPane&&z(this._mapPane,"leaflet-zoom-anim"),this._animatingZoom=!1,this._move(this._animateToCenter,this._animateToZoom,void 0,!0),this._tempFireZoomEvent&&this.fire("zoom"),delete this._tempFireZoomEvent,this.fire("move"),this._moveEnd(!0))}});function Ue(t){return new B(t)}var B=et.extend({options:{position:"topright"},initialize:function(t){c(this,t)},getPosition:function(){return this.options.position},setPosition:function(t){var e=this._map;return e&&e.removeControl(this),this.options.position=t,e&&e.addControl(this),this},getContainer:function(){return this._container},addTo:function(t){this.remove(),this._map=t;var e=this._container=this.onAdd(t),i=this.getPosition(),t=t._controlCorners[i];return M(e,"leaflet-control"),-1!==i.indexOf("bottom")?t.insertBefore(e,t.firstChild):t.appendChild(e),this._map.on("unload",this.remove,this),this},remove:function(){return this._map&&(T(this._container),this.onRemove&&this.onRemove(this._map),this._map.off("unload",this.remove,this),this._map=null),this},_refocusOnMap:function(t){this._map&&t&&0",e=document.createElement("div");return e.innerHTML=t,e.firstChild},_addItem:function(t){var e,i=document.createElement("label"),n=this._map.hasLayer(t.layer),n=(t.overlay?((e=document.createElement("input")).type="checkbox",e.className="leaflet-control-layers-selector",e.defaultChecked=n):e=this._createRadioElement("leaflet-base-layers_"+h(this),n),this._layerControlInputs.push(e),e.layerId=h(t.layer),S(e,"click",this._onInputClick,this),document.createElement("span")),o=(n.innerHTML=" "+t.name,document.createElement("span"));return i.appendChild(o),o.appendChild(e),o.appendChild(n),(t.overlay?this._overlaysList:this._baseLayersList).appendChild(i),this._checkDisabledLayers(),i},_onInputClick:function(){if(!this._preventClick){var t,e,i=this._layerControlInputs,n=[],o=[];this._handlingClick=!0;for(var s=i.length-1;0<=s;s--)t=i[s],e=this._getLayer(t.layerId).layer,t.checked?n.push(e):t.checked||o.push(e);for(s=0;se.options.maxZoom},_expandIfNotCollapsed:function(){return this._map&&!this.options.collapsed&&this.expand(),this},_expandSafely:function(){var t=this._section,e=(this._preventClick=!0,S(t,"click",O),this.expand(),this);setTimeout(function(){k(t,"click",O),e._preventClick=!1})}})),qe=B.extend({options:{position:"topleft",zoomInText:'',zoomInTitle:"Zoom in",zoomOutText:'',zoomOutTitle:"Zoom out"},onAdd:function(t){var e="leaflet-control-zoom",i=P("div",e+" leaflet-bar"),n=this.options;return this._zoomInButton=this._createButton(n.zoomInText,n.zoomInTitle,e+"-in",i,this._zoomIn),this._zoomOutButton=this._createButton(n.zoomOutText,n.zoomOutTitle,e+"-out",i,this._zoomOut),this._updateDisabled(),t.on("zoomend zoomlevelschange",this._updateDisabled,this),i},onRemove:function(t){t.off("zoomend zoomlevelschange",this._updateDisabled,this)},disable:function(){return this._disabled=!0,this._updateDisabled(),this},enable:function(){return this._disabled=!1,this._updateDisabled(),this},_zoomIn:function(t){!this._disabled&&this._map._zoomthis._map.getMinZoom()&&this._map.zoomOut(this._map.options.zoomDelta*(t.shiftKey?3:1))},_createButton:function(t,e,i,n,o){i=P("a",i,n);return i.innerHTML=t,i.href="#",i.title=e,i.setAttribute("role","button"),i.setAttribute("aria-label",e),Ie(i),S(i,"click",Re),S(i,"click",o,this),S(i,"click",this._refocusOnMap,this),i},_updateDisabled:function(){var t=this._map,e="leaflet-disabled";z(this._zoomInButton,e),z(this._zoomOutButton,e),this._zoomInButton.setAttribute("aria-disabled","false"),this._zoomOutButton.setAttribute("aria-disabled","false"),!this._disabled&&t._zoom!==t.getMinZoom()||(M(this._zoomOutButton,e),this._zoomOutButton.setAttribute("aria-disabled","true")),!this._disabled&&t._zoom!==t.getMaxZoom()||(M(this._zoomInButton,e),this._zoomInButton.setAttribute("aria-disabled","true"))}}),Ge=(A.mergeOptions({zoomControl:!0}),A.addInitHook(function(){this.options.zoomControl&&(this.zoomControl=new qe,this.addControl(this.zoomControl))}),B.extend({options:{position:"bottomleft",maxWidth:100,metric:!0,imperial:!0},onAdd:function(t){var e="leaflet-control-scale",i=P("div",e),n=this.options;return this._addScales(n,e+"-line",i),t.on(n.updateWhenIdle?"moveend":"move",this._update,this),t.whenReady(this._update,this),i},onRemove:function(t){t.off(this.options.updateWhenIdle?"moveend":"move",this._update,this)},_addScales:function(t,e,i){t.metric&&(this._mScale=P("div",e,i)),t.imperial&&(this._iScale=P("div",e,i))},_update:function(){var t=this._map,e=t.getSize().y/2,t=t.distance(t.containerPointToLatLng([0,e]),t.containerPointToLatLng([this.options.maxWidth,e]));this._updateScales(t)},_updateScales:function(t){this.options.metric&&t&&this._updateMetric(t),this.options.imperial&&t&&this._updateImperial(t)},_updateMetric:function(t){var e=this._getRoundNum(t);this._updateScale(this._mScale,e<1e3?e+" m":e/1e3+" km",e/t)},_updateImperial:function(t){var e,i,t=3.2808399*t;5280'+(b.inlineSvg?' ':"")+"Leaflet"},initialize:function(t){c(this,t),this._attributions={}},onAdd:function(t){for(var e in(t.attributionControl=this)._container=P("div","leaflet-control-attribution"),Ie(this._container),t._layers)t._layers[e].getAttribution&&this.addAttribution(t._layers[e].getAttribution());return this._update(),t.on("layeradd",this._addAttribution,this),this._container},onRemove:function(t){t.off("layeradd",this._addAttribution,this)},_addAttribution:function(t){t.layer.getAttribution&&(this.addAttribution(t.layer.getAttribution()),t.layer.once("remove",function(){this.removeAttribution(t.layer.getAttribution())},this))},setPrefix:function(t){return this.options.prefix=t,this._update(),this},addAttribution:function(t){return t&&(this._attributions[t]||(this._attributions[t]=0),this._attributions[t]++,this._update()),this},removeAttribution:function(t){return t&&this._attributions[t]&&(this._attributions[t]--,this._update()),this},_update:function(){if(this._map){var t,e=[];for(t in this._attributions)this._attributions[t]&&e.push(t);var i=[];this.options.prefix&&i.push(this.options.prefix),e.length&&i.push(e.join(", ")),this._container.innerHTML=i.join(' ')}}}),n=(A.mergeOptions({attributionControl:!0}),A.addInitHook(function(){this.options.attributionControl&&(new Ke).addTo(this)}),B.Layers=Ve,B.Zoom=qe,B.Scale=Ge,B.Attribution=Ke,Ue.layers=function(t,e,i){return new Ve(t,e,i)},Ue.zoom=function(t){return new qe(t)},Ue.scale=function(t){return new Ge(t)},Ue.attribution=function(t){return new Ke(t)},et.extend({initialize:function(t){this._map=t},enable:function(){return this._enabled||(this._enabled=!0,this.addHooks()),this},disable:function(){return this._enabled&&(this._enabled=!1,this.removeHooks()),this},enabled:function(){return!!this._enabled}})),ft=(n.addTo=function(t,e){return t.addHandler(e,this),this},{Events:e}),Ye=b.touch?"touchstart mousedown":"mousedown",Xe=it.extend({options:{clickTolerance:3},initialize:function(t,e,i,n){c(this,n),this._element=t,this._dragStartTarget=e||t,this._preventOutline=i},enable:function(){this._enabled||(S(this._dragStartTarget,Ye,this._onDown,this),this._enabled=!0)},disable:function(){this._enabled&&(Xe._dragging===this&&this.finishDrag(!0),k(this._dragStartTarget,Ye,this._onDown,this),this._enabled=!1,this._moved=!1)},_onDown:function(t){var e,i;this._enabled&&(this._moved=!1,ve(this._element,"leaflet-zoom-anim")||(t.touches&&1!==t.touches.length?Xe._dragging===this&&this.finishDrag():Xe._dragging||t.shiftKey||1!==t.which&&1!==t.button&&!t.touches||((Xe._dragging=this)._preventOutline&&Me(this._element),Le(),re(),this._moving||(this.fire("down"),i=t.touches?t.touches[0]:t,e=Ce(this._element),this._startPoint=new p(i.clientX,i.clientY),this._startPos=Pe(this._element),this._parentScale=Ze(e),i="mousedown"===t.type,S(document,i?"mousemove":"touchmove",this._onMove,this),S(document,i?"mouseup":"touchend touchcancel",this._onUp,this)))))},_onMove:function(t){var e;this._enabled&&(t.touches&&1e&&(i.push(t[n]),o=n);oe.max.x&&(i|=2),t.ye.max.y&&(i|=8),i}function ri(t,e,i,n){var o=e.x,e=e.y,s=i.x-o,r=i.y-e,a=s*s+r*r;return 0this._layersMaxZoom&&this.setZoom(this._layersMaxZoom),void 0===this.options.minZoom&&this._layersMinZoom&&this.getZoom()t.y!=n.y>t.y&&t.x<(n.x-i.x)*(t.y-i.y)/(n.y-i.y)+i.x&&(l=!l);return l||yi.prototype._containsPoint.call(this,t,!0)}});var wi=ci.extend({initialize:function(t,e){c(this,e),this._layers={},t&&this.addData(t)},addData:function(t){var e,i,n,o=d(t)?t:t.features;if(o){for(e=0,i=o.length;es.x&&(r=i.x+a-s.x+o.x),i.x-r-n.x<(a=0)&&(r=i.x-n.x),i.y+e+o.y>s.y&&(a=i.y+e-s.y+o.y),i.y-a-n.y<0&&(a=i.y-n.y),(r||a)&&(this.options.keepInView&&(this._autopanning=!0),t.fire("autopanstart").panBy([r,a]))))},_getAnchor:function(){return m(this._source&&this._source._getPopupAnchor?this._source._getPopupAnchor():[0,0])}})),Ii=(A.mergeOptions({closePopupOnClick:!0}),A.include({openPopup:function(t,e,i){return this._initOverlay(Bi,t,e,i).openOn(this),this},closePopup:function(t){return(t=arguments.length?t:this._popup)&&t.close(),this}}),o.include({bindPopup:function(t,e){return this._popup=this._initOverlay(Bi,this._popup,t,e),this._popupHandlersAdded||(this.on({click:this._openPopup,keypress:this._onKeyPress,remove:this.closePopup,move:this._movePopup}),this._popupHandlersAdded=!0),this},unbindPopup:function(){return this._popup&&(this.off({click:this._openPopup,keypress:this._onKeyPress,remove:this.closePopup,move:this._movePopup}),this._popupHandlersAdded=!1,this._popup=null),this},openPopup:function(t){return this._popup&&(this instanceof ci||(this._popup._source=this),this._popup._prepareOpen(t||this._latlng)&&this._popup.openOn(this._map)),this},closePopup:function(){return this._popup&&this._popup.close(),this},togglePopup:function(){return this._popup&&this._popup.toggle(this),this},isPopupOpen:function(){return!!this._popup&&this._popup.isOpen()},setPopupContent:function(t){return this._popup&&this._popup.setContent(t),this},getPopup:function(){return this._popup},_openPopup:function(t){var e;this._popup&&this._map&&(Re(t),e=t.layer||t.target,this._popup._source!==e||e instanceof fi?(this._popup._source=e,this.openPopup(t.latlng)):this._map.hasLayer(this._popup)?this.closePopup():this.openPopup(t.latlng))},_movePopup:function(t){this._popup.setLatLng(t.latlng)},_onKeyPress:function(t){13===t.originalEvent.keyCode&&this._openPopup(t)}}),Ai.extend({options:{pane:"tooltipPane",offset:[0,0],direction:"auto",permanent:!1,sticky:!1,opacity:.9},onAdd:function(t){Ai.prototype.onAdd.call(this,t),this.setOpacity(this.options.opacity),t.fire("tooltipopen",{tooltip:this}),this._source&&(this.addEventParent(this._source),this._source.fire("tooltipopen",{tooltip:this},!0))},onRemove:function(t){Ai.prototype.onRemove.call(this,t),t.fire("tooltipclose",{tooltip:this}),this._source&&(this.removeEventParent(this._source),this._source.fire("tooltipclose",{tooltip:this},!0))},getEvents:function(){var t=Ai.prototype.getEvents.call(this);return this.options.permanent||(t.preclick=this.close),t},_initLayout:function(){var t="leaflet-tooltip "+(this.options.className||"")+" leaflet-zoom-"+(this._zoomAnimated?"animated":"hide");this._contentNode=this._container=P("div",t),this._container.setAttribute("role","tooltip"),this._container.setAttribute("id","leaflet-tooltip-"+h(this))},_updateLayout:function(){},_adjustPan:function(){},_setPosition:function(t){var e,i=this._map,n=this._container,o=i.latLngToContainerPoint(i.getCenter()),i=i.layerPointToContainerPoint(t),s=this.options.direction,r=n.offsetWidth,a=n.offsetHeight,h=m(this.options.offset),l=this._getAnchor(),i="top"===s?(e=r/2,a):"bottom"===s?(e=r/2,0):(e="center"===s?r/2:"right"===s?0:"left"===s?r:i.xthis.options.maxZoom||nthis.options.maxZoom||void 0!==this.options.minZoom&&oi.max.x)||!e.wrapLat&&(t.yi.max.y))return!1}return!this.options.bounds||(e=this._tileCoordsToBounds(t),g(this.options.bounds).overlaps(e))},_keyToBounds:function(t){return this._tileCoordsToBounds(this._keyToTileCoords(t))},_tileCoordsToNwSe:function(t){var e=this._map,i=this.getTileSize(),n=t.scaleBy(i),i=n.add(i);return[e.unproject(n,t.z),e.unproject(i,t.z)]},_tileCoordsToBounds:function(t){t=this._tileCoordsToNwSe(t),t=new s(t[0],t[1]);return t=this.options.noWrap?t:this._map.wrapLatLngBounds(t)},_tileCoordsToKey:function(t){return t.x+":"+t.y+":"+t.z},_keyToTileCoords:function(t){var t=t.split(":"),e=new p(+t[0],+t[1]);return e.z=+t[2],e},_removeTile:function(t){var e=this._tiles[t];e&&(T(e.el),delete this._tiles[t],this.fire("tileunload",{tile:e.el,coords:this._keyToTileCoords(t)}))},_initTile:function(t){M(t,"leaflet-tile");var e=this.getTileSize();t.style.width=e.x+"px",t.style.height=e.y+"px",t.onselectstart=u,t.onmousemove=u,b.ielt9&&this.options.opacity<1&&C(t,this.options.opacity)},_addTile:function(t,e){var i=this._getTilePos(t),n=this._tileCoordsToKey(t),o=this.createTile(this._wrapCoords(t),a(this._tileReady,this,t));this._initTile(o),this.createTile.length<2&&x(a(this._tileReady,this,t,null,o)),Z(o,i),this._tiles[n]={el:o,coords:t,current:!0},e.appendChild(o),this.fire("tileloadstart",{tile:o,coords:t})},_tileReady:function(t,e,i){e&&this.fire("tileerror",{error:e,tile:i,coords:t});var n=this._tileCoordsToKey(t);(i=this._tiles[n])&&(i.loaded=+new Date,this._map._fadeAnimated?(C(i.el,0),r(this._fadeFrame),this._fadeFrame=x(this._updateOpacity,this)):(i.active=!0,this._pruneTiles()),e||(M(i.el,"leaflet-tile-loaded"),this.fire("tileload",{tile:i.el,coords:t})),this._noTilesToLoad()&&(this._loading=!1,this.fire("load"),b.ielt9||!this._map._fadeAnimated?x(this._pruneTiles,this):setTimeout(a(this._pruneTiles,this),250)))},_getTilePos:function(t){return t.scaleBy(this.getTileSize()).subtract(this._level.origin)},_wrapCoords:function(t){var e=new p(this._wrapX?H(t.x,this._wrapX):t.x,this._wrapY?H(t.y,this._wrapY):t.y);return e.z=t.z,e},_pxBoundsToTileRange:function(t){var e=this.getTileSize();return new f(t.min.unscaleBy(e).floor(),t.max.unscaleBy(e).ceil().subtract([1,1]))},_noTilesToLoad:function(){for(var t in this._tiles)if(!this._tiles[t].loaded)return!1;return!0}});var Di=Ni.extend({options:{minZoom:0,maxZoom:18,subdomains:"abc",errorTileUrl:"",zoomOffset:0,tms:!1,zoomReverse:!1,detectRetina:!1,crossOrigin:!1,referrerPolicy:!1},initialize:function(t,e){this._url=t,(e=c(this,e)).detectRetina&&b.retina&&0')}}catch(t){}return function(t){return document.createElement("<"+t+' xmlns="urn:schemas-microsoft.com:vml" class="lvml">')}}(),zt={_initContainer:function(){this._container=P("div","leaflet-vml-container")},_update:function(){this._map._animatingZoom||(Wi.prototype._update.call(this),this.fire("update"))},_initPath:function(t){var e=t._container=Vi("shape");M(e,"leaflet-vml-shape "+(this.options.className||"")),e.coordsize="1 1",t._path=Vi("path"),e.appendChild(t._path),this._updateStyle(t),this._layers[h(t)]=t},_addPath:function(t){var e=t._container;this._container.appendChild(e),t.options.interactive&&t.addInteractiveTarget(e)},_removePath:function(t){var e=t._container;T(e),t.removeInteractiveTarget(e),delete this._layers[h(t)]},_updateStyle:function(t){var e=t._stroke,i=t._fill,n=t.options,o=t._container;o.stroked=!!n.stroke,o.filled=!!n.fill,n.stroke?(e=e||(t._stroke=Vi("stroke")),o.appendChild(e),e.weight=n.weight+"px",e.color=n.color,e.opacity=n.opacity,n.dashArray?e.dashStyle=d(n.dashArray)?n.dashArray.join(" "):n.dashArray.replace(/( *, *)/g," "):e.dashStyle="",e.endcap=n.lineCap.replace("butt","flat"),e.joinstyle=n.lineJoin):e&&(o.removeChild(e),t._stroke=null),n.fill?(i=i||(t._fill=Vi("fill")),o.appendChild(i),i.color=n.fillColor||n.color,i.opacity=n.fillOpacity):i&&(o.removeChild(i),t._fill=null)},_updateCircle:function(t){var e=t._point.round(),i=Math.round(t._radius),n=Math.round(t._radiusY||i);this._setPath(t,t._empty()?"M0 0":"AL "+e.x+","+e.y+" "+i+","+n+" 0,23592600")},_setPath:function(t,e){t._path.v=e},_bringToFront:function(t){fe(t._container)},_bringToBack:function(t){ge(t._container)}},qi=b.vml?Vi:ct,Gi=Wi.extend({_initContainer:function(){this._container=qi("svg"),this._container.setAttribute("pointer-events","none"),this._rootGroup=qi("g"),this._container.appendChild(this._rootGroup)},_destroyContainer:function(){T(this._container),k(this._container),delete this._container,delete this._rootGroup,delete this._svgSize},_update:function(){var t,e,i;this._map._animatingZoom&&this._bounds||(Wi.prototype._update.call(this),e=(t=this._bounds).getSize(),i=this._container,this._svgSize&&this._svgSize.equals(e)||(this._svgSize=e,i.setAttribute("width",e.x),i.setAttribute("height",e.y)),Z(i,t.min),i.setAttribute("viewBox",[t.min.x,t.min.y,e.x,e.y].join(" ")),this.fire("update"))},_initPath:function(t){var e=t._path=qi("path");t.options.className&&M(e,t.options.className),t.options.interactive&&M(e,"leaflet-interactive"),this._updateStyle(t),this._layers[h(t)]=t},_addPath:function(t){this._rootGroup||this._initContainer(),this._rootGroup.appendChild(t._path),t.addInteractiveTarget(t._path)},_removePath:function(t){T(t._path),t.removeInteractiveTarget(t._path),delete this._layers[h(t)]},_updatePath:function(t){t._project(),t._update()},_updateStyle:function(t){var e=t._path,t=t.options;e&&(t.stroke?(e.setAttribute("stroke",t.color),e.setAttribute("stroke-opacity",t.opacity),e.setAttribute("stroke-width",t.weight),e.setAttribute("stroke-linecap",t.lineCap),e.setAttribute("stroke-linejoin",t.lineJoin),t.dashArray?e.setAttribute("stroke-dasharray",t.dashArray):e.removeAttribute("stroke-dasharray"),t.dashOffset?e.setAttribute("stroke-dashoffset",t.dashOffset):e.removeAttribute("stroke-dashoffset")):e.setAttribute("stroke","none"),t.fill?(e.setAttribute("fill",t.fillColor||t.color),e.setAttribute("fill-opacity",t.fillOpacity),e.setAttribute("fill-rule",t.fillRule||"evenodd")):e.setAttribute("fill","none"))},_updatePoly:function(t,e){this._setPath(t,dt(t._parts,e))},_updateCircle:function(t){var e=t._point,i=Math.max(Math.round(t._radius),1),n="a"+i+","+(Math.max(Math.round(t._radiusY),1)||i)+" 0 1,0 ",e=t._empty()?"M0 0":"M"+(e.x-i)+","+e.y+n+2*i+",0 "+n+2*-i+",0 ";this._setPath(t,e)},_setPath:function(t,e){t._path.setAttribute("d",e)},_bringToFront:function(t){fe(t._path)},_bringToBack:function(t){ge(t._path)}});function Ki(t){return b.svg||b.vml?new Gi(t):null}b.vml&&Gi.include(zt),A.include({getRenderer:function(t){t=(t=t.options.renderer||this._getPaneRenderer(t.options.pane)||this.options.renderer||this._renderer)||(this._renderer=this._createRenderer());return this.hasLayer(t)||this.addLayer(t),t},_getPaneRenderer:function(t){var e;return"overlayPane"!==t&&void 0!==t&&(void 0===(e=this._paneRenderers[t])&&(e=this._createRenderer({pane:t}),this._paneRenderers[t]=e),e)},_createRenderer:function(t){return this.options.preferCanvas&&Ui(t)||Ki(t)}});var Yi=xi.extend({initialize:function(t,e){xi.prototype.initialize.call(this,this._boundsToLatLngs(t),e)},setBounds:function(t){return this.setLatLngs(this._boundsToLatLngs(t))},_boundsToLatLngs:function(t){return[(t=g(t)).getSouthWest(),t.getNorthWest(),t.getNorthEast(),t.getSouthEast()]}});Gi.create=qi,Gi.pointsToPath=dt,wi.geometryToLayer=bi,wi.coordsToLatLng=Li,wi.coordsToLatLngs=Ti,wi.latLngToCoords=Mi,wi.latLngsToCoords=zi,wi.getFeature=Ci,wi.asFeature=Zi,A.mergeOptions({boxZoom:!0});var _t=n.extend({initialize:function(t){this._map=t,this._container=t._container,this._pane=t._panes.overlayPane,this._resetStateTimeout=0,t.on("unload",this._destroy,this)},addHooks:function(){S(this._container,"mousedown",this._onMouseDown,this)},removeHooks:function(){k(this._container,"mousedown",this._onMouseDown,this)},moved:function(){return this._moved},_destroy:function(){T(this._pane),delete this._pane},_resetState:function(){this._resetStateTimeout=0,this._moved=!1},_clearDeferredResetState:function(){0!==this._resetStateTimeout&&(clearTimeout(this._resetStateTimeout),this._resetStateTimeout=0)},_onMouseDown:function(t){if(!t.shiftKey||1!==t.which&&1!==t.button)return!1;this._clearDeferredResetState(),this._resetState(),re(),Le(),this._startPoint=this._map.mouseEventToContainerPoint(t),S(document,{contextmenu:Re,mousemove:this._onMouseMove,mouseup:this._onMouseUp,keydown:this._onKeyDown},this)},_onMouseMove:function(t){this._moved||(this._moved=!0,this._box=P("div","leaflet-zoom-box",this._container),M(this._container,"leaflet-crosshair"),this._map.fire("boxzoomstart")),this._point=this._map.mouseEventToContainerPoint(t);var t=new f(this._point,this._startPoint),e=t.getSize();Z(this._box,t.min),this._box.style.width=e.x+"px",this._box.style.height=e.y+"px"},_finish:function(){this._moved&&(T(this._box),z(this._container,"leaflet-crosshair")),ae(),Te(),k(document,{contextmenu:Re,mousemove:this._onMouseMove,mouseup:this._onMouseUp,keydown:this._onKeyDown},this)},_onMouseUp:function(t){1!==t.which&&1!==t.button||(this._finish(),this._moved&&(this._clearDeferredResetState(),this._resetStateTimeout=setTimeout(a(this._resetState,this),0),t=new s(this._map.containerPointToLatLng(this._startPoint),this._map.containerPointToLatLng(this._point)),this._map.fitBounds(t).fire("boxzoomend",{boxZoomBounds:t})))},_onKeyDown:function(t){27===t.keyCode&&(this._finish(),this._clearDeferredResetState(),this._resetState())}}),Ct=(A.addInitHook("addHandler","boxZoom",_t),A.mergeOptions({doubleClickZoom:!0}),n.extend({addHooks:function(){this._map.on("dblclick",this._onDoubleClick,this)},removeHooks:function(){this._map.off("dblclick",this._onDoubleClick,this)},_onDoubleClick:function(t){var e=this._map,i=e.getZoom(),n=e.options.zoomDelta,i=t.originalEvent.shiftKey?i-n:i+n;"center"===e.options.doubleClickZoom?e.setZoom(i):e.setZoomAround(t.containerPoint,i)}})),Zt=(A.addInitHook("addHandler","doubleClickZoom",Ct),A.mergeOptions({dragging:!0,inertia:!0,inertiaDeceleration:3400,inertiaMaxSpeed:1/0,easeLinearity:.2,worldCopyJump:!1,maxBoundsViscosity:0}),n.extend({addHooks:function(){var t;this._draggable||(t=this._map,this._draggable=new Xe(t._mapPane,t._container),this._draggable.on({dragstart:this._onDragStart,drag:this._onDrag,dragend:this._onDragEnd},this),this._draggable.on("predrag",this._onPreDragLimit,this),t.options.worldCopyJump&&(this._draggable.on("predrag",this._onPreDragWrap,this),t.on("zoomend",this._onZoomEnd,this),t.whenReady(this._onZoomEnd,this))),M(this._map._container,"leaflet-grab leaflet-touch-drag"),this._draggable.enable(),this._positions=[],this._times=[]},removeHooks:function(){z(this._map._container,"leaflet-grab"),z(this._map._container,"leaflet-touch-drag"),this._draggable.disable()},moved:function(){return this._draggable&&this._draggable._moved},moving:function(){return this._draggable&&this._draggable._moving},_onDragStart:function(){var t,e=this._map;e._stop(),this._map.options.maxBounds&&this._map.options.maxBoundsViscosity?(t=g(this._map.options.maxBounds),this._offsetLimit=_(this._map.latLngToContainerPoint(t.getNorthWest()).multiplyBy(-1),this._map.latLngToContainerPoint(t.getSouthEast()).multiplyBy(-1).add(this._map.getSize())),this._viscosity=Math.min(1,Math.max(0,this._map.options.maxBoundsViscosity))):this._offsetLimit=null,e.fire("movestart").fire("dragstart"),e.options.inertia&&(this._positions=[],this._times=[])},_onDrag:function(t){var e,i;this._map.options.inertia&&(e=this._lastTime=+new Date,i=this._lastPos=this._draggable._absPos||this._draggable._newPos,this._positions.push(i),this._times.push(e),this._prunePositions(e)),this._map.fire("move",t).fire("drag",t)},_prunePositions:function(t){for(;1e.max.x&&(t.x=this._viscousLimit(t.x,e.max.x)),t.y>e.max.y&&(t.y=this._viscousLimit(t.y,e.max.y)),this._draggable._newPos=this._draggable._startPos.add(t))},_onPreDragWrap:function(){var t=this._worldWidth,e=Math.round(t/2),i=this._initialWorldOffset,n=this._draggable._newPos.x,o=(n-e+i)%t+e-i,n=(n+e+i)%t-e-i,t=Math.abs(o+i)e.getMaxZoom()&&1