This commit is contained in:
maq
2025-11-05 01:30:03 -08:00
commit 268baf5933
42 changed files with 3062 additions and 0 deletions

5
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,5 @@
FROM node:18-alpine
WORKDIR /app
COPY . .
RUN npm install && npm run build
CMD ["npm", "run", "preview"]

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NoCap W.Y.A.</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

24
frontend/package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"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",
"mapbox-gl": "^2.15.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"
}
}

View File

@@ -0,0 +1,18 @@
{
"version": 8,
"name": "Basic Style",
"sources": {
"osm": {
"type": "raster",
"tiles": ["https://a.tile.openstreetmap.org/{z}/{x}/{y}.png"],
"tileSize": 256
}
},
"layers": [
{
"id": "osm-layer",
"source": "osm",
"type": "raster"
}
]
}

12
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,12 @@
import React from 'react';
import MapWithPlayback from './components/map/MapWithPlayback';
function App() {
return (
<div className="App">
<MapWithPlayback />
</div>
);
}
export default App;

View File

@@ -0,0 +1,348 @@
'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>
);
}

View File

@@ -0,0 +1,150 @@
'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;
}

View File

@@ -0,0 +1,83 @@
'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',
geometry: {
type: 'Point',
coordinates: [v.lng, v.lat],
},
properties: { weight: v.duration / 3600 },
}));
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;
}

View File

@@ -0,0 +1,69 @@
'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>
);
}

View File

@@ -0,0 +1,74 @@
'use client';
import { useState, useEffect } from 'react';
import { Play, Pause, SkipForward, SkipBack, Gauge } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Slider } from '@/components/ui/slider';
import { format } from 'date-fns';
import { motion } from 'framer-motion';
interface PlaybackControllerProps {
isPlaying: boolean;
onPlayPause: () => void;
onSpeedChange: (speed: number) => void;
onReset: () => void;
currentTime?: Date;
duration?: number;
}
const speeds = [0.5, 1, 2, 4, 8, 16];
export default function PlaybackController({
isPlaying,
onPlayPause,
onSpeedChange,
onReset,
currentTime,
duration,
}: PlaybackControllerProps) {
const [speedIndex, setSpeedIndex] = useState(1); // 1x
const handleSpeed = () => {
const next = (speedIndex + 1) % speeds.length;
setSpeedIndex(next);
onSpeedChange(speeds[next]);
};
return (
<motion.div
initial={{ y: 100 }}
animate={{ y: 0 }}
className="absolute bottom-4 left-1/2 transform -translate-x-1/2 bg-background/95 backdrop-blur-sm rounded-xl shadow-xl p-4 flex items-center gap-3 border"
>
<Button size="icon" variant="ghost" onClick={onReset}>
<SkipBack className="h-5 w-5" />
</Button>
<Button size="icon" onClick={onPlayPause}>
{isPlaying ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />}
</Button>
<Button size="icon" variant="ghost" onClick={handleSpeed}>
<Gauge className="h-4 w-4" />
<span className="ml-1 text-xs font-medium">{speeds[speedIndex]}x</span>
</Button>
<div className="flex-1 px-4">
<Slider
value={[currentTime ? currentTime.getTime() : 0]}
max={duration || 1}
step={1000}
className="cursor-pointer"
/>
<div className="flex justify-between text-xs text-muted-foreground mt-1">
<span>{currentTime ? format(currentTime, 'HH:mm:ss') : '--:--'}</span>
<span>{duration ? format(new Date(duration), 'HH:mm:ss') : '--:--'}</span>
</div>
</div>
<Button size="icon" variant="ghost" onClick={onReset}>
<SkipForward className="h-5 w-5" />
</Button>
</motion.div>
);
}

View File

@@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,55 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@@ -0,0 +1,62 @@
import * as React from "react"
import { ChevronLeft, ChevronRight } from "lucide-react"
import { DayPicker } from "react-day-picker"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
export type CalendarProps = React.ComponentProps<typeof DayPicker>
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4",
caption: "flex justify-center pt-1 relative items-center",
caption_label: "text-sm font-medium",
nav: "space-x-1 flex items-center",
nav_button: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-y-1",
head_row: "flex",
head_cell: "text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-range-start)]:rounded-l-md [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
day: cn(
buttonVariants({ variant: "ghost" }),
"h-9 w-9 p-0 font-normal aria-selected:opacity-100"
),
day_range_end: "day-range-end",
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside: "text-muted-foreground opacity-50",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle:
"aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames,
}}
components={{
IconLeft: ({ ...props }) => <ChevronLeft className="h-4 w-4" />,
IconRight: ({ ...props }) => <ChevronRight className="h-4 w-4" />,
}}
{...props}
/>
)
}
Calendar.displayName = "Calendar"
export { Calendar }

View File

@@ -0,0 +1,119 @@
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { Search } from "lucide-react"
import { cn } from "@/lib/utils"
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<React.ElementRef<typeof CommandPrimitive.Empty>, React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>>(
(props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
)
)
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled=\"true\"]:pointer-events-none data-[disabled=\"true\"]:opacity-50",
className
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
export {
Command,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandSeparator,
}

View File

@@ -0,0 +1,25 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@@ -0,0 +1,26 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
interface LabelProps
extends React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>,
VariantProps<typeof labelVariants> {}
const Label = React.forwardRef<React.ElementRef<typeof LabelPrimitive.Root>, LabelProps>(
({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
)
)
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@@ -0,0 +1,29 @@
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent }

View File

@@ -0,0 +1,29 @@
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

View File

@@ -0,0 +1,25 @@
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from "@/lib/utils"
const Slider = React.forwardRef<React.ElementRef<typeof SliderPrimitive.Root>, React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>>(
({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
className
)}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
)
)
Slider.displayName = SliderPrimitive.Root.displayName
export { Slider }

5
frontend/src/index.css Normal file
View File

@@ -0,0 +1,5 @@
@import 'mapbox-gl/dist/mapbox-gl.css';
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

10
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App'; // Assuming App.tsx is the main component
import './index.css'; // Assuming a global CSS file
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

View File

@@ -0,0 +1,49 @@
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',
}
)
);

View File

@@ -0,0 +1,38 @@
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) };
}

View File

@@ -0,0 +1,22 @@
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'),
],
}

13
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,13 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: { port: 3000 },
})