945 lines
28 KiB
Bash
Executable File
945 lines
28 KiB
Bash
Executable File
#!/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; // 0–100
|
||
}
|
||
|
||
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"
|