nocapwya
This commit is contained in:
5
.env
Normal file
5
.env
Normal file
@@ -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!
|
||||
104
New Folder With Items/Untitled 24.sh
Normal file
104
New Folder With Items/Untitled 24.sh
Normal file
@@ -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
|
||||
62
New Folder With Items/Untitled 25.yml
Normal file
62
New Folder With Items/Untitled 25.yml
Normal file
@@ -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
|
||||
71
New Folder With Items/docker compose.yml
Normal file
71
New Folder With Items/docker compose.yml
Normal file
@@ -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
|
||||
65
New Folder With Items/docker-compose.yml.broken
Normal file
65
New Folder With Items/docker-compose.yml.broken
Normal file
@@ -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
|
||||
65
New Folder With Items/docker-compose4.yml
Normal file
65
New Folder With Items/docker-compose4.yml
Normal file
@@ -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
|
||||
97
New Folder With Items/fix-deployment.sh
Executable file
97
New Folder With Items/fix-deployment.sh
Executable file
@@ -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"
|
||||
74
New Folder With Items/fix-deployment2.sh
Executable file
74
New Folder With Items/fix-deployment2.sh
Executable file
@@ -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
|
||||
944
New Folder With Items/install.sh
Executable file
944
New Folder With Items/install.sh
Executable file
@@ -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<string | null>(null);
|
||||
const [dateRange, setDateRange] = useState<{ from: Date | undefined; to: Date | undefined }>({ // Corrected: removed 'incessantly'
|
||||
from: filters.time?.start ? new Date(filters.time.start) : undefined,
|
||||
to: filters.time?.end ? new Date(filters.time.end) : undefined,
|
||||
});
|
||||
|
||||
// Sync local date range with store
|
||||
useEffect(() => {
|
||||
if (filters.time?.start && filters.time?.end) {
|
||||
setDateRange({
|
||||
from: new Date(filters.time.start),
|
||||
to: new Date(filters.time.end),
|
||||
});
|
||||
}
|
||||
}, [filters.time]);
|
||||
|
||||
const applyDateRange = () => {
|
||||
if (dateRange.from && dateRange.to) {
|
||||
setFilter('time', {
|
||||
start: dateRange.from.toISOString().split('T')[0],
|
||||
end: dateRange.to.toISOString().split('T')[0],
|
||||
});
|
||||
} else if (dateRange.from) {
|
||||
setFilter('time', { start: dateRange.from.toISOString().split('T')[0] });
|
||||
}
|
||||
setOpenPopover(null);
|
||||
};
|
||||
|
||||
const activeFilterCount = Object.keys(filters).filter(key =>
|
||||
filters[key as keyof FilterState] !== null &&
|
||||
key !== 'search'
|
||||
).length + (filters.search ? 1 : 0);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full bg-background border-b">
|
||||
<div className="container mx-auto px-4 py-3">
|
||||
{/* Top Row: Search + Filter Button */}
|
||||
<div className="flex gap-2 mb-3">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
|
||||
<Input
|
||||
placeholder="Search places, notes, or keywords..."
|
||||
value={filters.search || ''}
|
||||
onChange={(e) => setFilter('search', e.target.value || null)}
|
||||
className="pl-10 pr-10"
|
||||
/>
|
||||
{filters.search && (
|
||||
<button
|
||||
onClick={() => setFilter('search', null)}
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2"
|
||||
>
|
||||
<X className="h-4 w-4 text-muted-foreground hover:text-foreground" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
onClick={() => setOpenPopover(openPopover === 'main' ? null : 'main')}
|
||||
className="relative"
|
||||
>
|
||||
<Filter className="h-4 w-4 mr-2" />
|
||||
Filters
|
||||
{activeFilterCount > 0 && (
|
||||
<Badge variant="secondary" className="absolute -top-2 -right-2 h-5 w-5 p-0 flex items-center justify-center text-xs">
|
||||
{activeFilterCount}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filter Chips */}
|
||||
<AnimatePresence>
|
||||
{activeFilterCount > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="flex flex-wrap gap-2 items-center"
|
||||
>
|
||||
{filters.time && (
|
||||
<FilterChip
|
||||
label={`Date: ${filters.time.start} → ${filters.time.end || 'Now'}`}
|
||||
onRemove={() => removeFilter('time')}
|
||||
/>
|
||||
)}
|
||||
{filters.transport && filters.transport.length > 0 && (
|
||||
<FilterChip
|
||||
label={`Transport: ${filters.transport.map(t => t.charAt(0) + t.slice(1).toLowerCase()).join(', ')}`}
|
||||
onRemove={() => removeFilter('transport')}
|
||||
/>
|
||||
)}
|
||||
{filters.distance && (
|
||||
<FilterChip
|
||||
label={`Distance: >${filters.distance}km`}
|
||||
onRemove={() => removeFilter('distance')}
|
||||
/>
|
||||
)}
|
||||
{filters.placeType && filters.placeType.length > 0 && (
|
||||
<FilterChip
|
||||
label={`Places: ${filters.placeType.join(', ')}`}
|
||||
onRemove={() => removeFilter('placeType')}
|
||||
/>
|
||||
)}
|
||||
{filters.search && (
|
||||
<FilterChip
|
||||
label={`"${filters.search}" `}
|
||||
onRemove={() => setFilter('search', null)}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearFilters}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
Clear all
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Filter Popover */}
|
||||
<Popover open={openPopover === 'main'} onOpenChange={(open) => setOpenPopover(open ? 'main' : null)}>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="hidden" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-96 p-0" align="start">
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Date Range */}
|
||||
<div>
|
||||
<Label className="flex items-center gap-2 mb-2">
|
||||
<Calendar className="h-4 w-4" />
|
||||
Date Range
|
||||
</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="w-full justify-start text-left font-normal">
|
||||
{dateRange.from ? (
|
||||
dateRange.to ? (
|
||||
<>
|
||||
{format(dateRange.from, 'LLL dd, y')} – {format(dateRange.to, 'LLL dd, y')}
|
||||
</>
|
||||
) : (
|
||||
format(dateRange.from, 'LLL dd, y')
|
||||
)
|
||||
) : (
|
||||
<span>Pick a date range</span>
|
||||
)}
|
||||
<ChevronDown className="ml-auto h-4 w-4 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<CalendarComponent
|
||||
mode="range"
|
||||
selected={dateRange}
|
||||
onSelect={(range: any) => setDateRange(range || { from: undefined, to: undefined })}
|
||||
numberOfMonths={2}
|
||||
/>
|
||||
<div className="p-3 border-t flex gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => setDateRange({ from: undefined, to: undefined }) }>
|
||||
Clear
|
||||
</Button>
|
||||
<Button size="sm" onClick={applyDateRange}>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Transport Mode */}
|
||||
<div>
|
||||
<Label className="flex items-center gap-2 mb-2">
|
||||
<Car className="h-4 w-4" />
|
||||
Transport Mode
|
||||
</Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(['WALKING', 'CYCLING', 'DRIVING', 'TRANSIT'] as const).map((mode) => {
|
||||
const Icon = transportIcons[mode];
|
||||
const isActive = filters.transport?.includes(mode);
|
||||
return (
|
||||
<Button
|
||||
key={mode}
|
||||
variant={isActive ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const current = filters.transport || [];
|
||||
const updated = isActive
|
||||
? current.filter(m => m !== mode)
|
||||
: [...current, mode];
|
||||
setFilter('transport', updated.length > 0 ? updated : null);
|
||||
}}
|
||||
className="justify-start"
|
||||
>
|
||||
<Icon className="h-4 w-4 mr-2" />
|
||||
{mode.charAt(0) + mode.slice(1).toLowerCase()}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Distance */}
|
||||
<div>
|
||||
<Label className="flex items-center gap-2 mb-2">
|
||||
<Ruler className="h-4 w-4" />
|
||||
Minimum Distance (km)
|
||||
</Label>
|
||||
<div className="flex items-center gap-4">
|
||||
<Slider
|
||||
value={[filters.distance || 0]}
|
||||
onValueChange={([value]) => setFilter('distance', value > 0 ? value : null)}
|
||||
max={50}
|
||||
step={1}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-sm font-medium w-12 text-right">
|
||||
{filters.distance || 0} km
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Place Types */}
|
||||
<div>
|
||||
<Label className="flex items-center gap-2 mb-2">
|
||||
<MapPin className="h-4 w-4" />
|
||||
Place Types
|
||||
</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="w-full justify-start">
|
||||
{filters.placeType && filters.placeType.length > 0
|
||||
? `${filters.placeType.length} selected`
|
||||
: 'Select place types'}
|
||||
<ChevronDown className="ml-auto h-4 w-4 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search places..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{(['home', 'work', 'cafe', 'gym', 'restaurant', 'park', 'shop'] as const).map((type) => (
|
||||
<CommandItem
|
||||
key={type}
|
||||
onSelect={() => {
|
||||
const current = filters.placeType || [];
|
||||
const updated = current.includes(type)
|
||||
? current.filter(t => t !== type)
|
||||
: [...current, type];
|
||||
setFilter('placeType', updated.length > 0 ? updated : null);
|
||||
}}
|
||||
>
|
||||
<div className={cn(
|
||||
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border",
|
||||
filters.placeType?.includes(type)
|
||||
? "bg-primary text-primary-foreground border-primary"
|
||||
: "border-muted-foreground"
|
||||
)}>
|
||||
{filters.placeType?.includes(type) && <div className="h-2 w-2 bg-primary-foreground rounded-full" />}
|
||||
</div>
|
||||
{type.charAt(0).toUpperCase() + type.slice(1)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Filter Chip Component
|
||||
function FilterChip({ label, onRemove }: { label: string; onRemove: () => void }) {
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.8, opacity: 0 }}
|
||||
className="inline-flex items-center gap-1 bg-secondary text-secondary-foreground rounded-full px-3 py-1 text-xs font-medium"
|
||||
>
|
||||
{label}
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="ml-1 rounded-full hover:bg-secondary-foreground/20 p-0.5"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
EOF
|
||||
|
||||
cat > frontend/src/components/map/AnimatedRouteLayer.tsx <<'EOF'
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useMap } from 'react-map-gl';
|
||||
import { useFilterStore } from '@/stores/useFilterStore';
|
||||
import { interpolatePath, PathPoint } from '@/utils/animation';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
interface AnimatedRouteLayerProps {
|
||||
isPlaying: boolean;
|
||||
speed: number;
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
export default function AnimatedRouteLayer({ isPlaying, speed, onComplete }: AnimatedRouteLayerProps) {
|
||||
const { current: map } = useMap();
|
||||
const { filters } = useFilterStore();
|
||||
const [path, setPath] = useState<PathPoint[]>([]);
|
||||
const animationRef = useRef<number>();
|
||||
const startTimeRef = useRef<number>(0);
|
||||
const progressRef = useRef<number>(0);
|
||||
|
||||
// Fetch filtered trips
|
||||
useEffect(() => {
|
||||
const fetchTrips = async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.time?.start) params.append('start', filters.time.start);
|
||||
if (filters.time?.end) params.append('end', filters.time.end);
|
||||
if (filters.transport) params.append('mode', filters.transport.join(','));
|
||||
|
||||
const res = await fetch(`/api/trips?${params}`);
|
||||
const data = await res.json();
|
||||
const points: PathPoint[] = data.features.flatMap((f: any) =>
|
||||
f.geometry.coordinates.map((coord: [number, number], i: number) => ({
|
||||
lng: coord[0],
|
||||
lat: coord[1],
|
||||
timestamp: f.properties.timestamps[i],
|
||||
speed: f.properties.speeds[i] || 0,
|
||||
}))
|
||||
);
|
||||
setPath(points.sort((a, b) => a.timestamp - b.timestamp));
|
||||
progressRef.current = 0;
|
||||
};
|
||||
fetchTrips();
|
||||
}, [filters]);
|
||||
|
||||
// Animation loop
|
||||
useEffect(() => {
|
||||
if (!map || path.length === 0) return;
|
||||
|
||||
const sourceId = 'animated-route';
|
||||
const layerId = 'route-glow';
|
||||
const pointLayerId = 'route-point';
|
||||
|
||||
if (!map.getSource(sourceId)) {
|
||||
map.addSource(sourceId, {
|
||||
type: 'geojson',
|
||||
data: { type: 'FeatureCollection', features: [] },
|
||||
});
|
||||
|
||||
// Glowing line
|
||||
map.addLayer({
|
||||
id: layerId,
|
||||
type: 'line',
|
||||
source: sourceId,
|
||||
paint: {
|
||||
'line-color': '#3b82f6',
|
||||
'line-width': ['interpolate', ['linear'], ['zoom'], 10, 4, 18, 12],
|
||||
'line-opacity': 0.9,
|
||||
'line-blur': 8,
|
||||
'line-gradient': [
|
||||
'interpolate',
|
||||
['linear'],
|
||||
['line-progress'],
|
||||
0, 'rgba(59, 130, 246, 0)',
|
||||
0.1, 'rgba(59, 130, 246, 0.5)',
|
||||
1, 'rgba(59, 130, 246, 1)',
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// Moving dot
|
||||
map.addLayer({
|
||||
id: pointLayerId,
|
||||
type: 'circle',
|
||||
source: sourceId,
|
||||
paint: {
|
||||
'circle-radius': 10,
|
||||
'circle-color': '#fff',
|
||||
'circle-stroke-width': 3,
|
||||
'circle-stroke-color': '#3b82f6',
|
||||
'circle-opacity': ['interpolate', ['linear'], ['get', 'progress'], 0, 0, 1, 1],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const animate = (timestamp: number) => {
|
||||
if (!startTimeRef.current) startTimeRef.current = timestamp;
|
||||
const elapsed = (timestamp - startTimeRef.current) * speed;
|
||||
|
||||
const totalDuration = path[path.length - 1].timestamp - path[0].timestamp;
|
||||
progressRef.current = Math.min(elapsed / totalDuration, 1);
|
||||
|
||||
if (progressRef.current >= 1) {
|
||||
onComplete?.();
|
||||
return;
|
||||
}
|
||||
|
||||
const interpolated = interpolatePath(path, progressRef.current);
|
||||
const geojson = {
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
{
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'LineString',
|
||||
coordinates: interpolated.seen.map(p => [p.lng, p.lat]),
|
||||
},
|
||||
properties: { progress: progressRef.current },
|
||||
},
|
||||
{
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: [interpolated.current.lng, interpolated.current.lat],
|
||||
},
|
||||
properties: { progress: 1 },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
(map.getSource(sourceId) as any)?.setData(geojson);
|
||||
|
||||
if (isPlaying) {
|
||||
animationRef.current = requestAnimationFrame(animate);
|
||||
}
|
||||
};
|
||||
|
||||
if (isPlaying) {
|
||||
animationRef.current = requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (animationRef.current) cancelAnimationFrame(animationRef.current);
|
||||
startTimeRef.current = 0;
|
||||
};
|
||||
}, [map, path, isPlaying, speed, onComplete]);
|
||||
|
||||
return null;
|
||||
}
|
||||
EOF
|
||||
|
||||
cat > frontend/src/components/map/HeatmapLayer.tsx <<'EOF'
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useMap } from 'react-map-gl';
|
||||
import { useFilterStore } from '@/stores/useFilterStore';
|
||||
|
||||
interface HeatmapLayerProps {
|
||||
intensity: number; // 0–100
|
||||
}
|
||||
|
||||
export default function HeatmapLayer({ intensity }: HeatmapLayerProps) {
|
||||
const { current: map } = useMap();
|
||||
const { filters } = useFilterStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (!map) return;
|
||||
|
||||
const sourceId = 'heatmap-data';
|
||||
const layerId = 'visit-heatmap';
|
||||
|
||||
const fetchVisits = async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.time?.start) params.append('start', filters.time.start);
|
||||
if (filters.time?.end) params.append('end', filters.time.end);
|
||||
|
||||
const res = await fetch(`/api/visits?${params}`);
|
||||
const data = await res.json();
|
||||
|
||||
const features = data.map((v: any) => ({
|
||||
type: 'Feature' as const,
|
||||
geometry: {
|
||||
type: 'Point' as const,
|
||||
coordinates: [v.lng, v.lat],
|
||||
],
|
||||
properties: { weight: v.duration / 3600 }, // hours
|
||||
}));
|
||||
|
||||
if (map.getSource(sourceId)) {
|
||||
(map.getSource(sourceId) as any).setData({
|
||||
type: 'FeatureCollection',
|
||||
features,
|
||||
});
|
||||
} else {
|
||||
map.addSource(sourceId, {
|
||||
type: 'geojson',
|
||||
data: { type: 'FeatureCollection', features },
|
||||
});
|
||||
|
||||
map.addLayer({
|
||||
id: layerId,
|
||||
type: 'heatmap',
|
||||
source: sourceId,
|
||||
paint: {
|
||||
'heatmap-weight': ['interpolate', ['linear'], ['get', 'weight'], 0, 0, 10, 1],
|
||||
'heatmap-intensity': intensity / 100,
|
||||
'heatmap-color': [
|
||||
'interpolate',
|
||||
['linear'],
|
||||
['heatmap-density'],
|
||||
0, 'rgba(0, 0, 255, 0)',
|
||||
0.2, 'royalblue',
|
||||
0.4, 'cyan',
|
||||
0.6, 'lime',
|
||||
0.8, 'yellow',
|
||||
1, 'red',
|
||||
],
|
||||
'heatmap-radius': ['interpolate', ['linear'], ['zoom'], 0, 2, 18, 40],
|
||||
'heatmap-opacity': 0.8,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
fetchVisits();
|
||||
|
||||
return () => {
|
||||
if (map.getLayer(layerId)) map.removeLayer(layerId);
|
||||
if (map.getSource(sourceId)) map.removeSource(sourceId);
|
||||
};
|
||||
}, [map, filters, intensity]);
|
||||
|
||||
return null;
|
||||
}
|
||||
EOF
|
||||
|
||||
cat > frontend/src/components/map/MapWithPlayback.tsx <<'EOF'
|
||||
'use client';
|
||||
|
||||
import Map, { MapRef } from 'react-map-gl';
|
||||
import { useRef, useState } from 'react';
|
||||
import AnimatedRouteLayer from './AnimatedRouteLayer';
|
||||
import HeatmapLayer from './HeatmapLayer';
|
||||
import PlaybackController from './PlaybackController';
|
||||
import FilterBar from '@/components/FilterBar';
|
||||
import { useFilterStore } from '@/stores/useFilterStore';
|
||||
|
||||
const MAP_STYLE = '/styles/basic.json'; // self-hosted
|
||||
|
||||
export default function MapWithPlayback() {
|
||||
const mapRef = useRef<MapRef>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [speed, setSpeed] = useState(1);
|
||||
const [heatmapIntensity, setHeatmapIntensity] = useState(50);
|
||||
const { filters } = useFilterStore();
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col">
|
||||
<FilterBar />
|
||||
<div className="flex-1 relative">
|
||||
<Map
|
||||
ref={mapRef}
|
||||
initialViewState={{
|
||||
longitude: 24.9384,
|
||||
latitude: 60.1699,
|
||||
zoom: 10,
|
||||
}}
|
||||
mapStyle={MAP_STYLE}
|
||||
interactiveLayerIds={[]}
|
||||
>
|
||||
<AnimatedRouteLayer
|
||||
isPlaying={isPlaying}
|
||||
speed={speed}
|
||||
onComplete={() => setIsPlaying(false)}
|
||||
/>
|
||||
<HeatmapLayer intensity={heatmapIntensity} />
|
||||
|
||||
{isPlaying && (
|
||||
<PlaybackController
|
||||
isPlaying={isPlaying}
|
||||
onPlayPause={() => setIsPlaying(!isPlaying)}
|
||||
onSpeedChange={setSpeed}
|
||||
onReset={() => {
|
||||
setIsPlaying(false);
|
||||
// Reset animation
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Map>
|
||||
|
||||
{/* Heatmap Intensity Control */}
|
||||
<div className="absolute top-4 right-4 bg-background/90 backdrop-blur-sm rounded-lg p-3 shadow-lg">
|
||||
<label className="text-xs font-medium">Heatmap Intensity</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={heatmapIntensity}
|
||||
onChange={(e) => setHeatmapIntensity(+e.target.value)}
|
||||
className="w-32 mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
EOF
|
||||
|
||||
cat > frontend/src/stores/useFilterStore.ts <<'EOF'
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
export type TransportMode = 'WALKING' | 'CYCLING' | 'DRIVING' | 'TRANSIT';
|
||||
export type PlaceType = 'home' | 'work' | 'cafe' | 'gym' | 'restaurant' | 'park' | 'shop';
|
||||
|
||||
export interface FilterState {
|
||||
search: string | null;
|
||||
time: { start: string; end?: string } | null;
|
||||
transport: TransportMode[] | null;
|
||||
distance: number | null;
|
||||
placeType: PlaceType[] | null;
|
||||
}
|
||||
|
||||
interface FilterStore extends FilterState {
|
||||
setFilter: <K extends keyof FilterState>(key: K, value: FilterState[K]) => void;
|
||||
removeFilter: (key: keyof FilterState) => void;
|
||||
clearFilters: () => void;
|
||||
}
|
||||
|
||||
const initialState: FilterState = {
|
||||
search: null,
|
||||
time: null,
|
||||
transport: null,
|
||||
distance: null,
|
||||
placeType: null,
|
||||
};
|
||||
|
||||
export const useFilterStore = create<FilterStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
...initialState,
|
||||
setFilter: (key, value) =>
|
||||
set((state) => ({
|
||||
...state,
|
||||
[key]: value,
|
||||
})),
|
||||
removeFilter: (key) =>
|
||||
set((state) => ({
|
||||
...state,
|
||||
[key]: initialState[key],
|
||||
})),
|
||||
clearFilters: () => set(initialState),
|
||||
}),
|
||||
{
|
||||
name: 'reitti-filters',
|
||||
}
|
||||
)
|
||||
);
|
||||
EOF
|
||||
|
||||
cat > frontend/src/utils/animation.ts <<'EOF'
|
||||
export interface PathPoint {
|
||||
lng: number;
|
||||
lat: number;
|
||||
timestamp: number;
|
||||
speed?: number;
|
||||
}
|
||||
|
||||
export interface InterpolatedPath {
|
||||
current: PathPoint;
|
||||
seen: PathPoint[];
|
||||
}
|
||||
|
||||
export function interpolatePath(path: PathPoint[], progress: number): InterpolatedPath {
|
||||
if (path.length === 0) return { current: {lng: 0, lat: 0, timestamp: 0}, seen: [] };
|
||||
if (progress >= 1) return { current: path[path.length - 1], seen: path };
|
||||
|
||||
const totalTime = path[path.length - 1].timestamp - path[0].timestamp;
|
||||
const targetTime = path[0].timestamp + totalTime * progress;
|
||||
|
||||
let i = 0;
|
||||
while (i < path.length - 1 && path[i + 1].timestamp < targetTime) i++;
|
||||
|
||||
const a = path[i];
|
||||
const b = path[i + 1];
|
||||
|
||||
// If we are at the last point or beyond, return the last point
|
||||
if (!b) return { current: a, seen: path.slice(0, i + 1) };
|
||||
|
||||
const t = (targetTime - a.timestamp) / (b.timestamp - a.timestamp);
|
||||
|
||||
const current = {
|
||||
lng: a.lng + (b.lng - a.lng) * t,
|
||||
lat: a.lat + (b.lat - a.lat) * t,
|
||||
timestamp: targetTime,
|
||||
};
|
||||
|
||||
return { current, seen: path.slice(0, i + 1) };
|
||||
}
|
||||
EOF
|
||||
|
||||
echo "No cap. Just facts. Ready."
|
||||
echo "Run: docker compose up -d"
|
||||
echo "Open: http://localhost:3000"
|
||||
77
Push to Gitea for Memories.sh
Executable file
77
Push to Gitea for Memories.sh
Executable file
@@ -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"
|
||||
6
backend/Dockerfile
Normal file
6
backend/Dockerfile
Normal file
@@ -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"]
|
||||
44
backend/app/main.py
Normal file
44
backend/app/main.py
Normal file
@@ -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}
|
||||
]
|
||||
13
backend/pyproject.toml
Normal file
13
backend/pyproject.toml
Normal file
@@ -0,0 +1,13 @@
|
||||
[project]
|
||||
name = "nocap-wya"
|
||||
version = "1.1.0"
|
||||
dependencies = [
|
||||
"fastapi",
|
||||
"uvicorn",
|
||||
"sqlalchemy",
|
||||
"psycopg2-binary",
|
||||
"geoalchemy2",
|
||||
"ijson",
|
||||
"orjson",
|
||||
"python-dateutil"
|
||||
]
|
||||
79
docker-compose.yml
Normal file
79
docker-compose.yml
Normal file
@@ -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
|
||||
|
||||
|
||||
5
frontend/Dockerfile
Normal file
5
frontend/Dockerfile
Normal 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
13
frontend/index.html
Normal 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
24
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
18
frontend/public/styles/basic.json
Normal file
18
frontend/public/styles/basic.json
Normal 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
12
frontend/src/App.tsx
Normal 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;
|
||||
348
frontend/src/components/FilterBar.tsx
Normal file
348
frontend/src/components/FilterBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
150
frontend/src/components/map/AnimatedRouteLayer.tsx
Normal file
150
frontend/src/components/map/AnimatedRouteLayer.tsx
Normal 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;
|
||||
}
|
||||
83
frontend/src/components/map/HeatmapLayer.tsx
Normal file
83
frontend/src/components/map/HeatmapLayer.tsx
Normal 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; // 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;
|
||||
}
|
||||
69
frontend/src/components/map/MapWithPlayback.tsx
Normal file
69
frontend/src/components/map/MapWithPlayback.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
74
frontend/src/components/map/PlaybackController.tsx
Normal file
74
frontend/src/components/map/PlaybackController.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
frontend/src/components/ui/badge.tsx
Normal file
36
frontend/src/components/ui/badge.tsx
Normal 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 }
|
||||
55
frontend/src/components/ui/button.tsx
Normal file
55
frontend/src/components/ui/button.tsx
Normal 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 }
|
||||
62
frontend/src/components/ui/calendar.tsx
Normal file
62
frontend/src/components/ui/calendar.tsx
Normal 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 }
|
||||
119
frontend/src/components/ui/command.tsx
Normal file
119
frontend/src/components/ui/command.tsx
Normal 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,
|
||||
}
|
||||
25
frontend/src/components/ui/input.tsx
Normal file
25
frontend/src/components/ui/input.tsx
Normal 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 }
|
||||
26
frontend/src/components/ui/label.tsx
Normal file
26
frontend/src/components/ui/label.tsx
Normal 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 }
|
||||
29
frontend/src/components/ui/popover.tsx
Normal file
29
frontend/src/components/ui/popover.tsx
Normal 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 }
|
||||
29
frontend/src/components/ui/separator.tsx
Normal file
29
frontend/src/components/ui/separator.tsx
Normal 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 }
|
||||
25
frontend/src/components/ui/slider.tsx
Normal file
25
frontend/src/components/ui/slider.tsx
Normal 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
5
frontend/src/index.css
Normal file
@@ -0,0 +1,5 @@
|
||||
@import 'mapbox-gl/dist/mapbox-gl.css';
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
6
frontend/src/lib/utils.ts
Normal file
6
frontend/src/lib/utils.ts
Normal 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
10
frontend/src/main.tsx
Normal 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>,
|
||||
);
|
||||
49
frontend/src/stores/useFilterStore.ts
Normal file
49
frontend/src/stores/useFilterStore.ts
Normal 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',
|
||||
}
|
||||
)
|
||||
);
|
||||
38
frontend/src/utils/animation.ts
Normal file
38
frontend/src/utils/animation.ts
Normal 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) };
|
||||
}
|
||||
22
frontend/tailwind.config.ts
Normal file
22
frontend/tailwind.config.ts
Normal 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
13
frontend/vite.config.ts
Normal 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 },
|
||||
})
|
||||
11
nocap.code-workspace
Normal file
11
nocap.code-workspace
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": "../../../Documents/PlatformIO/Projects/esp-01 wifi uart"
|
||||
},
|
||||
{
|
||||
"path": "../../../Documents/PlatformIO/Projects/Raspberry Pi Pico Spot Welder Timer"
|
||||
}
|
||||
],
|
||||
"settings": {}
|
||||
}
|
||||
Reference in New Issue
Block a user