apps/route: AI trip planner on MapView — tool broker + local/cloud agent dispatch, nav session + simulated drive, layers/theme state, DDG image search, trip history, tilt-shift DOF layer (linear circle-of-confusion: level = log2 of radius, constant growth rate, tilt raises only the ceiling; tilt-shift on by default). Platform: Cx geo location API (macOS/iOS/Android/web) + permission plumbing, memory watchdog, headless build cfg. Map: nav layer M0 API, landcover drape, bridge dz. Geodata: 250m dual-radar compositor (radar_volume) + KNMI sync. Docs for the route/glass/blur/shiny work. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
193 lines
6.6 KiB
Rust
193 lines
6.6 KiB
Rust
//! Disk-based drive history (route.md follow-up; user directive 2026-07-30).
|
|
//!
|
|
//! Every app session with GPS movement becomes one append-only JSONL drive
|
|
//! record under `local/route_history/`: a header line, `trip` events each
|
|
//! time the planned trip changes, one line per accepted GPS fix, and an
|
|
//! `end` footer. The header reserves `media` (future timelapse video
|
|
//! attachments) and `synced` (future server sync) so the format survives
|
|
//! those extensions without migration.
|
|
|
|
use crate::trip::haversine_m;
|
|
use makepad_widgets::LocationUpdateEvent;
|
|
use std::io::Write;
|
|
use std::path::PathBuf;
|
|
|
|
const HISTORY_DIR: &str = "local/route_history";
|
|
/// Ignore jitter below this move distance.
|
|
const MIN_MOVE_M: f64 = 3.0;
|
|
|
|
#[derive(Default)]
|
|
pub struct DriveLog {
|
|
file: Option<std::fs::File>,
|
|
last: Option<(f64, f64)>,
|
|
pub distance_m: f64,
|
|
pub samples: usize,
|
|
closed: bool,
|
|
}
|
|
|
|
fn now_unix() -> f64 {
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_secs_f64())
|
|
.unwrap_or(0.0)
|
|
}
|
|
|
|
impl DriveLog {
|
|
fn ensure_file(&mut self) -> Option<&mut std::fs::File> {
|
|
if self.file.is_none() && !self.closed {
|
|
std::fs::create_dir_all(HISTORY_DIR).ok()?;
|
|
let path = PathBuf::from(HISTORY_DIR)
|
|
.join(format!("drive-{}.jsonl", now_unix() as u64));
|
|
let mut file = std::fs::File::create(&path).ok()?;
|
|
let header = serde_json::json!({
|
|
"event": "start",
|
|
"v": 1,
|
|
"started_unix": now_unix(),
|
|
"media": [], // future: timelapse video refs
|
|
"synced": false, // future: server sync state
|
|
});
|
|
writeln!(file, "{header}").ok()?;
|
|
self.file = Some(file);
|
|
}
|
|
self.file.as_mut()
|
|
}
|
|
|
|
/// Record a trip (re)plan so the drive record knows what was navigated.
|
|
pub fn log_trip(&mut self, digest: &str) {
|
|
let line = serde_json::json!({
|
|
"event": "trip",
|
|
"t": now_unix(),
|
|
"digest": digest,
|
|
});
|
|
if let Some(file) = self.ensure_file() {
|
|
let _ = writeln!(file, "{line}");
|
|
}
|
|
}
|
|
|
|
/// Record a GPS fix (deduped below MIN_MOVE_M).
|
|
pub fn log_fix(&mut self, fix: &LocationUpdateEvent) {
|
|
if let Some(last) = self.last {
|
|
let moved = haversine_m(last, (fix.lon, fix.lat));
|
|
if moved < MIN_MOVE_M {
|
|
return;
|
|
}
|
|
self.distance_m += moved;
|
|
}
|
|
self.last = Some((fix.lon, fix.lat));
|
|
self.samples += 1;
|
|
let line = serde_json::json!({
|
|
"event": "fix",
|
|
"t": fix.time,
|
|
"lon": fix.lon,
|
|
"lat": fix.lat,
|
|
"acc": fix.accuracy_m,
|
|
"speed": fix.speed_mps,
|
|
"heading": fix.heading_deg,
|
|
});
|
|
if let Some(file) = self.ensure_file() {
|
|
let _ = writeln!(file, "{line}");
|
|
}
|
|
}
|
|
|
|
/// Finalize the record (app shutdown).
|
|
pub fn close(&mut self) {
|
|
if let Some(mut file) = self.file.take() {
|
|
let footer = serde_json::json!({
|
|
"event": "end",
|
|
"t": now_unix(),
|
|
"distance_m": self.distance_m,
|
|
"samples": self.samples,
|
|
});
|
|
let _ = writeln!(file, "{footer}");
|
|
}
|
|
self.closed = true;
|
|
}
|
|
}
|
|
|
|
/// Digest of recent drive records for the `trip_history` tool.
|
|
pub fn list_drives(limit: usize) -> String {
|
|
let Ok(dir) = std::fs::read_dir(HISTORY_DIR) else {
|
|
return "no drive history yet".into();
|
|
};
|
|
let mut names: Vec<String> = dir
|
|
.filter_map(|e| e.ok())
|
|
.map(|e| e.file_name().to_string_lossy().into_owned())
|
|
.filter(|n| n.starts_with("drive-") && n.ends_with(".jsonl"))
|
|
.collect();
|
|
if names.is_empty() {
|
|
return "no drive history yet".into();
|
|
}
|
|
names.sort();
|
|
names.reverse();
|
|
names.truncate(limit);
|
|
let mut out = String::new();
|
|
for name in &names {
|
|
let path = PathBuf::from(HISTORY_DIR).join(name);
|
|
let mut date = String::from("?");
|
|
let mut km = String::from("in progress");
|
|
let mut trip = String::new();
|
|
if let Ok(content) = std::fs::read_to_string(&path) {
|
|
for line in content.lines() {
|
|
let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
|
|
continue;
|
|
};
|
|
match v.get("event").and_then(|e| e.as_str()) {
|
|
Some("start") => {
|
|
if let Some(t) = v.get("started_unix").and_then(|t| t.as_f64()) {
|
|
date = format_date(t);
|
|
}
|
|
}
|
|
Some("trip") => {
|
|
if let Some(d) = v.get("digest").and_then(|d| d.as_str()) {
|
|
// first + last stop names from the digest
|
|
trip = d
|
|
.lines()
|
|
.filter(|l| l.starts_with("stop_"))
|
|
.filter_map(|l| l.split(": ").nth(1))
|
|
.filter_map(|l| l.split(" (").next())
|
|
.collect::<Vec<_>>()
|
|
.join(" → ");
|
|
}
|
|
}
|
|
Some("end") => {
|
|
if let Some(d) = v.get("distance_m").and_then(|d| d.as_f64()) {
|
|
km = format!("{:.1} km", d / 1000.0);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
out.push_str(&format!(
|
|
"{name} | {date} | {km}{}\n",
|
|
if trip.is_empty() { String::new() } else { format!(" | {trip}") }
|
|
));
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Unix seconds → "YYYY-MM-DD HH:MM" (UTC), no chrono dependency.
|
|
fn format_date(unix: f64) -> String {
|
|
let secs = unix as i64;
|
|
let days = secs.div_euclid(86400);
|
|
let rem = secs.rem_euclid(86400);
|
|
// civil-from-days (Howard Hinnant's algorithm)
|
|
let z = days + 719468;
|
|
let era = z.div_euclid(146097);
|
|
let doe = z.rem_euclid(146097);
|
|
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
|
|
let y = yoe + era * 400;
|
|
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
|
let mp = (5 * doy + 2) / 153;
|
|
let d = doy - (153 * mp + 2) / 5 + 1;
|
|
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
|
let y = if m <= 2 { y + 1 } else { y };
|
|
format!(
|
|
"{:04}-{:02}-{:02} {:02}:{:02}",
|
|
y,
|
|
m,
|
|
d,
|
|
rem / 3600,
|
|
(rem % 3600) / 60
|
|
)
|
|
}
|