From ffa16e2381a29a2b1adec8305f7b255237c97bc3 Mon Sep 17 00:00:00 2001 From: Atul Gupta Date: Mon, 6 Jul 2026 10:19:52 -0700 Subject: [PATCH 1/9] =?UTF-8?q?feat(web):=20Drive=20DNA=20=E2=80=94=20dete?= =?UTF-8?q?rministic=20generative=20telemetry=20art?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New /drive-dna page turns a single drive's telemetry into a unique, reproducible radial genome bloom: angle=journey progress, radius=speed, hue=power flow (regen cool / draw warm), lightness=SoC, concentric rings= elevation bands. Derives human traits (Spirited/Mountainous/Regen-rich/ Cold-start), a stable 7-char signature, and an exportable standalone SVG. Pure/deterministic + null-safe engine with co-located tests (5 cases). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- web/src/App.tsx | 2 + web/src/features/driving/lib/driveDNA.test.ts | 88 +++++++ web/src/features/driving/lib/driveDNA.ts | 223 +++++++++++++++++ .../features/driving/pages/DriveDNAPage.tsx | 229 ++++++++++++++++++ web/src/i18n/en.json | 15 ++ web/src/lib/routeRegistry.ts | 1 + 6 files changed, 558 insertions(+) create mode 100644 web/src/features/driving/lib/driveDNA.test.ts create mode 100644 web/src/features/driving/lib/driveDNA.ts create mode 100644 web/src/features/driving/pages/DriveDNAPage.tsx diff --git a/web/src/App.tsx b/web/src/App.tsx index cb29e560fd..81c078b0ac 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -64,6 +64,7 @@ const SpeedProfile = lazy(() => import('./features/driving/pages/SpeedProfilePag const RegenEfficiency = lazy(() => import('./features/driving/pages/RegenEfficiencyPage')) const RouteEfficiency = lazy(() => import('./features/driving/pages/RouteEfficiencyPage')) const TripPlanner = lazy(() => import('./features/driving/pages/TripPlannerPage')) +const DriveDNA = lazy(() => import('./features/driving/pages/DriveDNAPage')) // Analytics & Statistics const Analytics = lazy(() => import('./features/analytics/pages/AnalyticsPage')) @@ -562,6 +563,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> {/* Phase-50 / 0050 alias: the slice prompt registered the AI feature diff --git a/web/src/features/driving/lib/driveDNA.test.ts b/web/src/features/driving/lib/driveDNA.test.ts new file mode 100644 index 0000000000..4fd3caf045 --- /dev/null +++ b/web/src/features/driving/lib/driveDNA.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from 'vitest'; +import { generateDriveDNA, petalLine, DNA_CENTER } from './driveDNA'; +import type { DriveTelemetryPoint } from '@/types/driving'; + +function pt(over: Partial): DriveTelemetryPoint { + return { + timestamp: '2025-01-01T00:00:00Z', + speed: 20, + power: 10000, + batteryLevel: 70, + outsideTemp: 18, + insideTemp: 21, + driverTemp: 21, + passengerTemp: 21, + elevation: 100, + idealRange: null, + ratedRange: null, + estRange: null, + odometer: null, + soc: 70, + usableSoc: 70, + tirePressureFl: null, + tirePressureFr: null, + tirePressureRl: null, + tirePressureRr: null, + isClimateOn: true, + fanStatus: null, + latitude: null, + longitude: null, + ...over, + } as DriveTelemetryPoint; +} + +const spiritedDrive: DriveTelemetryPoint[] = Array.from({ length: 40 }, (_, i) => + pt({ speed: 10 + (i % 8) * 5, power: i % 3 === 0 ? -6000 : 40000, soc: 80 - i, elevation: 100 + i * 6 }), +); + +describe('generateDriveDNA', () => { + it('is deterministic — identical telemetry yields the same signature and petal count', () => { + const a = generateDriveDNA(spiritedDrive); + const b = generateDriveDNA(spiritedDrive.map((p) => ({ ...p }))); // structural clone + expect(a.signature).toBe(b.signature); + expect(a.petals.length).toBe(b.petals.length); + expect(a.petals.length).toBe(spiritedDrive.length); + expect(a.signature).toMatch(/^[0-9A-Z]{7}$/); + }); + + it('returns coherent empty art for empty / single-point input without throwing', () => { + const empty = generateDriveDNA([]); + expect(empty.petals).toHaveLength(0); + expect(empty.rings).toHaveLength(0); + expect(empty.signature).toBe('0000000'); + expect(generateDriveDNA(undefined).stats.points).toBe(0); + expect(generateDriveDNA([pt({})]).petals).toHaveLength(0); // needs >= 2 points + }); + + it('is null-safe — missing channels collapse to neutral geometry, never NaN', () => { + const sparse = [pt({ speed: null, power: null, soc: null, elevation: null }), pt({ speed: null, power: null })]; + const g = generateDriveDNA(sparse); + expect(g.petals.length).toBe(2); + for (const p of g.petals) { + expect(Number.isFinite(p.r1)).toBe(true); + expect(Number.isFinite(p.width)).toBe(true); + expect(p.color).toMatch(/^hsl\(/); + } + }); + + it('derives traits from how the car was driven', () => { + const g = generateDriveDNA(spiritedDrive); + // maxSpeed 45 m/s (>33) => Spirited; big climb => Mountainous + expect(g.traits).toContain('Spirited'); + expect(g.traits).toContain('Mountainous'); + expect(g.stats.topSpeedKph).toBeGreaterThan(120); + + const gentle = generateDriveDNA(Array.from({ length: 20 }, () => pt({ speed: 8, power: 3000, elevation: 100 }))); + expect(gentle.traits).toContain('Gentle'); + }); + + it('petalLine maps a petal to finite coordinates radiating from centre', () => { + const [p] = generateDriveDNA(spiritedDrive).petals; + const line = petalLine(p); + for (const v of Object.values(line)) expect(Number.isFinite(v)).toBe(true); + // outer point is further from centre than inner point + const d0 = Math.hypot(line.x1 - DNA_CENTER, line.y1 - DNA_CENTER); + const d1 = Math.hypot(line.x2 - DNA_CENTER, line.y2 - DNA_CENTER); + expect(d1).toBeGreaterThanOrEqual(d0); + }); +}); diff --git a/web/src/features/driving/lib/driveDNA.ts b/web/src/features/driving/lib/driveDNA.ts new file mode 100644 index 0000000000..8d29f9673f --- /dev/null +++ b/web/src/features/driving/lib/driveDNA.ts @@ -0,0 +1,223 @@ +/** + * Drive DNA — deterministic generative-art engine. + * + * Turns a single drive's telemetry into a unique, reproducible visual + * "fingerprint": a radial genome bloom where every petal is one telemetry + * sample and its geometry/colour encode how the car was actually driven. + * + * Encoding (all derived purely from the data, so the same drive always + * renders the same art — no randomness): + * - angle : progress through the drive (0..2π) + * - radius : instantaneous speed (faster => reaches further out) + * - hue : power flow — regen (negative kW) is cool/emerald, + * hard draw (positive kW) is warm/rose + * - saturation : |power| magnitude (effort) + * - lightness : battery state of charge at that moment + * - ringRadius : elevation band (concentric terrain rings) + * + * Everything is null-safe: missing signals collapse to neutral values so a + * sparse drive still produces coherent art instead of NaN geometry. + */ +import type { DriveTelemetryPoint } from '@/types/driving'; + +export interface DNAPetal { + /** Polar angle in radians (0 = 3 o'clock, grows clockwise). */ + angle: number; + /** Inner radius (viewBox units) — where the petal starts. */ + r0: number; + /** Outer radius (viewBox units) — driven by speed. */ + r1: number; + /** HSL colour string encoding power / SoC. */ + color: string; + /** Stroke width — effort (|power|). */ + width: number; + /** 0..1 opacity — recency-weighted so the drive reads as a journey. */ + opacity: number; +} + +export interface DNARing { + /** Radius of the concentric terrain ring. */ + r: number; + /** Faint colour keyed to the elevation band. */ + color: string; +} + +export interface DriveGenome { + petals: DNAPetal[]; + rings: DNARing[]; + /** Dominant background halo hue (average efficiency mood). */ + haloColor: string; + /** Short human traits, e.g. ["Spirited", "Mountainous", "Cold-start"]. */ + traits: string[]; + /** Deterministic 12-char signature (a shareable "gene sequence"). */ + signature: string; + /** Summary stats surfaced next to the art. */ + stats: { + points: number; + topSpeedKph: number | null; + climbM: number | null; + regenShare: number | null; // 0..1 fraction of samples in regen + coldStart: boolean; + }; +} + +const VIEWBOX = 100; // square viewBox side; art is centred at (50,50) +const CENTER = VIEWBOX / 2; + +function num(v: number | null | undefined, fallback = 0): number { + return typeof v === 'number' && Number.isFinite(v) ? v : fallback; +} + +function clamp(v: number, lo: number, hi: number): number { + return v < lo ? lo : v > hi ? hi : v; +} + +/** FNV-1a 32-bit hash → stable base36 signature from the drive's shape. */ +function signatureOf(points: DriveTelemetryPoint[]): string { + let h = 0x811c9dc5; + for (const p of points) { + // Quantise the meaningful channels so tiny float noise stays stable. + const parts = [ + Math.round(num(p.speed)), + Math.round(num(p.power) / 5), + Math.round(num(p.soc)), + Math.round(num(p.elevation) / 10), + ]; + for (const part of parts) { + h ^= part & 0xffff; + h = Math.imul(h, 0x01000193) >>> 0; + } + } + return (h >>> 0).toString(36).padStart(7, '0').slice(0, 7).toUpperCase(); +} + +/** + * Build a full genome from a drive's telemetry. Returns coherent (empty-art) + * output for an empty/degenerate series rather than throwing. + */ +export function generateDriveDNA(raw: readonly DriveTelemetryPoint[] | undefined): DriveGenome { + const points = (raw ?? []).filter(Boolean); + const n = points.length; + + const empty: DriveGenome = { + petals: [], + rings: [], + haloColor: 'hsl(210, 30%, 20%)', + traits: [], + signature: '0000000', + stats: { points: 0, topSpeedKph: null, climbM: null, regenShare: null, coldStart: false }, + }; + if (n < 2) return empty; + + // ---- Pass 1: ranges for normalisation -------------------------------- + let maxSpeed = 0; + let minElev = Infinity; + let maxElev = -Infinity; + let regenSamples = 0; + let sumEffort = 0; + let firstOutside: number | null = null; + let climb = 0; + let prevElev: number | null = null; + + for (const p of points) { + const s = num(p.speed); + if (s > maxSpeed) maxSpeed = s; + const e = p.elevation; + if (typeof e === 'number' && Number.isFinite(e)) { + if (e < minElev) minElev = e; + if (e > maxElev) maxElev = e; + if (prevElev != null && e > prevElev) climb += e - prevElev; + prevElev = e; + } + const pw = num(p.power); + if (pw < -1) regenSamples += 1; + sumEffort += Math.abs(pw); + if (firstOutside == null && typeof p.outsideTemp === 'number') firstOutside = p.outsideTemp; + } + if (!Number.isFinite(minElev)) { + minElev = 0; + maxElev = 0; + } + const elevSpan = Math.max(1, maxElev - minElev); + const speedSpan = Math.max(1, maxSpeed); + const avgEffort = sumEffort / n; + + // ---- Pass 2: petals --------------------------------------------------- + const petals: DNAPetal[] = points.map((p, i) => { + const t = i / (n - 1); // 0..1 journey progress + const angle = t * Math.PI * 2 - Math.PI / 2; // start at 12 o'clock + const speedNorm = clamp(num(p.speed) / speedSpan, 0, 1); + const r0 = 10; + const r1 = r0 + speedNorm * (CENTER - 14); + + const power = num(p.power); + // Hue: regen (power<0) → emerald ~150°, coasting → cyan ~190°, + // hard draw (power>0) → rose ~350°. Map power [-avg..+2avg] onto hue. + const powerNorm = clamp((power + avgEffort) / (3 * avgEffort + 1), 0, 1); + const hue = 150 + powerNorm * (350 - 150); + const sat = 55 + clamp(Math.abs(power) / (avgEffort * 2 + 1), 0, 1) * 40; + const light = 40 + clamp(num(p.soc, 50) / 100, 0, 1) * 35; + + return { + angle, + r0, + r1, + color: `hsl(${Math.round(hue)}, ${Math.round(sat)}%, ${Math.round(light)}%)`, + width: 0.4 + clamp(Math.abs(power) / (avgEffort * 2 + 1), 0, 1) * 1.4, + opacity: 0.35 + t * 0.55, // brighten toward the end of the drive + }; + }); + + // ---- Terrain rings (elevation bands) --------------------------------- + const ringCount = clamp(Math.round((maxElev - minElev) / 60), 0, 5); + const rings: DNARing[] = Array.from({ length: ringCount }, (_, k) => { + const frac = (k + 1) / (ringCount + 1); + return { + r: 12 + frac * (CENTER - 14), + color: `hsla(${Math.round(200 - frac * 60)}, 45%, 55%, 0.12)`, + }; + }); + + // ---- Traits + halo ---------------------------------------------------- + const regenShare = regenSamples / n; + const coldStart = firstOutside != null && firstOutside < 5; + const traits: string[] = []; + if (maxSpeed > 33) traits.push('Spirited'); // >~120 km/h + else if (maxSpeed < 14) traits.push('Gentle'); + if (climb > 150) traits.push('Mountainous'); + if (regenShare > 0.35) traits.push('Regen-rich'); + if (coldStart) traits.push('Cold-start'); + if (avgEffort < 8000) traits.push('Efficient'); + if (traits.length === 0) traits.push('Balanced'); + + const haloHue = 150 + clamp(1 - regenShare, 0, 1) * 60; // greener when regen-heavy + const haloColor = `hsl(${Math.round(haloHue)}, 40%, 16%)`; + + return { + petals, + rings, + haloColor, + traits, + signature: signatureOf(points), + stats: { + points: n, + topSpeedKph: maxSpeed > 0 ? Math.round(maxSpeed * 3.6) : null, + climbM: elevSpan > 1 ? Math.round(climb) : null, + regenShare, + coldStart, + }, + }; +} + +/** Convert a petal to an SVG line segment from centre outward. */ +export function petalLine(p: DNAPetal): { x1: number; y1: number; x2: number; y2: number } { + return { + x1: CENTER + Math.cos(p.angle) * p.r0, + y1: CENTER + Math.sin(p.angle) * p.r0, + x2: CENTER + Math.cos(p.angle) * p.r1, + y2: CENTER + Math.sin(p.angle) * p.r1, + }; +} + +export const DNA_VIEWBOX = VIEWBOX; +export const DNA_CENTER = CENTER; diff --git a/web/src/features/driving/pages/DriveDNAPage.tsx b/web/src/features/driving/pages/DriveDNAPage.tsx new file mode 100644 index 0000000000..9db1003747 --- /dev/null +++ b/web/src/features/driving/pages/DriveDNAPage.tsx @@ -0,0 +1,229 @@ +import { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Sparkles, Download, Gauge, Mountain, Snowflake, Leaf, Fingerprint } from 'lucide-react'; + +import { PageContainer } from '@/components/layout'; +import { GlassPanel, PanelTitle, Text, Button, Badge, Select } from '@/components/ui'; +import { VehicleSelect } from '@/components/forms'; +import { Skeleton, EmptyState, QueryError } from '@/components/feedback'; +import { FadeIn } from '@/components/motion'; + +import { useDrives, useDriveTelemetry } from '@/api/hooks/useDriving'; +import { useSelectedVehicle } from '@/hooks/useSelectedVehicle'; +import { usePageTitle } from '@/hooks/usePageTitle'; +import { formatDateShort } from '@/lib/dateFormat'; +import type { Drive } from '@/types/driving'; + +import { + generateDriveDNA, + petalLine, + DNA_VIEWBOX, + DNA_CENTER, + type DriveGenome, +} from '../lib/driveDNA'; + +const TRAIT_ICON: Record = { + Spirited: Gauge, + Gentle: Leaf, + Mountainous: Mountain, + 'Regen-rich': Leaf, + 'Cold-start': Snowflake, + Efficient: Leaf, + Balanced: Sparkles, +}; + +/** Build a standalone, downloadable SVG document string from a genome. */ +function genomeToSvg(g: DriveGenome, label: string): string { + const rings = g.rings + .map((r) => ``) + .join(''); + const petals = g.petals + .map((p) => { + const l = petalLine(p); + return ``; + }) + .join(''); + return `` + + `` + + `` + + rings + petals + + `${label} · ${g.signature}` + + ``; +} + +export default function DriveDNAPage() { + const { t } = useTranslation(); + usePageTitle(t('driveDna.title', 'Drive DNA')); + + const { vehicleId } = useSelectedVehicle(); + const vehicleIdStr = vehicleId != null ? String(vehicleId) : undefined; + + const drivesQuery = useDrives(vehicleIdStr); + const drives = useMemo(() => drivesQuery.data ?? [], [drivesQuery.data]); + + const [selectedId, setSelectedId] = useState(''); + const activeId = selectedId || (drives[0] ? String(drives[0].id) : ''); + + const telemetryQuery = useDriveTelemetry(activeId); + const genome = useMemo(() => generateDriveDNA(telemetryQuery.data), [telemetryQuery.data]); + + const driveOptions = useMemo( + () => + drives.map((d) => ({ + value: String(d.id), + label: `${formatDateShort(d.startTs)} · ${(d.distanceM / 1000).toFixed(1)} km`, + })), + [drives], + ); + + const activeDrive = drives.find((d) => String(d.id) === activeId); + const label = activeDrive ? formatDateShort(activeDrive.startTs) : t('driveDna.title', 'Drive DNA'); + + function handleDownload() { + if (!genome.petals.length) return; + const svg = genomeToSvg(genome, label); + const blob = new Blob([svg], { type: 'image/svg+xml' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `drive-dna-${genome.signature}.svg`; + a.click(); + URL.revokeObjectURL(url); + } + + return ( + + + +
+
+ {t('driveDna.heading', 'Generative telemetry art')} + + {t( + 'driveDna.blurb', + 'Every drive has a unique signature. This bloom encodes speed, power flow, elevation and battery into a reproducible fingerprint.', + )} + +
+
+ + {driveOptions.length > 0 && ( + setSelectedId(e.target.value)} options={driveOptions} /> + )} +
+
+
+
+ + {drivesQuery.isError ? ( + drivesQuery.refetch()} /> + ) : loading ? ( +
+ + +
+ ) : !activeId ? ( + + ) : !result.ok ? ( + + ) : ( +
+ {/* Controls */} + + +
+ {t('whatIf.knobs', 'Knobs')} + +
+ + setKnobs((k) => ({ ...k, speedFactor: v }))} + /> + +
+ {t('whatIf.tires', 'Tire pressure')} +