Add GPS trip reports and route maps
This commit is contained in:
+13
-6
@@ -13,6 +13,7 @@ import {
|
||||
GitBranch,
|
||||
DownloadSimple,
|
||||
Power,
|
||||
Path,
|
||||
SignOut,
|
||||
TerminalWindow,
|
||||
Trash,
|
||||
@@ -46,10 +47,15 @@ const ShellTerminal = lazy(async () => {
|
||||
return { default: module.ShellTerminal };
|
||||
});
|
||||
|
||||
type Screen = "loading" | "setup" | "login" | "dashboard" | "fatal";
|
||||
type DashboardPage = "home" | "car" | "adb" | "network" | "settings";
|
||||
const TripsPage = lazy(async () => {
|
||||
const module = await import("./TripsPage");
|
||||
return { default: module.TripsPage };
|
||||
});
|
||||
|
||||
const dashboardPages = new Set<DashboardPage>(["home", "car", "adb", "network", "settings"]);
|
||||
type Screen = "loading" | "setup" | "login" | "dashboard" | "fatal";
|
||||
type DashboardPage = "home" | "car" | "trips" | "adb" | "network" | "settings";
|
||||
|
||||
const dashboardPages = new Set<DashboardPage>(["home", "car", "trips", "adb", "network", "settings"]);
|
||||
|
||||
function pageFromLocation(): DashboardPage {
|
||||
const page = window.location.hash.replace(/^#\/?/, "");
|
||||
@@ -233,6 +239,7 @@ function Dashboard({ auth, onLoggedOut }: { auth: AuthState; onLoggedOut: () =>
|
||||
<nav aria-label="Primary navigation">
|
||||
<button className={`nav-item ${page === "home" ? "active" : ""}`} onClick={() => navigate("home")}><House size={24} weight={page === "home" ? "fill" : "regular"} /><span>Home</span></button>
|
||||
<button className={`nav-item ${page === "car" ? "active" : ""}`} onClick={() => navigate("car")}><CarProfile size={24} weight={page === "car" ? "fill" : "regular"} /><span>Car</span></button>
|
||||
<button className={`nav-item ${page === "trips" ? "active" : ""}`} onClick={() => navigate("trips")}><Path size={24} weight={page === "trips" ? "fill" : "regular"} /><span>Trips</span></button>
|
||||
<button className={`nav-item ${page === "adb" ? "active" : ""}`} onClick={() => navigate("adb")}><AndroidLogo size={24} weight={page === "adb" ? "fill" : "regular"} /><span>ADB</span></button>
|
||||
<button className={`nav-item ${page === "network" ? "active" : ""}`} onClick={() => navigate("network")}><WifiHigh size={24} weight={page === "network" ? "fill" : "regular"} /><span>Network</span></button>
|
||||
<button className={`nav-item ${page === "settings" ? "active" : ""}`} onClick={() => navigate("settings")}><Gear size={24} weight={page === "settings" ? "fill" : "regular"} /><span>Settings</span></button>
|
||||
@@ -242,8 +249,8 @@ function Dashboard({ auth, onLoggedOut }: { auth: AuthState; onLoggedOut: () =>
|
||||
<main className="dashboard">
|
||||
<header className="dashboard-header">
|
||||
<div>
|
||||
<p className="eyebrow">{page === "home" ? "System overview" : page === "car" ? "Live vehicle signals" : page === "adb" ? "Android device bridge" : page === "network" ? "Live diagnostics" : "Companion settings"}</p>
|
||||
<h1>{page === "home" ? status?.hostname ?? "Your Raspberry Pi" : page === "car" ? "Car" : page === "adb" ? "ADB" : page === "network" ? "Network activity" : "Settings"}</h1>
|
||||
<p className="eyebrow">{page === "home" ? "System overview" : page === "car" ? "Live vehicle signals" : page === "trips" ? "Drive history" : page === "adb" ? "Android device bridge" : page === "network" ? "Live diagnostics" : "Companion settings"}</p>
|
||||
<h1>{page === "home" ? status?.hostname ?? "Your Raspberry Pi" : page === "car" ? "Car" : page === "trips" ? "Trips" : page === "adb" ? "ADB" : page === "network" ? "Network activity" : "Settings"}</h1>
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<div className={`connection-state ${connection}`}>
|
||||
@@ -258,7 +265,7 @@ function Dashboard({ auth, onLoggedOut }: { auth: AuthState; onLoggedOut: () =>
|
||||
{page === "home" ? <>
|
||||
{error && <div className="status-error" role="alert"><WarningCircle size={22} />{error}<button onClick={() => void refresh()}>Try again</button></div>}
|
||||
{!status ? <DashboardSkeleton /> : <StatusContent status={status} />}
|
||||
</> : page === "car" ? <CarPage csrfToken={auth.csrfToken} /> : page === "adb" ? <AdbPage csrfToken={auth.csrfToken} /> : page === "network" ? <NetworkActivityPage /> : <SettingsPage csrfToken={auth.csrfToken} />}
|
||||
</> : page === "car" ? <CarPage csrfToken={auth.csrfToken} /> : page === "trips" ? <Suspense fallback={<div className="trips-skeleton"><div /><div /><div /></div>}><TripsPage /></Suspense> : page === "adb" ? <AdbPage csrfToken={auth.csrfToken} /> : page === "network" ? <NetworkActivityPage /> : <SettingsPage csrfToken={auth.csrfToken} />}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import L from "leaflet";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
import {
|
||||
ArrowClockwise,
|
||||
BatteryMedium,
|
||||
Clock,
|
||||
FlagCheckered,
|
||||
MapPin,
|
||||
NavigationArrow,
|
||||
Path,
|
||||
Speedometer
|
||||
} from "@phosphor-icons/react";
|
||||
import { getTrip, getTrips, type TripDetail, type TripSummary } from "./api";
|
||||
|
||||
function formatDistance(value: number): string {
|
||||
return value < 1 ? `${Math.round(value * 1_000)} m` : `${value.toFixed(1)} km`;
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const minutes = Math.max(0, Math.round(seconds / 60));
|
||||
const hours = Math.floor(minutes / 60);
|
||||
return hours ? `${hours}h ${minutes % 60}m` : `${minutes} min`;
|
||||
}
|
||||
|
||||
function formatDate(timestamp: number): string {
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
weekday: "short",
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
}).format(timestamp);
|
||||
}
|
||||
|
||||
function value(value_: number | null, suffix: string, digits = 0): string {
|
||||
return value_ === null ? "Unavailable" : `${value_.toFixed(digits)}${suffix}`;
|
||||
}
|
||||
|
||||
export function TripsPage() {
|
||||
const [trips, setTrips] = useState<TripSummary[] | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [detail, setDetail] = useState<TripDetail | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [detailError, setDetailError] = useState("");
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
async function refresh() {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
const nextTrips = await getTrips();
|
||||
setTrips(nextTrips);
|
||||
setError("");
|
||||
if (selectedId !== null && !nextTrips.some((trip) => trip.id === selectedId)) {
|
||||
setSelectedId(null);
|
||||
setDetail(null);
|
||||
}
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : "Trips could not be loaded");
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { void refresh(); }, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedId === null) return;
|
||||
setDetail(null);
|
||||
setDetailError("");
|
||||
void getTrip(selectedId)
|
||||
.then(setDetail)
|
||||
.catch((caught) => setDetailError(caught instanceof Error ? caught.message : "Trip details could not be loaded"));
|
||||
}, [selectedId]);
|
||||
|
||||
return (
|
||||
<section className="trips-page">
|
||||
<div className="trips-toolbar">
|
||||
<div>
|
||||
<strong>{trips?.length ?? 0} recorded trips</strong>
|
||||
<span>Trips start above 5 km/h and close after three stationary minutes.</span>
|
||||
</div>
|
||||
<button className="secondary-button trips-refresh" onClick={() => void refresh()} disabled={refreshing}>
|
||||
<ArrowClockwise size={21} className={refreshing ? "spin" : ""} /> Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="status-error" role="alert">{error}<button onClick={() => void refresh()}>Try again</button></div>}
|
||||
{trips === null && !error ? <TripsSkeleton /> : trips?.length === 0 ? <EmptyTrips /> : (
|
||||
<div className="trips-workspace">
|
||||
<div className="trip-list" aria-label="Recorded trips">
|
||||
{trips?.map((trip) => (
|
||||
<button
|
||||
key={trip.id}
|
||||
className={`trip-row ${selectedId === trip.id ? "selected" : ""}`}
|
||||
onClick={() => setSelectedId(trip.id)}
|
||||
aria-pressed={selectedId === trip.id}
|
||||
>
|
||||
<span className="trip-row-route"><NavigationArrow size={20} weight="fill" /></span>
|
||||
<span className="trip-row-main">
|
||||
<strong>{formatDate(trip.startedAt)}</strong>
|
||||
<small>{trip.active ? "Recording now" : `${formatDuration(trip.durationSeconds)} · ${trip.deviceName}`}</small>
|
||||
</span>
|
||||
<span className="trip-row-distance">{formatDistance(trip.distanceKm)}</span>
|
||||
<span className="trip-row-speed">{value(trip.peakSpeedKph, " km/h")}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="trip-detail">
|
||||
{detailError ? <div className="trip-detail-state" role="alert"><MapPin size={34} />{detailError}</div>
|
||||
: selectedId === null ? <div className="trip-detail-state"><Path size={42} /><strong>Select a trip</strong><span>Open a drive to inspect its route, statistics, and notable points.</span></div>
|
||||
: detail === null ? <TripDetailSkeleton /> : <TripReport trip={detail} />}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TripReport({ trip }: { trip: TripDetail }) {
|
||||
return (
|
||||
<article className="trip-report">
|
||||
<div className="trip-report-heading">
|
||||
<div>
|
||||
<span>{trip.active ? "Live trip" : "Trip report"}</span>
|
||||
<h2>{formatDate(trip.startedAt)}</h2>
|
||||
<p>{formatDuration(trip.durationSeconds)} recorded by {trip.deviceName}</p>
|
||||
</div>
|
||||
{trip.active && <span className="trip-live"><i /> Recording</span>}
|
||||
</div>
|
||||
<TripMap trip={trip} />
|
||||
<div className="trip-stat-grid">
|
||||
<TripStat icon={<Path size={22} />} label="Distance" value={formatDistance(trip.distanceKm)} />
|
||||
<TripStat icon={<Speedometer size={22} />} label="Peak speed" value={value(trip.peakSpeedKph, " km/h")} />
|
||||
<TripStat icon={<NavigationArrow size={22} />} label="Average speed" value={value(trip.averageSpeedKph, " km/h")} />
|
||||
<TripStat icon={<Clock size={22} />} label="Duration" value={formatDuration(trip.durationSeconds)} />
|
||||
<TripStat icon={<BatteryMedium size={22} />} label="Vehicle battery used" value={value(trip.batteryConsumedPercent, "%", 1)} />
|
||||
<TripStat icon={<BatteryMedium size={22} />} label="Energy used" value={value(trip.energyConsumedKwh, " kWh", 2)} />
|
||||
</div>
|
||||
<div className="trip-report-footer">
|
||||
<span><MapPin size={18} /> {trip.pointCount.toLocaleString()} GPS samples</span>
|
||||
<span><FlagCheckered size={18} /> {trip.endedAt ? `Ended ${formatDate(trip.endedAt)}` : "Trip in progress"}</span>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function TripStat({ icon, label, value: displayValue }: { icon: React.ReactNode; label: string; value: string }) {
|
||||
return <div className="trip-stat"><span>{icon}{label}</span><strong>{displayValue}</strong></div>;
|
||||
}
|
||||
|
||||
function TripMap({ trip }: { trip: TripDetail }) {
|
||||
const host = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!host.current || trip.points.length === 0) return;
|
||||
const map = L.map(host.current, { zoomControl: true, attributionControl: true });
|
||||
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||
maxZoom: 19,
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
}).addTo(map);
|
||||
const coordinates = trip.points.map((point) => L.latLng(point.latitude, point.longitude));
|
||||
L.polyline(coordinates, { color: "#0b1005", weight: 9, opacity: 0.72, lineCap: "round" }).addTo(map);
|
||||
L.polyline(coordinates, { color: "#b9e769", weight: 5, opacity: 0.95, lineCap: "round" }).addTo(map);
|
||||
const bounds = L.latLngBounds(coordinates);
|
||||
if (bounds.isValid() && coordinates.length > 1) map.fitBounds(bounds, { padding: [34, 34], maxZoom: 16 });
|
||||
else map.setView(coordinates[0]!, 16);
|
||||
|
||||
for (const pin of trip.pins) {
|
||||
const peak = pin.type === "peak-speed";
|
||||
const marker = L.circleMarker([pin.latitude, pin.longitude], {
|
||||
radius: peak ? 9 : 7,
|
||||
color: peak ? "#10130f" : "#f0f2eb",
|
||||
weight: 3,
|
||||
fillColor: peak ? "#b9e769" : pin.type === "start" ? "#f0f2eb" : "#8f9a88",
|
||||
fillOpacity: 1
|
||||
}).addTo(map);
|
||||
marker.bindTooltip(peak ? `Top speed · ${value(pin.speedKph ?? null, " km/h")}` : pin.type === "start" ? "Trip start" : "Trip end");
|
||||
}
|
||||
return () => { map.remove(); };
|
||||
}, [trip]);
|
||||
|
||||
if (trip.points.length === 0) return <div className="trip-map-empty"><MapPin size={34} />No route points were retained for this trip.</div>;
|
||||
return <div className="trip-map" ref={host} aria-label="Map showing the trip route" />;
|
||||
}
|
||||
|
||||
function EmptyTrips() {
|
||||
return <div className="trips-empty"><Path size={46} /><h2>No trips recorded yet</h2><p>Pair the Android companion and leave its foreground service running. Your first drive will appear here automatically.</p></div>;
|
||||
}
|
||||
|
||||
function TripsSkeleton() {
|
||||
return <div className="trips-skeleton" aria-label="Loading trips"><div /><div /><div /></div>;
|
||||
}
|
||||
|
||||
function TripDetailSkeleton() {
|
||||
return <div className="trip-detail-skeleton" aria-label="Loading trip report"><div /><div /><div /></div>;
|
||||
}
|
||||
@@ -130,6 +130,50 @@ export type BluetoothStatus = {
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type TripSummary = {
|
||||
id: number;
|
||||
deviceId: string;
|
||||
deviceName: string;
|
||||
startedAt: number;
|
||||
endedAt: number | null;
|
||||
active: boolean;
|
||||
durationSeconds: number;
|
||||
movingSeconds: number;
|
||||
distanceKm: number;
|
||||
pointCount: number;
|
||||
averageSpeedKph: number | null;
|
||||
peakSpeedKph: number | null;
|
||||
vehicleBatteryStartPercent: number | null;
|
||||
vehicleBatteryEndPercent: number | null;
|
||||
batteryConsumedPercent: number | null;
|
||||
energyConsumedKwh: number | null;
|
||||
phoneBatteryStartPercent: number | null;
|
||||
phoneBatteryEndPercent: number | null;
|
||||
start: { latitude: number; longitude: number };
|
||||
end: { latitude: number; longitude: number };
|
||||
};
|
||||
|
||||
export type TripPoint = {
|
||||
capturedAt: number;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
accuracyMeters: number;
|
||||
altitudeMeters: number | null;
|
||||
speedKph: number | null;
|
||||
bearingDegrees: number | null;
|
||||
vehicleBatteryPercent: number | null;
|
||||
};
|
||||
|
||||
export type TripPin = {
|
||||
type: "start" | "end" | "peak-speed";
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
capturedAt: number | null;
|
||||
speedKph?: number | null;
|
||||
};
|
||||
|
||||
export type TripDetail = TripSummary & { points: TripPoint[]; pins: TripPin[] };
|
||||
|
||||
export type CarTelemetry = {
|
||||
available: true;
|
||||
collectedAt: string;
|
||||
@@ -274,6 +318,17 @@ export async function getBluetoothStatus(): Promise<BluetoothStatus> {
|
||||
return parseResponse<BluetoothStatus>(await fetch("/api/mobile/bluetooth", { credentials: "same-origin" }));
|
||||
}
|
||||
|
||||
export async function getTrips(): Promise<TripSummary[]> {
|
||||
const response = await parseResponse<{ trips: TripSummary[] }>(
|
||||
await fetch("/api/trips", { credentials: "same-origin" })
|
||||
);
|
||||
return response.trips;
|
||||
}
|
||||
|
||||
export async function getTrip(id: number): Promise<TripDetail> {
|
||||
return parseResponse<TripDetail>(await fetch(`/api/trips/${id}`, { credentials: "same-origin" }));
|
||||
}
|
||||
|
||||
export async function revokeMobileDevice(id: string, csrfToken: string): Promise<void> {
|
||||
await parseResponse(await fetch(`/api/mobile/devices/${encodeURIComponent(id)}`, {
|
||||
method: "DELETE",
|
||||
|
||||
@@ -408,6 +408,63 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
.clear-statistics-options .danger-button small { color: #e7b8b1; }
|
||||
.status-success { display: flex; align-items: center; min-height: 52px; padding: 12px 16px; color: var(--accent); background: rgba(185, 231, 105, 0.08); border: 1px solid rgba(185, 231, 105, 0.24); border-radius: 10px; }
|
||||
|
||||
.trips-page { min-width: 0; display: grid; gap: 18px; }
|
||||
.trips-toolbar { min-height: 76px; display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 14px 18px 14px 22px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); }
|
||||
.trips-toolbar > div { min-width: 0; display: grid; gap: 5px; }
|
||||
.trips-toolbar strong { font-size: 0.95rem; }
|
||||
.trips-toolbar span { color: var(--muted); font-size: 0.76rem; line-height: 1.4; }
|
||||
.trips-refresh { min-height: 46px; padding: 0 17px; }
|
||||
.trips-workspace { min-width: 0; display: grid; grid-template-columns: minmax(300px, 0.72fr) minmax(510px, 1.28fr); gap: 18px; align-items: start; }
|
||||
.trip-list { min-width: 0; overflow: hidden; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); }
|
||||
.trip-row { width: 100%; min-width: 0; min-height: 84px; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 5px 13px; padding: 13px 16px; color: var(--text); background: transparent; border: 0; border-bottom: 1px solid var(--line); text-align: left; cursor: pointer; transition: background 140ms ease; }
|
||||
.trip-row:last-child { border-bottom: 0; }
|
||||
.trip-row:hover { background: var(--surface-raised); }
|
||||
.trip-row.selected { background: rgba(185, 231, 105, 0.09); box-shadow: inset 3px 0 var(--accent); }
|
||||
.trip-row-route { grid-row: 1 / 3; width: 38px; height: 38px; display: grid; place-items: center; color: var(--accent); background: rgba(185, 231, 105, 0.08); border-radius: 9px; }
|
||||
.trip-row-main { min-width: 0; display: grid; gap: 5px; }
|
||||
.trip-row-main strong, .trip-row-main small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.trip-row-main strong { font-size: 0.84rem; }
|
||||
.trip-row-main small { color: var(--muted); font-size: 0.7rem; }
|
||||
.trip-row-distance { justify-self: end; font: 750 0.88rem/1 ui-monospace, "Cascadia Code", monospace; }
|
||||
.trip-row-speed { grid-column: 3; justify-self: end; color: var(--muted); font: 0.67rem/1 ui-monospace, "Cascadia Code", monospace; }
|
||||
.trip-detail { min-width: 0; overflow: hidden; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); }
|
||||
.trip-detail-state { min-height: 540px; display: grid; place-content: center; justify-items: center; gap: 12px; padding: 32px; color: var(--muted); text-align: center; }
|
||||
.trip-detail-state svg { color: var(--accent); }
|
||||
.trip-detail-state strong { color: var(--text); font-size: 1.15rem; }
|
||||
.trip-detail-state span { max-width: 370px; font-size: 0.8rem; line-height: 1.5; }
|
||||
.trip-report-heading { min-height: 106px; display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 20px 23px; }
|
||||
.trip-report-heading > div > span { color: var(--accent); font: 700 0.67rem/1.2 ui-monospace, "Cascadia Code", monospace; letter-spacing: 0.1em; text-transform: uppercase; }
|
||||
.trip-report-heading h2 { margin: 7px 0 5px; font-size: 1.3rem; }
|
||||
.trip-report-heading p { margin: 0; color: var(--muted); font-size: 0.76rem; }
|
||||
.trip-live { display: flex; align-items: center; gap: 8px; color: var(--accent); font-size: 0.75rem; font-weight: 750; }
|
||||
.trip-live i { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 5px rgba(185, 231, 105, 0.1); }
|
||||
.trip-map { position: relative; z-index: 0; width: 100%; height: clamp(340px, 46dvh, 500px); background: #222820; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
|
||||
.trip-map .leaflet-tile-pane { filter: saturate(0.45) brightness(0.72) contrast(1.08); }
|
||||
.trip-map .leaflet-control-zoom a { color: var(--text); background: #171b16; border-color: var(--line); }
|
||||
.trip-map .leaflet-control-zoom a:hover { background: #242a22; }
|
||||
.trip-map .leaflet-control-attribution { color: #cbd3c5; background: rgba(16, 19, 15, 0.86); }
|
||||
.trip-map .leaflet-control-attribution a { color: var(--accent); }
|
||||
.trip-map .leaflet-tooltip { color: var(--text); background: #171b16; border: 1px solid #465043; box-shadow: none; font-weight: 700; }
|
||||
.trip-map .leaflet-tooltip::before { border-top-color: #465043; }
|
||||
.trip-map-empty { min-height: 340px; display: grid; place-content: center; justify-items: center; gap: 10px; color: var(--muted); border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
|
||||
.trip-map-empty svg { color: var(--accent); }
|
||||
.trip-stat-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1px; background: var(--line); }
|
||||
.trip-stat { min-width: 0; min-height: 106px; display: grid; align-content: center; gap: 12px; padding: 17px 19px; background: var(--surface); }
|
||||
.trip-stat span { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 0.72rem; }
|
||||
.trip-stat svg { flex: none; color: var(--accent); }
|
||||
.trip-stat strong { overflow: hidden; font: 750 1.16rem/1 ui-monospace, "Cascadia Code", monospace; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.trip-report-footer { min-height: 58px; display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 14px 20px; color: var(--muted); font-size: 0.7rem; }
|
||||
.trip-report-footer span { display: flex; align-items: center; gap: 7px; }
|
||||
.trip-report-footer svg { color: var(--accent); }
|
||||
.trips-empty { min-height: 460px; display: grid; place-content: center; justify-items: center; padding: 36px; color: var(--muted); text-align: center; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); }
|
||||
.trips-empty svg { margin-bottom: 19px; color: var(--accent); }
|
||||
.trips-empty h2 { margin: 0 0 10px; color: var(--text); }
|
||||
.trips-empty p { max-width: 500px; margin: 0; font-size: 0.84rem; line-height: 1.55; }
|
||||
.trips-skeleton { min-height: 420px; display: grid; grid-template-columns: 0.72fr 1.28fr; gap: 18px; }
|
||||
.trips-skeleton div, .trip-detail-skeleton div { border-radius: var(--radius); background: linear-gradient(100deg, var(--surface) 30%, var(--surface-raised) 50%, var(--surface) 70%); background-size: 220% 100%; animation: shimmer 1.4s ease infinite; }
|
||||
.trips-skeleton div:first-child { grid-row: 1 / 3; }
|
||||
.trip-detail-skeleton { min-height: 540px; display: grid; grid-template-rows: 90px 1fr 120px; gap: 1px; padding: 18px; }
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.adb-command-grid { grid-template-columns: 1fr; }
|
||||
.drive-stage { grid-template-columns: 1fr 1.25fr; }
|
||||
@@ -417,6 +474,8 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
.telemetry-source { grid-column: 1 / -1; }
|
||||
.record-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.chart-grid { grid-template-columns: 1fr; }
|
||||
.trips-workspace { grid-template-columns: minmax(280px, 0.7fr) minmax(440px, 1.3fr); }
|
||||
.trip-stat-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
@@ -446,4 +505,15 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
.telemetry-storage { grid-template-columns: auto minmax(0, 1fr); }
|
||||
.telemetry-storage-summary { grid-column: 1 / -1; text-align: left; }
|
||||
.telemetry-clear-button { grid-column: 1 / -1; width: 100%; padding: 0 17px; font-size: inherit; }
|
||||
.trips-toolbar { align-items: flex-start; }
|
||||
.trips-toolbar span { display: none; }
|
||||
.trips-refresh { width: 46px; padding: 0; font-size: 0; }
|
||||
.trips-workspace { grid-template-columns: 1fr; }
|
||||
.trip-detail { min-height: 420px; }
|
||||
.trip-detail-state { min-height: 420px; }
|
||||
.trip-map { height: 52dvh; min-height: 330px; }
|
||||
.trip-stat-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.trip-report-footer { align-items: flex-start; flex-direction: column; }
|
||||
.trips-skeleton { grid-template-columns: 1fr; }
|
||||
.trips-skeleton div:first-child { grid-row: auto; min-height: 260px; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user