Files
2025-11-05 01:30:03 -08:00

945 lines
28 KiB
Bash
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/bin/bash
set -e
echo "NoCap W.Y.A. — Deploying truth engine..."
# Create directories
mkdir -p backend/app frontend/src/{components/map,stores,utils} data/tiles
# Write all files
cat > docker-compose.yml <<'EOF'
version: '3.9'
services:
db:
image: postgis/postgis:15-3.4
environment:
POSTGRES_DB: wya
POSTGRES_USER: wya
POSTGRES_PASSWORD: wya
volumes:
- pg:/var/lib/postgresql/data
restart: unless-stopped
backend:
build: ./backend
ports:
- "8000:8000"
volumes:
- ./data:/data
depends_on:
- db
restart: unless-stopped
frontend:
build: ./frontend
ports:
- "3000:3000"
volumes:
- ./data/tiles:/app/public/tiles
depends_on:
- backend
restart: unless-stopped
volumes:
pg:
EOF
cat > backend/Dockerfile <<'EOF'
FROM python:3.11-slim
WORKDIR /app
COPY backend/ .
RUN pip install --no-cache-dir -r pyproject.toml
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
EOF
cat > backend/pyproject.toml <<'EOF'
[project]
name = "nocap-wya"
version = "1.1.0"
dependencies = [
"fastapi",
"uvicorn",
"sqlalchemy",
"psycopg2-binary",
"geoalchemy2",
"ijson",
"orjson",
"python-dateutil"
]
EOF
cat > backend/app/main.py <<'EOF'
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI(
title="NoCap W.Y.A.",
description="No cap. Just facts. Your moves. Your server.",
version="1.1"
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/")
def root():
return {"msg": "NoCap W.Y.A. — W.Y.A.?"}
@app.get("/api/trips")
def get_trips():
return {
"type": "FeatureCollection",
"features": [{
"type": "Feature",
"geometry": {
"type": "LineString",
"coordinates": [[24.9384, 60.1699], [25.7482, 61.9241]]
},
"properties": {
"timestamps": [1727000000000, 1727010000000],
"speeds": [50, 80]
}
}]
}
@app.get("/api/visits")
def get_visits():
return [
{"lat": 60.1699, "lng": 24.9384, "duration": 3600},
{"lat": 60.1929, "lng": 24.9455, "duration": 7200}
]
EOF
cat > frontend/Dockerfile <<'EOF'
FROM node:18-alpine
WORKDIR /app
COPY frontend/ .
RUN npm install && npm run build
CMD ["npm", "run", "preview"]
EOF
cat > frontend/package.json <<'EOF'
{
"name": "nocap-wya",
"version": "1.1.0",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-map-gl": "^7.1.0",
"zustand": "^4.0.0",
"framer-motion": "^10.0.0",
"lucide-react": "^0.263.0",
"date-fns": "^2.30.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.0.0",
"tailwindcss": "^3.3.0",
"vite": "^4.0.0"
}
}
EOF
cat > frontend/vite.config.ts <<'EOF'
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: { port: 3000 }
})
EOF
cat > frontend/tailwind.config.ts <<'EOF'
module.exports = {
darkMode: ['class'],
content: [
'./src/**/*.{js,ts,jsx,tsx}',
],
theme: {
extend: {
animation: {
'fade-in': 'fadeIn 0.2s ease-in-out',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0', transform: 'scale(0.95)' },
'100%': { opacity: '1', transform: 'scale(1)' },
},
},
},
},
plugins: [
require('tailwindcss-animate'),
],
}
EOF
cat > frontend/src/components/FilterBar.tsx <<'EOF'
'use client';
import { useState, useEffect, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { format } from 'date-fns';
import {
Calendar,
Clock,
MapPin,
Search,
X,
Filter,
ChevronDown,
Car,
Bike,
PersonStanding,
Train,
Ruler
} from 'lucide-react';
import { useFilterStore, FilterState } from '@/stores/useFilterStore';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
import { Calendar as CalendarComponent } from '@/components/ui/calendar';
import { Slider } from '@/components/ui/slider';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
const transportIcons = {
WALKING: PersonStanding,
CYCLING: Bike,
DRIVING: Car,
TRANSIT: Train,
};
export default function FilterBar() {
const { filters, setFilter, removeFilter, clearFilters } = useFilterStore();
const [openPopover, setOpenPopover] = useState<string | null>(null);
const [dateRange, setDateRange] = useState<{ from: Date | undefined; to: Date | undefined }>({ // Corrected: removed 'incessantly'
from: filters.time?.start ? new Date(filters.time.start) : undefined,
to: filters.time?.end ? new Date(filters.time.end) : undefined,
});
// Sync local date range with store
useEffect(() => {
if (filters.time?.start && filters.time?.end) {
setDateRange({
from: new Date(filters.time.start),
to: new Date(filters.time.end),
});
}
}, [filters.time]);
const applyDateRange = () => {
if (dateRange.from && dateRange.to) {
setFilter('time', {
start: dateRange.from.toISOString().split('T')[0],
end: dateRange.to.toISOString().split('T')[0],
});
} else if (dateRange.from) {
setFilter('time', { start: dateRange.from.toISOString().split('T')[0] });
}
setOpenPopover(null);
};
const activeFilterCount = Object.keys(filters).filter(key =>
filters[key as keyof FilterState] !== null &&
key !== 'search'
).length + (filters.search ? 1 : 0);
return (
<>
<div className="w-full bg-background border-b">
<div className="container mx-auto px-4 py-3">
{/* Top Row: Search + Filter Button */}
<div className="flex gap-2 mb-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
<Input
placeholder="Search places, notes, or keywords..."
value={filters.search || ''}
onChange={(e) => setFilter('search', e.target.value || null)}
className="pl-10 pr-10"
/>
{filters.search && (
<button
onClick={() => setFilter('search', null)}
className="absolute right-3 top-1/2 transform -translate-y-1/2"
>
<X className="h-4 w-4 text-muted-foreground hover:text-foreground" />
</button>
)}
</div>
<Button
variant="outline"
size="default"
onClick={() => setOpenPopover(openPopover === 'main' ? null : 'main')}
className="relative"
>
<Filter className="h-4 w-4 mr-2" />
Filters
{activeFilterCount > 0 && (
<Badge variant="secondary" className="absolute -top-2 -right-2 h-5 w-5 p-0 flex items-center justify-center text-xs">
{activeFilterCount}
</Badge>
)}
</Button>
</div>
{/* Filter Chips */}
<AnimatePresence>
{activeFilterCount > 0 && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="flex flex-wrap gap-2 items-center"
>
{filters.time && (
<FilterChip
label={`Date: ${filters.time.start} → ${filters.time.end || 'Now'}`}
onRemove={() => removeFilter('time')}
/>
)}
{filters.transport && filters.transport.length > 0 && (
<FilterChip
label={`Transport: ${filters.transport.map(t => t.charAt(0) + t.slice(1).toLowerCase()).join(', ')}`}
onRemove={() => removeFilter('transport')}
/>
)}
{filters.distance && (
<FilterChip
label={`Distance: >${filters.distance}km`}
onRemove={() => removeFilter('distance')}
/>
)}
{filters.placeType && filters.placeType.length > 0 && (
<FilterChip
label={`Places: ${filters.placeType.join(', ')}`}
onRemove={() => removeFilter('placeType')}
/>
)}
{filters.search && (
<FilterChip
label={`"${filters.search}" `}
onRemove={() => setFilter('search', null)}
/>
)}
<Button
variant="ghost"
size="sm"
onClick={clearFilters}
className="h-7 text-xs"
>
Clear all
</Button>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
{/* Main Filter Popover */}
<Popover open={openPopover === 'main'} onOpenChange={(open) => setOpenPopover(open ? 'main' : null)}>
<PopoverTrigger asChild>
<div className="hidden" />
</PopoverTrigger>
<PopoverContent className="w-96 p-0" align="start">
<div className="p-4 space-y-4">
{/* Date Range */}
<div>
<Label className="flex items-center gap-2 mb-2">
<Calendar className="h-4 w-4" />
Date Range
</Label>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="w-full justify-start text-left font-normal">
{dateRange.from ? (
dateRange.to ? (
<>
{format(dateRange.from, 'LLL dd, y')} {format(dateRange.to, 'LLL dd, y')}
</>
) : (
format(dateRange.from, 'LLL dd, y')
)
) : (
<span>Pick a date range</span>
)}
<ChevronDown className="ml-auto h-4 w-4 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<CalendarComponent
mode="range"
selected={dateRange}
onSelect={(range: any) => setDateRange(range || { from: undefined, to: undefined })}
numberOfMonths={2}
/>
<div className="p-3 border-t flex gap-2">
<Button size="sm" variant="outline" onClick={() => setDateRange({ from: undefined, to: undefined }) }>
Clear
</Button>
<Button size="sm" onClick={applyDateRange}>
Apply
</Button>
</div>
</PopoverContent>
</Popover>
</div>
<Separator />
{/* Transport Mode */}
<div>
<Label className="flex items-center gap-2 mb-2">
<Car className="h-4 w-4" />
Transport Mode
</Label>
<div className="grid grid-cols-2 gap-2">
{(['WALKING', 'CYCLING', 'DRIVING', 'TRANSIT'] as const).map((mode) => {
const Icon = transportIcons[mode];
const isActive = filters.transport?.includes(mode);
return (
<Button
key={mode}
variant={isActive ? 'default' : 'outline'}
size="sm"
onClick={() => {
const current = filters.transport || [];
const updated = isActive
? current.filter(m => m !== mode)
: [...current, mode];
setFilter('transport', updated.length > 0 ? updated : null);
}}
className="justify-start"
>
<Icon className="h-4 w-4 mr-2" />
{mode.charAt(0) + mode.slice(1).toLowerCase()}
</Button>
);
})}
</div>
</div>
<Separator />
{/* Distance */}
<div>
<Label className="flex items-center gap-2 mb-2">
<Ruler className="h-4 w-4" />
Minimum Distance (km)
</Label>
<div className="flex items-center gap-4">
<Slider
value={[filters.distance || 0]}
onValueChange={([value]) => setFilter('distance', value > 0 ? value : null)}
max={50}
step={1}
className="flex-1"
/>
<span className="text-sm font-medium w-12 text-right">
{filters.distance || 0} km
</span>
</div>
</div>
<Separator />
{/* Place Types */}
<div>
<Label className="flex items-center gap-2 mb-2">
<MapPin className="h-4 w-4" />
Place Types
</Label>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="w-full justify-start">
{filters.placeType && filters.placeType.length > 0
? `${filters.placeType.length} selected`
: 'Select place types'}
<ChevronDown className="ml-auto h-4 w-4 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<Command>
<CommandInput placeholder="Search places..." />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup>
{(['home', 'work', 'cafe', 'gym', 'restaurant', 'park', 'shop'] as const).map((type) => (
<CommandItem
key={type}
onSelect={() => {
const current = filters.placeType || [];
const updated = current.includes(type)
? current.filter(t => t !== type)
: [...current, type];
setFilter('placeType', updated.length > 0 ? updated : null);
}}
>
<div className={cn(
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border",
filters.placeType?.includes(type)
? "bg-primary text-primary-foreground border-primary"
: "border-muted-foreground"
)}>
{filters.placeType?.includes(type) && <div className="h-2 w-2 bg-primary-foreground rounded-full" />}
</div>
{type.charAt(0).toUpperCase() + type.slice(1)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</div>
</PopoverContent>
</Popover>
</>
);
}
// Filter Chip Component
function FilterChip({ label, onRemove }: { label: string; onRemove: () => void }) {
return (
<motion.div
layout
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.8, opacity: 0 }}
className="inline-flex items-center gap-1 bg-secondary text-secondary-foreground rounded-full px-3 py-1 text-xs font-medium"
>
{label}
<button
onClick={onRemove}
className="ml-1 rounded-full hover:bg-secondary-foreground/20 p-0.5"
>
<X className="h-3 w-3" />
</button>
</motion.div>
);
}
EOF
cat > frontend/src/components/map/AnimatedRouteLayer.tsx <<'EOF'
'use client';
import { useEffect, useRef, useState } from 'react';
import { useMap } from 'react-map-gl';
import { useFilterStore } from '@/stores/useFilterStore';
import { interpolatePath, PathPoint } from '@/utils/animation';
import { motion } from 'framer-motion';
interface AnimatedRouteLayerProps {
isPlaying: boolean;
speed: number;
onComplete?: () => void;
}
export default function AnimatedRouteLayer({ isPlaying, speed, onComplete }: AnimatedRouteLayerProps) {
const { current: map } = useMap();
const { filters } = useFilterStore();
const [path, setPath] = useState<PathPoint[]>([]);
const animationRef = useRef<number>();
const startTimeRef = useRef<number>(0);
const progressRef = useRef<number>(0);
// Fetch filtered trips
useEffect(() => {
const fetchTrips = async () => {
const params = new URLSearchParams();
if (filters.time?.start) params.append('start', filters.time.start);
if (filters.time?.end) params.append('end', filters.time.end);
if (filters.transport) params.append('mode', filters.transport.join(','));
const res = await fetch(`/api/trips?${params}`);
const data = await res.json();
const points: PathPoint[] = data.features.flatMap((f: any) =>
f.geometry.coordinates.map((coord: [number, number], i: number) => ({
lng: coord[0],
lat: coord[1],
timestamp: f.properties.timestamps[i],
speed: f.properties.speeds[i] || 0,
}))
);
setPath(points.sort((a, b) => a.timestamp - b.timestamp));
progressRef.current = 0;
};
fetchTrips();
}, [filters]);
// Animation loop
useEffect(() => {
if (!map || path.length === 0) return;
const sourceId = 'animated-route';
const layerId = 'route-glow';
const pointLayerId = 'route-point';
if (!map.getSource(sourceId)) {
map.addSource(sourceId, {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] },
});
// Glowing line
map.addLayer({
id: layerId,
type: 'line',
source: sourceId,
paint: {
'line-color': '#3b82f6',
'line-width': ['interpolate', ['linear'], ['zoom'], 10, 4, 18, 12],
'line-opacity': 0.9,
'line-blur': 8,
'line-gradient': [
'interpolate',
['linear'],
['line-progress'],
0, 'rgba(59, 130, 246, 0)',
0.1, 'rgba(59, 130, 246, 0.5)',
1, 'rgba(59, 130, 246, 1)',
],
},
});
// Moving dot
map.addLayer({
id: pointLayerId,
type: 'circle',
source: sourceId,
paint: {
'circle-radius': 10,
'circle-color': '#fff',
'circle-stroke-width': 3,
'circle-stroke-color': '#3b82f6',
'circle-opacity': ['interpolate', ['linear'], ['get', 'progress'], 0, 0, 1, 1],
},
});
}
const animate = (timestamp: number) => {
if (!startTimeRef.current) startTimeRef.current = timestamp;
const elapsed = (timestamp - startTimeRef.current) * speed;
const totalDuration = path[path.length - 1].timestamp - path[0].timestamp;
progressRef.current = Math.min(elapsed / totalDuration, 1);
if (progressRef.current >= 1) {
onComplete?.();
return;
}
const interpolated = interpolatePath(path, progressRef.current);
const geojson = {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: interpolated.seen.map(p => [p.lng, p.lat]),
},
properties: { progress: progressRef.current },
},
{
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [interpolated.current.lng, interpolated.current.lat],
},
properties: { progress: 1 },
},
],
};
(map.getSource(sourceId) as any)?.setData(geojson);
if (isPlaying) {
animationRef.current = requestAnimationFrame(animate);
}
};
if (isPlaying) {
animationRef.current = requestAnimationFrame(animate);
}
return () => {
if (animationRef.current) cancelAnimationFrame(animationRef.current);
startTimeRef.current = 0;
};
}, [map, path, isPlaying, speed, onComplete]);
return null;
}
EOF
cat > frontend/src/components/map/HeatmapLayer.tsx <<'EOF'
'use client';
import { useEffect } from 'react';
import { useMap } from 'react-map-gl';
import { useFilterStore } from '@/stores/useFilterStore';
interface HeatmapLayerProps {
intensity: number; // 0100
}
export default function HeatmapLayer({ intensity }: HeatmapLayerProps) {
const { current: map } = useMap();
const { filters } = useFilterStore();
useEffect(() => {
if (!map) return;
const sourceId = 'heatmap-data';
const layerId = 'visit-heatmap';
const fetchVisits = async () => {
const params = new URLSearchParams();
if (filters.time?.start) params.append('start', filters.time.start);
if (filters.time?.end) params.append('end', filters.time.end);
const res = await fetch(`/api/visits?${params}`);
const data = await res.json();
const features = data.map((v: any) => ({
type: 'Feature' as const,
geometry: {
type: 'Point' as const,
coordinates: [v.lng, v.lat],
],
properties: { weight: v.duration / 3600 }, // hours
}));
if (map.getSource(sourceId)) {
(map.getSource(sourceId) as any).setData({
type: 'FeatureCollection',
features,
});
} else {
map.addSource(sourceId, {
type: 'geojson',
data: { type: 'FeatureCollection', features },
});
map.addLayer({
id: layerId,
type: 'heatmap',
source: sourceId,
paint: {
'heatmap-weight': ['interpolate', ['linear'], ['get', 'weight'], 0, 0, 10, 1],
'heatmap-intensity': intensity / 100,
'heatmap-color': [
'interpolate',
['linear'],
['heatmap-density'],
0, 'rgba(0, 0, 255, 0)',
0.2, 'royalblue',
0.4, 'cyan',
0.6, 'lime',
0.8, 'yellow',
1, 'red',
],
'heatmap-radius': ['interpolate', ['linear'], ['zoom'], 0, 2, 18, 40],
'heatmap-opacity': 0.8,
},
});
}
};
fetchVisits();
return () => {
if (map.getLayer(layerId)) map.removeLayer(layerId);
if (map.getSource(sourceId)) map.removeSource(sourceId);
};
}, [map, filters, intensity]);
return null;
}
EOF
cat > frontend/src/components/map/MapWithPlayback.tsx <<'EOF'
'use client';
import Map, { MapRef } from 'react-map-gl';
import { useRef, useState } from 'react';
import AnimatedRouteLayer from './AnimatedRouteLayer';
import HeatmapLayer from './HeatmapLayer';
import PlaybackController from './PlaybackController';
import FilterBar from '@/components/FilterBar';
import { useFilterStore } from '@/stores/useFilterStore';
const MAP_STYLE = '/styles/basic.json'; // self-hosted
export default function MapWithPlayback() {
const mapRef = useRef<MapRef>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [speed, setSpeed] = useState(1);
const [heatmapIntensity, setHeatmapIntensity] = useState(50);
const { filters } = useFilterStore();
return (
<div className="h-screen flex flex-col">
<FilterBar />
<div className="flex-1 relative">
<Map
ref={mapRef}
initialViewState={{
longitude: 24.9384,
latitude: 60.1699,
zoom: 10,
}}
mapStyle={MAP_STYLE}
interactiveLayerIds={[]}
>
<AnimatedRouteLayer
isPlaying={isPlaying}
speed={speed}
onComplete={() => setIsPlaying(false)}
/>
<HeatmapLayer intensity={heatmapIntensity} />
{isPlaying && (
<PlaybackController
isPlaying={isPlaying}
onPlayPause={() => setIsPlaying(!isPlaying)}
onSpeedChange={setSpeed}
onReset={() => {
setIsPlaying(false);
// Reset animation
}}
/>
)}
</Map>
{/* Heatmap Intensity Control */}
<div className="absolute top-4 right-4 bg-background/90 backdrop-blur-sm rounded-lg p-3 shadow-lg">
<label className="text-xs font-medium">Heatmap Intensity</label>
<input
type="range"
min="0"
max="100"
value={heatmapIntensity}
onChange={(e) => setHeatmapIntensity(+e.target.value)}
className="w-32 mt-1"
/>
</div>
</div>
</div>
);
}
EOF
cat > frontend/src/stores/useFilterStore.ts <<'EOF'
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export type TransportMode = 'WALKING' | 'CYCLING' | 'DRIVING' | 'TRANSIT';
export type PlaceType = 'home' | 'work' | 'cafe' | 'gym' | 'restaurant' | 'park' | 'shop';
export interface FilterState {
search: string | null;
time: { start: string; end?: string } | null;
transport: TransportMode[] | null;
distance: number | null;
placeType: PlaceType[] | null;
}
interface FilterStore extends FilterState {
setFilter: <K extends keyof FilterState>(key: K, value: FilterState[K]) => void;
removeFilter: (key: keyof FilterState) => void;
clearFilters: () => void;
}
const initialState: FilterState = {
search: null,
time: null,
transport: null,
distance: null,
placeType: null,
};
export const useFilterStore = create<FilterStore>()(
persist(
(set) => ({
...initialState,
setFilter: (key, value) =>
set((state) => ({
...state,
[key]: value,
})),
removeFilter: (key) =>
set((state) => ({
...state,
[key]: initialState[key],
})),
clearFilters: () => set(initialState),
}),
{
name: 'reitti-filters',
}
)
);
EOF
cat > frontend/src/utils/animation.ts <<'EOF'
export interface PathPoint {
lng: number;
lat: number;
timestamp: number;
speed?: number;
}
export interface InterpolatedPath {
current: PathPoint;
seen: PathPoint[];
}
export function interpolatePath(path: PathPoint[], progress: number): InterpolatedPath {
if (path.length === 0) return { current: {lng: 0, lat: 0, timestamp: 0}, seen: [] };
if (progress >= 1) return { current: path[path.length - 1], seen: path };
const totalTime = path[path.length - 1].timestamp - path[0].timestamp;
const targetTime = path[0].timestamp + totalTime * progress;
let i = 0;
while (i < path.length - 1 && path[i + 1].timestamp < targetTime) i++;
const a = path[i];
const b = path[i + 1];
// If we are at the last point or beyond, return the last point
if (!b) return { current: a, seen: path.slice(0, i + 1) };
const t = (targetTime - a.timestamp) / (b.timestamp - a.timestamp);
const current = {
lng: a.lng + (b.lng - a.lng) * t,
lat: a.lat + (b.lat - a.lat) * t,
timestamp: targetTime,
};
return { current, seen: path.slice(0, i + 1) };
}
EOF
echo "No cap. Just facts. Ready."
echo "Run: docker compose up -d"
echo "Open: http://localhost:3000"