From 268baf59331dab99f4793f58b2078cb90977046a Mon Sep 17 00:00:00 2001 From: maq Date: Wed, 5 Nov 2025 01:30:03 -0800 Subject: [PATCH] nocapwya --- .DS_Store | Bin 0 -> 6148 bytes .env | 5 + New Folder With Items/Untitled 24.sh | 104 ++ New Folder With Items/Untitled 25.yml | 62 ++ New Folder With Items/docker compose.yml | 71 ++ .../docker-compose.yml.broken | 65 ++ New Folder With Items/docker-compose4.yml | 65 ++ New Folder With Items/fix-deployment.sh | 97 ++ New Folder With Items/fix-deployment2.sh | 74 ++ New Folder With Items/install.sh | 944 ++++++++++++++++++ Push to Gitea for Memories.sh | 77 ++ backend/Dockerfile | 6 + backend/app/main.py | 44 + backend/pyproject.toml | 13 + docker-compose.yml | 79 ++ frontend/Dockerfile | 5 + frontend/index.html | 13 + frontend/package.json | 24 + frontend/public/styles/basic.json | 18 + frontend/src/App.tsx | 12 + frontend/src/components/FilterBar.tsx | 348 +++++++ .../src/components/map/AnimatedRouteLayer.tsx | 150 +++ frontend/src/components/map/HeatmapLayer.tsx | 83 ++ .../src/components/map/MapWithPlayback.tsx | 69 ++ .../src/components/map/PlaybackController.tsx | 74 ++ frontend/src/components/ui/badge.tsx | 36 + frontend/src/components/ui/button.tsx | 55 + frontend/src/components/ui/calendar.tsx | 62 ++ frontend/src/components/ui/command.tsx | 119 +++ frontend/src/components/ui/input.tsx | 25 + frontend/src/components/ui/label.tsx | 26 + frontend/src/components/ui/popover.tsx | 29 + frontend/src/components/ui/separator.tsx | 29 + frontend/src/components/ui/slider.tsx | 25 + frontend/src/index.css | 5 + frontend/src/lib/utils.ts | 6 + frontend/src/main.tsx | 10 + frontend/src/stores/useFilterStore.ts | 49 + frontend/src/utils/animation.ts | 38 + frontend/tailwind.config.ts | 22 + frontend/vite.config.ts | 13 + nocap.code-workspace | 11 + 42 files changed, 3062 insertions(+) create mode 100644 .DS_Store create mode 100644 .env create mode 100644 New Folder With Items/Untitled 24.sh create mode 100644 New Folder With Items/Untitled 25.yml create mode 100644 New Folder With Items/docker compose.yml create mode 100644 New Folder With Items/docker-compose.yml.broken create mode 100644 New Folder With Items/docker-compose4.yml create mode 100755 New Folder With Items/fix-deployment.sh create mode 100755 New Folder With Items/fix-deployment2.sh create mode 100755 New Folder With Items/install.sh create mode 100755 Push to Gitea for Memories.sh create mode 100644 backend/Dockerfile create mode 100644 backend/app/main.py create mode 100644 backend/pyproject.toml create mode 100644 docker-compose.yml create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/public/styles/basic.json create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/components/FilterBar.tsx create mode 100644 frontend/src/components/map/AnimatedRouteLayer.tsx create mode 100644 frontend/src/components/map/HeatmapLayer.tsx create mode 100644 frontend/src/components/map/MapWithPlayback.tsx create mode 100644 frontend/src/components/map/PlaybackController.tsx create mode 100644 frontend/src/components/ui/badge.tsx create mode 100644 frontend/src/components/ui/button.tsx create mode 100644 frontend/src/components/ui/calendar.tsx create mode 100644 frontend/src/components/ui/command.tsx create mode 100644 frontend/src/components/ui/input.tsx create mode 100644 frontend/src/components/ui/label.tsx create mode 100644 frontend/src/components/ui/popover.tsx create mode 100644 frontend/src/components/ui/separator.tsx create mode 100644 frontend/src/components/ui/slider.tsx create mode 100644 frontend/src/index.css create mode 100644 frontend/src/lib/utils.ts create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/stores/useFilterStore.ts create mode 100644 frontend/src/utils/animation.ts create mode 100644 frontend/tailwind.config.ts create mode 100644 frontend/vite.config.ts create mode 100644 nocap.code-workspace diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..bdf53e146dbe5b7456fd8b57e09bd874d7084b01 GIT binary patch literal 6148 zcmeHK!AiqG5S^(-B3^nCJnk>_4`PXeAcFMZ#e=j_D;gHh@klrHKxMg}SDk2g z^pOFnT}HRGqKL}1>Nkrq)$oAMQPgJ&bb^qIO3YT6UD7?m7qCuKT1{&y`!~|6yYX}$ z>t&0cEjF!VQOv41lXK*6FJ~85uSc-ATA}>m3d=?31g*ok=?pjn&Oq-NV11)(zjtru zFXRk31Aoncd>;Z-FgI)z)29PNY5{;lxRbz^UP5w$VQ$zc;t7N`6{x9fEe2~k{K4XK z!$wiliLLoy+nKFH;j}x}4=J2DSM<>ta0WIR*wN`o?*HSj`~S@(pE(20z`tUEhsCs* z;7E42b`B?ZZGc`wMI^3K{78Y3u42T>ReT6d0)G$#m>V{V@Id@WAkpB1Gw`bnyaNNZ BQIY@v literal 0 HcmV?d00001 diff --git a/.env b/.env new file mode 100644 index 00000000..039c0579 --- /dev/null +++ b/.env @@ -0,0 +1,5 @@ +# Database (auto from compose) +# API_BASE_URL=http://localhost:8000 # For frontend if needed + +# Mapbox (required for maps; replace with your pk.eyJ1Oi... token) +VITE_MAPBOX_TOKEN=pk.eyJ1OnlvdXJ1c2VybmFtZSIsImEiOiJqbG... # TODO: Update this! diff --git a/New Folder With Items/Untitled 24.sh b/New Folder With Items/Untitled 24.sh new file mode 100644 index 00000000..e0cde15a --- /dev/null +++ b/New Folder With Items/Untitled 24.sh @@ -0,0 +1,104 @@ +# Create the fix script +cat > fix-deployment.sh <<'EOF' +#!/bin/zsh # Match your shell +set -e + +echo "Fixing NoCap W.Y.A. deployment..." + +# 1. Update docker-compose.yml (no version, project name, M1 tweaks) +cat > docker-compose.yml <<'DOCS' +name: nocapwya # Explicit project name (ignores dir spaces) +services: + db: + image: postgis/postgis:15-3.4-alpine # Alpine for M1 arm64 efficiency + container_name: nocapwya-db + environment: + POSTGRES_DB: wya + POSTGRES_USER: wya + POSTGRES_PASSWORD: wya + volumes: + - pg_data:/var/lib/postgresql/data + - ./backend/app/init.sql:/docker-entrypoint-initdb.d/init.sql + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U wya -d wya"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - wya-net + + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: nocapwya-backend + ports: + - "8000:8000" + volumes: + - ./data:/data + depends_on: + db: + condition: service_healthy + env_file: + - .env + restart: unless-stopped + networks: + - wya-net + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: nocapwya-frontend + ports: + - "3000:80" + volumes: + - ./data/tiles:/usr/share/nginx/html/tiles:ro + depends_on: + - backend + env_file: + - .env + restart: unless-stopped + networks: + - wya-net + +volumes: + pg_data: # Named volume for persistence + +networks: + wya-net: + driver: bridge +DOCS + +# 2. Create .env (add your Mapbox token here; get free from mapbox.com) +cat > .env <<'ENV' +# Database (auto from compose) +# API_BASE_URL=http://localhost:8000 # For frontend if needed + +# Mapbox (required for maps; replace with your pk.eyJ1Oi... token) +VITE_MAPBOX_TOKEN=pk.eyJ1OnlvdXJ1c2VybmFtZSIsImEiOiJqbG... # TODO: Update this! +ENV + +# 3. Tweak frontend Dockerfile for M1 (use full node if alpine fails) +if [[ $(uname -m) == "arm64" ]]; then + sed -i '' 's/node:18-alpine/node:18/' frontend/Dockerfile + echo "Applied M1 fix to frontend Dockerfile" +fi + +# 4. Ensure data dir and perms (M1/macOS volume issues) +mkdir -p data/tiles +chmod -R 755 data +echo "Data dir ready (perms fixed)" + +# 5. Verify init.sql exists (seed data) +if [[ ! -f backend/app/init.sql ]]; then + echo "Warning: init.sql missing—re-run install.sh to regenerate files" +fi + +echo "Fixes applied! Now run: docker compose down -v && docker compose up -d --build" +EOF + +# Make executable and run it +chmod +x fix-deployment.sh +./fix-deployment.sh diff --git a/New Folder With Items/Untitled 25.yml b/New Folder With Items/Untitled 25.yml new file mode 100644 index 00000000..2b8a6fc0 --- /dev/null +++ b/New Folder With Items/Untitled 25.yml @@ -0,0 +1,62 @@ +name: nocapwya # Explicit project name (ignores dir spaces) +services: + db: + image: postgis/postgis:15-3.4-alpine # Alpine for M1 arm64 efficiency + container_name: nocapwya-db + environment: + POSTGRES_DB: wya + POSTGRES_USER: wya + POSTGRES_PASSWORD: wya + volumes: + - pg_data:/var/lib/postgresql/data + - ./backend/app/init.sql:/docker-entrypoint-initdb.d/init.sql + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U wya -d wya"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - wya-net + + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: nocapwya-backend + ports: + - "8000:8000" + volumes: + - ./data:/data + depends_on: + db: + condition: service_healthy + env_file: + - .env + restart: unless-stopped + networks: + - wya-net + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: nocapwya-frontend + ports: + - "3000:80" + volumes: + - ./data/tiles:/usr/share/nginx/html/tiles:ro + depends_on: + - backend + env_file: + - .env + restart: unless-stopped + networks: + - wya-net + +volumes: + pg_data: # Named volume for persistence + +networks: + wya-net: + driver: bridge diff --git a/New Folder With Items/docker compose.yml b/New Folder With Items/docker compose.yml new file mode 100644 index 00000000..44a6c6a4 --- /dev/null +++ b/New Folder With Items/docker compose.yml @@ -0,0 +1,71 @@ +# Overwrite with corrected YAML (platform at service level) +cat > docker-compose.yml << 'EOF' +name: nocapwya +services: + db: + image: postgis/postgis:16-3.5 + container_name: nocapwya-db + platform: linux/arm64 + environment: + POSTGRES_DB: wya + POSTGRES_USER: wya + POSTGRES_PASSWORD: wya + volumes: + - pg_data:/var/lib/postgresql/data + - ./backend/app/init.sql:/docker-entrypoint-initdb.d/init.sql + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U wya -d wya"] + interval: 10s + timeout: 5s + retries: 10 + networks: + - wya-net + + backend: + platform: linux/arm64 + build: + context: ./backend + dockerfile: Dockerfile + container_name: nocapwya-backend + ports: + - "8000:8000" + volumes: + - ./data:/data + depends_on: + db: + condition: service_healthy + env_file: + - .env + restart: unless-stopped + networks: + - wya-net + + frontend: + platform: linux/arm64 + build: + context: ./frontend + dockerfile: Dockerfile + container_name: nocapwya-frontend + ports: + - "3000:80" + volumes: + - ./data/tiles:/usr/share/nginx/html/tiles:ro + depends_on: + - backend + env_file: + - .env + restart: unless-stopped + networks: + - wya-net + +volumes: + pg_data: + +networks: + wya-net: + driver: bridge +EOF + +# Validate (should output the parsed config without errors) +docker compose config diff --git a/New Folder With Items/docker-compose.yml.broken b/New Folder With Items/docker-compose.yml.broken new file mode 100644 index 00000000..b39ca505 --- /dev/null +++ b/New Folder With Items/docker-compose.yml.broken @@ -0,0 +1,65 @@ +name: nocapwya +services: + db: + image: postgis/postgis:16-3.5 + container_name: nocapwya-db + platform: linux/arm64 + environment: + POSTGRES_DB: wya + POSTGRES_USER: wya + POSTGRES_PASSWORD: wya + volumes: + - pg_data:/var/lib/postgresql/data + - ./backend/app/init.sql:/docker-entrypoint-initdb.d/init.sql + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U wya -d wya"] + interval: 10s + timeout: 5s + retries: 10 + networks: + - wya-net + + backend: + build: + context: ./backend + dockerfile: Dockerfile + platform: linux/arm64 + container_name: nocapwya-backend + ports: + - "8000:8000" + volumes: + - ./data:/data + depends_on: + db: + condition: service_healthy + env_file: + - .env + restart: unless-stopped + networks: + - wya-net + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + platform: linux/arm64 + container_name: nocapwya-frontend + ports: + - "3000:80" + volumes: + - ./data/tiles:/usr/share/nginx/html/tiles:ro + depends_on: + - backend + env_file: + - .env + restart: unless-stopped + networks: + - wya-net + +volumes: + pg_data: + +networks: + wya-net: + driver: bridge diff --git a/New Folder With Items/docker-compose4.yml b/New Folder With Items/docker-compose4.yml new file mode 100644 index 00000000..200a5dad --- /dev/null +++ b/New Folder With Items/docker-compose4.yml @@ -0,0 +1,65 @@ +name: nocapwya +services: + db: + image: postgis/postgis:16-3.5 + container_name: nocapwya-db + platform: linux/arm64 + environment: + POSTGRES_DB: wya + POSTGRES_USER: wya + POSTGRES_PASSWORD: wya + volumes: + - pg_data:/var/lib/postgresql/data + - ./backend/app/init.sql:/docker-entrypoint-initdb.d/init.sql + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U wya -d wya"] + interval: 10s + timeout: 5s + retries: 10 + networks: + - wya-net + + backend: + build: + context: ./backend + dockerfile: Dockerfile + platform: linux/arm64 + container_name: nocapwya-backend + ports: + - "8000:8000" + volumes: + - ./data:/data + depends_on: + db: + condition: service_healthy + env_file: + - .env + restart: unless-stopped + networks: + - wya-net + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + platform: linux/arm64 + container_name: nocapwya-frontend + ports: + - "3000:80" + volumes: + - ./data/tiles:/usr/share/nginx/html/tiles:ro + depends_on: + - backend + env_file: + - .env + restart: unless-stopped + networks: + - wya-net + +volumes: + pg_data: + +networks: + wya-net: + driver: bridge diff --git a/New Folder With Items/fix-deployment.sh b/New Folder With Items/fix-deployment.sh new file mode 100755 index 00000000..da522312 --- /dev/null +++ b/New Folder With Items/fix-deployment.sh @@ -0,0 +1,97 @@ +#!/bin/zsh # Match your shell +set -e + +echo "Fixing NoCap W.Y.A. deployment..." + +# 1. Update docker-compose.yml (no version, project name, M1 tweaks) +cat > docker-compose.yml <<'DOCS' +name: nocapwya # Explicit project name (ignores dir spaces) +services: + db: + image: postgis/postgis:15-3.4-alpine # Alpine for M1 arm64 efficiency + container_name: nocapwya-db + environment: + POSTGRES_DB: wya + POSTGRES_USER: wya + POSTGRES_PASSWORD: wya + volumes: + - pg_data:/var/lib/postgresql/data + - ./backend/app/init.sql:/docker-entrypoint-initdb.d/init.sql + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U wya -d wya"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - wya-net + + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: nocapwya-backend + ports: + - "8000:8000" + volumes: + - ./data:/data + depends_on: + db: + condition: service_healthy + env_file: + - .env + restart: unless-stopped + networks: + - wya-net + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: nocapwya-frontend + ports: + - "3000:80" + volumes: + - ./data/tiles:/usr/share/nginx/html/tiles:ro + depends_on: + - backend + env_file: + - .env + restart: unless-stopped + networks: + - wya-net + +volumes: + pg_data: # Named volume for persistence + +networks: + wya-net: + driver: bridge +DOCS + +# 2. Create .env (add your Mapbox token here; get free from mapbox.com) +cat > .env <<'ENV' +# Database (auto from compose) +# API_BASE_URL=http://localhost:8000 # For frontend if needed + +# Mapbox (required for maps; replace with your pk.eyJ1Oi... token) +VITE_MAPBOX_TOKEN=pk.eyJ1OnlvdXJ1c2VybmFtZSIsImEiOiJqbG... # TODO: Update this! +ENV + +# 3. Tweak frontend Dockerfile for M1 (use full node if alpine fails) +if [[ $(uname -m) == "arm64" ]]; then + sed -i '' 's/node:18-alpine/node:18/' frontend/Dockerfile + echo "Applied M1 fix to frontend Dockerfile" +fi + +# 4. Ensure data dir and perms (M1/macOS volume issues) +mkdir -p data/tiles +chmod -R 755 data +echo "Data dir ready (perms fixed)" + +# 5. Verify init.sql exists (seed data) +if [[ ! -f backend/app/init.sql ]]; then + echo "Warning: init.sql missing—re-run install.sh to regenerate files" +fi + +echo "Fixes applied! Now run: docker compose down -v && docker compose up -d --build" diff --git a/New Folder With Items/fix-deployment2.sh b/New Folder With Items/fix-deployment2.sh new file mode 100755 index 00000000..6a827bb0 --- /dev/null +++ b/New Folder With Items/fix-deployment2.sh @@ -0,0 +1,74 @@ +# Backup old (if needed) +mv docker-compose.yml docker-compose.yml.broken 2>/dev/null || true + +# Create validated YAML (arm64-safe, no syntax issues) +cat > docker-compose.yml << 'EOF' +name: nocapwya +services: + db: + image: postgis/postgis:16-3.5 + container_name: nocapwya-db + platform: linux/arm64 + environment: + POSTGRES_DB: wya + POSTGRES_USER: wya + POSTGRES_PASSWORD: wya + volumes: + - pg_data:/var/lib/postgresql/data + - ./backend/app/init.sql:/docker-entrypoint-initdb.d/init.sql + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U wya -d wya"] + interval: 10s + timeout: 5s + retries: 10 + networks: + - wya-net + + backend: + build: + context: ./backend + dockerfile: Dockerfile + platform: linux/arm64 + container_name: nocapwya-backend + ports: + - "8000:8000" + volumes: + - ./data:/data + depends_on: + db: + condition: service_healthy + env_file: + - .env + restart: unless-stopped + networks: + - wya-net + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + platform: linux/arm64 + container_name: nocapwya-frontend + ports: + - "3000:80" + volumes: + - ./data/tiles:/usr/share/nginx/html/tiles:ro + depends_on: + - backend + env_file: + - .env + restart: unless-stopped + networks: + - wya-net + +volumes: + pg_data: + +networks: + wya-net: + driver: bridge +EOF + +# Verify YAML syntax (should output nothing if valid) +docker compose config # Runs parser; if error, it'll show line details diff --git a/New Folder With Items/install.sh b/New Folder With Items/install.sh new file mode 100755 index 00000000..6afa7763 --- /dev/null +++ b/New Folder With Items/install.sh @@ -0,0 +1,944 @@ +#!/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(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 ( + <> +
+
+ {/* Top Row: Search + Filter Button */} +
+
+ + setFilter('search', e.target.value || null)} + className="pl-10 pr-10" + /> + {filters.search && ( + + )} +
+ +
+ + {/* Filter Chips */} + + {activeFilterCount > 0 && ( + + {filters.time && ( + removeFilter('time')} + /> + )} + {filters.transport && filters.transport.length > 0 && ( + t.charAt(0) + t.slice(1).toLowerCase()).join(', ')}`} + onRemove={() => removeFilter('transport')} + /> + )} + {filters.distance && ( + ${filters.distance}km`} + onRemove={() => removeFilter('distance')} + /> + )} + {filters.placeType && filters.placeType.length > 0 && ( + removeFilter('placeType')} + /> + )} + {filters.search && ( + setFilter('search', null)} + /> + )} + + + )} + +
+
+ + {/* Main Filter Popover */} + setOpenPopover(open ? 'main' : null)}> + +
+ + +
+ {/* Date Range */} +
+ + + + + + + setDateRange(range || { from: undefined, to: undefined })} + numberOfMonths={2} + /> +
+ + +
+
+
+
+ + + + {/* Transport Mode */} +
+ +
+ {(['WALKING', 'CYCLING', 'DRIVING', 'TRANSIT'] as const).map((mode) => { + const Icon = transportIcons[mode]; + const isActive = filters.transport?.includes(mode); + return ( + + ); + })} +
+
+ + + + {/* Distance */} +
+ +
+ setFilter('distance', value > 0 ? value : null)} + max={50} + step={1} + className="flex-1" + /> + + {filters.distance || 0} km + +
+
+ + + + {/* Place Types */} +
+ + + + + + + + + + No results found. + + {(['home', 'work', 'cafe', 'gym', 'restaurant', 'park', 'shop'] as const).map((type) => ( + { + const current = filters.placeType || []; + const updated = current.includes(type) + ? current.filter(t => t !== type) + : [...current, type]; + setFilter('placeType', updated.length > 0 ? updated : null); + }} + > +
+ {filters.placeType?.includes(type) &&
} +
+ {type.charAt(0).toUpperCase() + type.slice(1)} + + ))} + + + + + +
+
+ + + + ); +} + +// Filter Chip Component +function FilterChip({ label, onRemove }: { label: string; onRemove: () => void }) { + return ( + + {label} + + + ); +} +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([]); + const animationRef = useRef(); + const startTimeRef = useRef(0); + const progressRef = useRef(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(null); + const [isPlaying, setIsPlaying] = useState(false); + const [speed, setSpeed] = useState(1); + const [heatmapIntensity, setHeatmapIntensity] = useState(50); + const { filters } = useFilterStore(); + + return ( +
+ +
+ + setIsPlaying(false)} + /> + + + {isPlaying && ( + setIsPlaying(!isPlaying)} + onSpeedChange={setSpeed} + onReset={() => { + setIsPlaying(false); + // Reset animation + }} + /> + )} + + + {/* Heatmap Intensity Control */} +
+ + setHeatmapIntensity(+e.target.value)} + className="w-32 mt-1" + /> +
+
+
+ ); +} +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: (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()( + 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" diff --git a/Push to Gitea for Memories.sh b/Push to Gitea for Memories.sh new file mode 100755 index 00000000..298d739e --- /dev/null +++ b/Push to Gitea for Memories.sh @@ -0,0 +1,77 @@ +#!/bin/bash + +# Universal Git Automation: Init, Commit, and Push to Gitea +# Works for ANY project! Run from your project directory. +# Usage: bash setup-git-gitea.sh +# Author: ProjectForge (generalized from Memories project) + +set -e # Exit on any error + +# Detect project name from current directory +PROJECT_DIR="$(basename $(pwd))" +echo "=== Universal Git to Gitea Automation ===" +echo "Project: $PROJECT_DIR" +echo "Current directory: $(pwd)" + +# Check if Git is installed +if ! command -v git &> /dev/null; then + echo "Error: Git is not installed. Install it from https://git-scm.com/" + exit 1 +fi + +# Step 1: Initialize Git if needed +if [ ! -d ".git" ]; then + echo "Initializing Git repository..." + git init +else + echo "Git repository already exists." +fi + +# Step 2: Add all project files (customize this line if you want specific files) +echo "Adding all project files..." +git add . + +# Step 3: Commit (prompt for message) +DEFAULT_COMMIT_MSG="Initial commit: $PROJECT_DIR project setup with docs and code" +echo "" +echo "Enter commit message (or press Enter for default: $DEFAULT_COMMIT_MSG):" +read -r COMMIT_MSG +if [ -z "$COMMIT_MSG" ]; then + COMMIT_MSG="$DEFAULT_COMMIT_MSG" +fi +git commit -m "$COMMIT_MSG" || echo "No changes to commit (repo may already have initial commit)" + +# Step 4: Prompt for Gitea repo URL +echo "" +echo "Enter your Gitea repository URL for this project." +echo "Examples:" +echo " - HTTPS: http://localhost:3000/username/$PROJECT_DIR.git" +echo " - SSH: git@your-server:username/$PROJECT_DIR.git" +echo " (Create the empty repo in Gitea first if needed)" +read -r GITEA_URL +if [ -z "$GITEA_URL" ]; then + echo "Error: URL is required." + exit 1 +fi + +# Step 5: Set up remote and push +echo "Setting up remote: $GITEA_URL" +if git remote | grep -q origin; then + echo "Updating existing remote URL..." + git remote set-url origin "$GITEA_URL" +else + git remote add origin "$GITEA_URL" +fi + +echo "Setting branch to main..." +git branch -M main + +echo "Pushing to Gitea (may prompt for credentials)..." +git push -u origin main + +echo "" +echo "=== Success! ===" +echo "Your $PROJECT_DIR project is now in Gitea at: $GITEA_URL" +echo "Check the repo in your browser for files and commits." +echo "If you have a CI/CD workflow (.gitea/workflows/), check the 'Actions' tab." +echo "Future updates: git add . && git commit -m 'Update' && git push" diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 00000000..5a881938 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.11-slim +WORKDIR /app +COPY . . +# Install backend dependencies declared in pyproject (no build-system specified) +RUN pip install --no-cache-dir fastapi uvicorn sqlalchemy psycopg2-binary geoalchemy2 ijson orjson python-dateutil +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 00000000..b93e7c1a --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,44 @@ +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} + ] diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 00000000..2c906fea --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "nocap-wya" +version = "1.1.0" +dependencies = [ + "fastapi", + "uvicorn", + "sqlalchemy", + "psycopg2-binary", + "geoalchemy2", + "ijson", + "orjson", + "python-dateutil" +] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..48bee52c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,79 @@ +version: "3.9" +name: nocapwya +services: + db: + image: postgis/postgis:16-3.5 + container_name: nocapwya-db + environment: + POSTGRES_DB: wya + POSTGRES_USER: wya + POSTGRES_PASSWORD: wya + volumes: + - pg_data:/var/lib/postgresql/data + - ./backend/app/init.sql:/docker-entrypoint-initdb.d/init.sql + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U wya -d wya"] + interval: 10s + timeout: 5s + retries: 10 + networks: + - nocap_net + + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: nocapwya-backend + command: uvicorn app.main:app --host 0.0.0.0 --port 8000 + working_dir: /app + ports: + - "8000:8000" + volumes: + - ./backend:/app + - ./data:/data + depends_on: + db: + condition: service_healthy + env_file: + - .env + environment: + - PYTHONUNBUFFERED=1 + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "curl -fsS http://localhost:8000/ || exit 1"] + interval: 10s + timeout: 3s + retries: 10 + networks: + - nocap_net + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: nocapwya-frontend + command: sh -c "npm run preview -- --host 0.0.0.0 --port 4173" + working_dir: /app + ports: + - "4173:4173" + volumes: + - ./frontend:/app + - ./data/tiles:/app/dist/tiles:ro + environment: + - VITE_API_URL=http://backend:8000 + depends_on: + backend: + condition: service_started + restart: unless-stopped + networks: + - nocap_net + +volumes: + pg_data: + +networks: + nocap_net: + driver: bridge + + diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 00000000..0745212a --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,5 @@ +FROM node:18-alpine +WORKDIR /app +COPY . . +RUN npm install && npm run build +CMD ["npm", "run", "preview"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 00000000..19cc5ce5 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + NoCap W.Y.A. + + +
+ + + \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 00000000..95e1bf1f --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/public/styles/basic.json b/frontend/public/styles/basic.json new file mode 100644 index 00000000..baad8b93 --- /dev/null +++ b/frontend/public/styles/basic.json @@ -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" + } + ] +} \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 00000000..bbb35997 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import MapWithPlayback from './components/map/MapWithPlayback'; + +function App() { + return ( +
+ +
+ ); +} + +export default App; \ No newline at end of file diff --git a/frontend/src/components/FilterBar.tsx b/frontend/src/components/FilterBar.tsx new file mode 100644 index 00000000..8aac8fbb --- /dev/null +++ b/frontend/src/components/FilterBar.tsx @@ -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(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 ( + <> +
+
+ {/* Top Row: Search + Filter Button */} +
+
+ + setFilter('search', e.target.value || null)} + className="pl-10 pr-10" + /> + {filters.search && ( + + )} +
+ +
+ + {/* Filter Chips */} + + {activeFilterCount > 0 && ( + + {filters.time && ( + removeFilter('time')} + /> + )} + {filters.transport && filters.transport.length > 0 && ( + t.charAt(0) + t.slice(1).toLowerCase()).join(', ')}`} + onRemove={() => removeFilter('transport')} + /> + )} + {filters.distance && ( + ${filters.distance}km`} + onRemove={() => removeFilter('distance')} + /> + )} + {filters.placeType && filters.placeType.length > 0 && ( + removeFilter('placeType')} + /> + )} + {filters.search && ( + setFilter('search', null)} + /> + )} + + + )} + +
+
+ + {/* Main Filter Popover */} + setOpenPopover(open ? 'main' : null)}> + +
+ + +
+ {/* Date Range */} +
+ + + + + + + setDateRange(range || { from: undefined, to: undefined })} + numberOfMonths={2} + /> +
+ + +
+
+
+
+ + + + {/* Transport Mode */} +
+ +
+ {(['WALKING', 'CYCLING', 'DRIVING', 'TRANSIT'] as const).map((mode) => { + const Icon = transportIcons[mode]; + const isActive = filters.transport?.includes(mode); + return ( + + ); + })} +
+
+ + + + {/* Distance */} +
+ +
+ setFilter('distance', value > 0 ? value : null)} + max={50} + step={1} + className="flex-1" + /> + + {filters.distance || 0} km + +
+
+ + + + {/* Place Types */} +
+ + + + + + + + + + No results found. + + {(['home', 'work', 'cafe', 'gym', 'restaurant', 'park', 'shop'] as const).map((type) => ( + { + const current = filters.placeType || []; + const updated = current.includes(type) + ? current.filter(t => t !== type) + : [...current, type]; + setFilter('placeType', updated.length > 0 ? updated : null); + }} + > +
+ {filters.placeType?.includes(type) &&
} +
+ {type.charAt(0).toUpperCase() + type.slice(1)} + + ))} + + + + + +
+
+ + + + ); +} + +// Filter Chip Component +function FilterChip({ label, onRemove }: { label: string; onRemove: () => void }) { + return ( + + {label} + + + ); +} diff --git a/frontend/src/components/map/AnimatedRouteLayer.tsx b/frontend/src/components/map/AnimatedRouteLayer.tsx new file mode 100644 index 00000000..75b0b2ab --- /dev/null +++ b/frontend/src/components/map/AnimatedRouteLayer.tsx @@ -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([]); + const animationRef = useRef(); + const startTimeRef = useRef(0); + const progressRef = useRef(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; +} diff --git a/frontend/src/components/map/HeatmapLayer.tsx b/frontend/src/components/map/HeatmapLayer.tsx new file mode 100644 index 00000000..86742347 --- /dev/null +++ b/frontend/src/components/map/HeatmapLayer.tsx @@ -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; // 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', + 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; +} diff --git a/frontend/src/components/map/MapWithPlayback.tsx b/frontend/src/components/map/MapWithPlayback.tsx new file mode 100644 index 00000000..7744b895 --- /dev/null +++ b/frontend/src/components/map/MapWithPlayback.tsx @@ -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(null); + const [isPlaying, setIsPlaying] = useState(false); + const [speed, setSpeed] = useState(1); + const [heatmapIntensity, setHeatmapIntensity] = useState(50); + const { filters } = useFilterStore(); + + return ( +
+ +
+ + setIsPlaying(false)} + /> + + + {isPlaying && ( + setIsPlaying(!isPlaying)} + onSpeedChange={setSpeed} + onReset={() => { + setIsPlaying(false); + // Reset animation + }} + /> + )} + + + {/* Heatmap Intensity Control */} +
+ + setHeatmapIntensity(+e.target.value)} + className="w-32 mt-1" + /> +
+
+
+ ); +} diff --git a/frontend/src/components/map/PlaybackController.tsx b/frontend/src/components/map/PlaybackController.tsx new file mode 100644 index 00000000..6ae90cbf --- /dev/null +++ b/frontend/src/components/map/PlaybackController.tsx @@ -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 ( + + + + + + + +
+ +
+ {currentTime ? format(currentTime, 'HH:mm:ss') : '--:--'} + {duration ? format(new Date(duration), 'HH:mm:ss') : '--:--'} +
+
+ + +
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/ui/badge.tsx b/frontend/src/components/ui/badge.tsx new file mode 100644 index 00000000..aa5f2dbf --- /dev/null +++ b/frontend/src/components/ui/badge.tsx @@ -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, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } \ No newline at end of file diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx new file mode 100644 index 00000000..80dd30b6 --- /dev/null +++ b/frontend/src/components/ui/button.tsx @@ -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, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return ( + + ) + } +) +Button.displayName = "Button" + +export { Button, buttonVariants } \ No newline at end of file diff --git a/frontend/src/components/ui/calendar.tsx b/frontend/src/components/ui/calendar.tsx new file mode 100644 index 00000000..5b61261d --- /dev/null +++ b/frontend/src/components/ui/calendar.tsx @@ -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 + +function Calendar({ + className, + classNames, + showOutsideDays = true, + ...props +}: CalendarProps) { + return ( + , + IconRight: ({ ...props }) => , + }} + {...props} + /> + ) +} +Calendar.displayName = "Calendar" + +export { Calendar } \ No newline at end of file diff --git a/frontend/src/components/ui/command.tsx b/frontend/src/components/ui/command.tsx new file mode 100644 index 00000000..6318fdf4 --- /dev/null +++ b/frontend/src/components/ui/command.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Command.displayName = CommandPrimitive.displayName + +const CommandInput = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( +
+ + +
+)) + +CommandInput.displayName = CommandPrimitive.Input.displayName + +const CommandList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandList.displayName = CommandPrimitive.List.displayName + +const CommandEmpty = React.forwardRef, React.ComponentPropsWithoutRef>( + (props, ref) => ( + + ) +) + +CommandEmpty.displayName = CommandPrimitive.Empty.displayName + +const CommandGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandGroup.displayName = CommandPrimitive.Group.displayName + +const CommandSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandSeparator.displayName = CommandPrimitive.Separator.displayName + +const CommandItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandItem.displayName = CommandPrimitive.Item.displayName + +export { + Command, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandSeparator, +} diff --git a/frontend/src/components/ui/input.tsx b/frontend/src/components/ui/input.tsx new file mode 100644 index 00000000..522915bf --- /dev/null +++ b/frontend/src/components/ui/input.tsx @@ -0,0 +1,25 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +export interface InputProps + extends React.InputHTMLAttributes {} + +const Input = React.forwardRef( + ({ className, type, ...props }, ref) => { + return ( + + ) + } +) +Input.displayName = "Input" + +export { Input } \ No newline at end of file diff --git a/frontend/src/components/ui/label.tsx b/frontend/src/components/ui/label.tsx new file mode 100644 index 00000000..50c0caaa --- /dev/null +++ b/frontend/src/components/ui/label.tsx @@ -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, + VariantProps {} + +const Label = React.forwardRef, LabelProps>( + ({ className, ...props }, ref) => ( + + ) +) +Label.displayName = LabelPrimitive.Root.displayName + +export { Label } \ No newline at end of file diff --git a/frontend/src/components/ui/popover.tsx b/frontend/src/components/ui/popover.tsx new file mode 100644 index 00000000..04f1a89a --- /dev/null +++ b/frontend/src/components/ui/popover.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + + + +)) +PopoverContent.displayName = PopoverPrimitive.Content.displayName + +export { Popover, PopoverTrigger, PopoverContent } \ No newline at end of file diff --git a/frontend/src/components/ui/separator.tsx b/frontend/src/components/ui/separator.tsx new file mode 100644 index 00000000..35e06136 --- /dev/null +++ b/frontend/src/components/ui/separator.tsx @@ -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, + React.ComponentPropsWithoutRef +>( + ( + { className, orientation = "horizontal", decorative = true, ...props }, + ref + ) => ( + + ) +) +Separator.displayName = SeparatorPrimitive.Root.displayName + +export { Separator } \ No newline at end of file diff --git a/frontend/src/components/ui/slider.tsx b/frontend/src/components/ui/slider.tsx new file mode 100644 index 00000000..7c36ab17 --- /dev/null +++ b/frontend/src/components/ui/slider.tsx @@ -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.ComponentPropsWithoutRef>( + ({ className, ...props }, ref) => ( + + + + + + + ) +) +Slider.displayName = SliderPrimitive.Root.displayName + +export { Slider } \ No newline at end of file diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 00000000..87dcd547 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,5 @@ +@import 'mapbox-gl/dist/mapbox-gl.css'; + +@tailwind base; +@tailwind components; +@tailwind utilities; \ No newline at end of file diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts new file mode 100644 index 00000000..04b43bf9 --- /dev/null +++ b/frontend/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} \ No newline at end of file diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 00000000..98a4d609 --- /dev/null +++ b/frontend/src/main.tsx @@ -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( + + + , +); \ No newline at end of file diff --git a/frontend/src/stores/useFilterStore.ts b/frontend/src/stores/useFilterStore.ts new file mode 100644 index 00000000..b8c4e4af --- /dev/null +++ b/frontend/src/stores/useFilterStore.ts @@ -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: (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()( + 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', + } + ) +); diff --git a/frontend/src/utils/animation.ts b/frontend/src/utils/animation.ts new file mode 100644 index 00000000..126f7fa2 --- /dev/null +++ b/frontend/src/utils/animation.ts @@ -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) }; +} diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts new file mode 100644 index 00000000..32fed7d1 --- /dev/null +++ b/frontend/tailwind.config.ts @@ -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'), + ], +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 00000000..3889d92b --- /dev/null +++ b/frontend/vite.config.ts @@ -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 }, +}) diff --git a/nocap.code-workspace b/nocap.code-workspace new file mode 100644 index 00000000..2905d9f5 --- /dev/null +++ b/nocap.code-workspace @@ -0,0 +1,11 @@ +{ + "folders": [ + { + "path": "../../../Documents/PlatformIO/Projects/esp-01 wifi uart" + }, + { + "path": "../../../Documents/PlatformIO/Projects/Raspberry Pi Pico Spot Welder Timer" + } + ], + "settings": {} +} \ No newline at end of file