Initial commit: Complete Memories app with Leaflet map, backend fixes, enhancements, and docs
This commit is contained in:
57
Push to Gitea for Memories.sh
Executable file
57
Push to Gitea for Memories.sh
Executable file
@@ -0,0 +1,57 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Memories Project: Automate Git Init, Commit, and Push to Gitea
|
||||
# Run from /Users/maq/memories directory
|
||||
# Usage: bash setup-git.sh
|
||||
|
||||
set -e # Exit on any error
|
||||
|
||||
echo "=== Memories Project Git Automation ==="
|
||||
echo "Current directory: $(pwd)"
|
||||
if [ "$(basename $(pwd))" != "memories" ]; then
|
||||
echo "Error: Run this from /Users/maq/memories"
|
||||
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 and commit new files (docs and workflow)
|
||||
echo "Adding and committing documentation files..."
|
||||
git add .gitignore README.md .gitea/workflows/deploy.yml
|
||||
git commit -m "Add .gitignore, README.md, and Gitea CI/CD workflow" || echo "No changes to commit (files may already be added)"
|
||||
|
||||
# Step 3: Add and commit the full project
|
||||
echo "Adding and committing full project files..."
|
||||
git add .
|
||||
git commit -m "Initial commit: Complete Memories app with Leaflet map, backend fixes, enhancements, and docs" || echo "Full project already committed"
|
||||
|
||||
# Step 4: Prompt for Gitea repo URL
|
||||
echo ""
|
||||
echo "Enter your Gitea repository URL (e.g., http://localhost:3000/maq/memories.git or git@your-server:maq/memories.git):"
|
||||
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"
|
||||
git remote add origin $GITEA_URL 2>/dev/null || git remote set-url origin $GITEA_URL
|
||||
|
||||
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 Memories project is now in Gitea at: $GITEA_URL"
|
||||
echo "Check the 'Actions' tab for CI/CD workflow results."
|
||||
echo "Next: Visit the repo in your browser to verify."
|
||||
13
backend/Dockerfile
Normal file
13
backend/Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
||||
FROM python:3.9-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
146
backend/analysis.py
Normal file
146
backend/analysis.py
Normal file
@@ -0,0 +1,146 @@
|
||||
import datetime
|
||||
import numpy as np
|
||||
from geoalchemy2.elements import WKTElement
|
||||
from models import Location, Visit, Trip
|
||||
|
||||
class LocationAnalyzer:
|
||||
def __init__(self, visit_radius_meters: float = 50.0, min_visit_duration_minutes: int = 10):
|
||||
self.visit_radius_meters = visit_radius_meters
|
||||
self.min_visit_duration_minutes = min_visit_duration_minutes
|
||||
|
||||
def detect_visits(self, locations: list[Location]) -> list[Visit]:
|
||||
visits = []
|
||||
if not locations:
|
||||
return visits
|
||||
|
||||
current_visit_locations = []
|
||||
for i in range(len(locations)):
|
||||
if not current_visit_locations:
|
||||
current_visit_locations.append(locations[i])
|
||||
else:
|
||||
# Calculate distance from the first location of the current potential visit
|
||||
first_loc = current_visit_locations[0]
|
||||
dist = self._haversine_distance(first_loc.latitude, first_loc.longitude,
|
||||
locations[i].latitude, locations[i].longitude)
|
||||
|
||||
if dist <= self.visit_radius_meters:
|
||||
current_visit_locations.append(locations[i])
|
||||
else:
|
||||
# Check if the accumulated locations form a valid visit
|
||||
if len(current_visit_locations) > 1:
|
||||
duration = (current_visit_locations[-1].timestamp - current_visit_locations[0].timestamp).total_seconds() / 60
|
||||
if duration >= self.min_visit_duration_minutes:
|
||||
# Create Visit object with computed fields
|
||||
start_time = current_visit_locations[0].timestamp
|
||||
end_time = current_visit_locations[-1].timestamp
|
||||
|
||||
# Compute centroid
|
||||
lats = np.array([loc.latitude for loc in current_visit_locations])
|
||||
lons = np.array([loc.longitude for loc in current_visit_locations])
|
||||
centroid_lat = np.mean(lats)
|
||||
centroid_lon = np.mean(lons)
|
||||
centroid_point = WKTElement(f'POINT({centroid_lon} {centroid_lat})', srid=4326)
|
||||
|
||||
visit = Visit(
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
centroid_geom=centroid_point
|
||||
)
|
||||
visits.append(visit)
|
||||
|
||||
current_visit_locations = [locations[i]] # Start a new potential visit
|
||||
|
||||
# Check for a visit at the very end of the locations list
|
||||
if len(current_visit_locations) > 1:
|
||||
duration = (current_visit_locations[-1].timestamp - current_visit_locations[0].timestamp).total_seconds() / 60
|
||||
if duration >= self.min_visit_duration_minutes:
|
||||
start_time = current_visit_locations[0].timestamp
|
||||
end_time = current_visit_locations[-1].timestamp
|
||||
|
||||
lats = np.array([loc.latitude for loc in current_visit_locations])
|
||||
lons = np.array([loc.longitude for loc in current_visit_locations])
|
||||
centroid_lat = np.mean(lats)
|
||||
centroid_lon = np.mean(lons)
|
||||
centroid_point = WKTElement(f'POINT({centroid_lon} {centroid_lat})', srid=4326)
|
||||
|
||||
visit = Visit(
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
centroid_geom=centroid_point
|
||||
)
|
||||
visits.append(visit)
|
||||
|
||||
return visits
|
||||
|
||||
def detect_trips(self, locations: list[Location], visits: list[Visit]) -> list[Trip]:
|
||||
trips = []
|
||||
if not locations:
|
||||
return trips
|
||||
|
||||
# For simplicity, identify trips as sequences between visits
|
||||
# First, get all visit time ranges
|
||||
visit_time_ranges = []
|
||||
for visit in visits:
|
||||
visit_time_ranges.append((visit.start_time, visit.end_time))
|
||||
|
||||
current_trip_locations = []
|
||||
for loc in locations:
|
||||
in_visit = False
|
||||
for start, end in visit_time_ranges:
|
||||
if start <= loc.timestamp <= end:
|
||||
in_visit = True
|
||||
break
|
||||
|
||||
if not in_visit:
|
||||
current_trip_locations.append(loc)
|
||||
else:
|
||||
if len(current_trip_locations) > 1: # At least 2 points for a trip
|
||||
# Create Trip object
|
||||
start_time = current_trip_locations[0].timestamp
|
||||
end_time = current_trip_locations[-1].timestamp
|
||||
|
||||
# Create LINESTRING
|
||||
coords = [(loc.longitude, loc.latitude) for loc in current_trip_locations]
|
||||
linestring_str = f'LINESTRING({", ".join([f"{lon} {lat}" for lon, lat in coords])})'
|
||||
path_line = WKTElement(linestring_str, srid=4326)
|
||||
|
||||
trip = Trip(
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
transport_mode="unknown",
|
||||
path_geom=path_line
|
||||
)
|
||||
trips.append(trip)
|
||||
|
||||
current_trip_locations = []
|
||||
|
||||
# Check for final trip
|
||||
if len(current_trip_locations) > 1:
|
||||
start_time = current_trip_locations[0].timestamp
|
||||
end_time = current_trip_locations[-1].timestamp
|
||||
|
||||
coords = [(loc.longitude, loc.latitude) for loc in current_trip_locations]
|
||||
linestring_str = f'LINESTRING({", ".join([f"{lon} {lat}" for lon, lat in coords])})'
|
||||
path_line = WKTElement(linestring_str, srid=4326)
|
||||
|
||||
trip = Trip(
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
transport_mode="unknown",
|
||||
path_geom=path_line
|
||||
)
|
||||
trips.append(trip)
|
||||
|
||||
return trips
|
||||
|
||||
def _haversine_distance(self, lat1, lon1, lat2, lon2):
|
||||
R = 6371000 # Radius of Earth in meters
|
||||
phi1 = np.radians(lat1)
|
||||
phi2 = np.radians(lat2)
|
||||
delta_phi = np.radians(lat2 - lat1)
|
||||
delta_lambda = np.radians(lon2 - lon1)
|
||||
|
||||
a = np.sin(delta_phi / 2)**2 + np.cos(phi1) * np.cos(phi2) * np.sin(delta_lambda / 2)**2
|
||||
c = 2 * np.arctan2(np.sqrt(a), np.sqrt(1 - a))
|
||||
|
||||
return R * c
|
||||
27
backend/data_importer.py
Normal file
27
backend/data_importer.py
Normal file
@@ -0,0 +1,27 @@
|
||||
import json
|
||||
import datetime
|
||||
from models import Location
|
||||
|
||||
class GoogleTakeoutImporter:
|
||||
def import_locations(self, json_filepath: str) -> list[Location]:
|
||||
locations = []
|
||||
try:
|
||||
with open(json_filepath, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
for loc_data in data.get('locations', []):
|
||||
timestamp_ms = int(loc_data['timestampMs'])
|
||||
timestamp = datetime.datetime.fromtimestamp(timestamp_ms / 1000.0)
|
||||
latitude = loc_data['latitudeE7'] / 1e7
|
||||
longitude = loc_data['longitudeE7'] / 1e7
|
||||
locations.append({
|
||||
'latitude': latitude,
|
||||
'longitude': longitude,
|
||||
'timestamp': timestamp
|
||||
})
|
||||
except FileNotFoundError:
|
||||
print(f"Error: File not found: {json_filepath}")
|
||||
except json.JSONDecodeError:
|
||||
print("Error: Invalid JSON format.")
|
||||
except Exception as e:
|
||||
print(f"An unexpected error occurred during import: {e}")
|
||||
return locations
|
||||
172
backend/main.py
Normal file
172
backend/main.py
Normal file
@@ -0,0 +1,172 @@
|
||||
from fastapi import FastAPI, UploadFile, File, Depends, HTTPException
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from geoalchemy2.elements import WKTElement
|
||||
import os
|
||||
import datetime
|
||||
from datetime import datetime as dt
|
||||
import json
|
||||
import redis
|
||||
from reportlab.lib.pagesizes import letter
|
||||
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table
|
||||
from reportlab.lib.styles import getSampleStyleSheet
|
||||
from reportlab.lib import colors
|
||||
from io import BytesIO
|
||||
|
||||
from data_importer import GoogleTakeoutImporter
|
||||
from analysis import LocationAnalyzer
|
||||
from models import SessionLocal, engine, Base, Location, Visit, Trip, create_db_tables
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
# Redis connection
|
||||
redis_client = redis.from_url(os.getenv("REDIS_URL", "redis://localhost:6379"))
|
||||
|
||||
# Dependency to get the DB session
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
create_db_tables()
|
||||
|
||||
@app.post("/upload")
|
||||
async def upload_file(file: UploadFile = File(...), db: Session = Depends(get_db)):
|
||||
if not file.filename:
|
||||
raise HTTPException(status_code=400, detail="No selected file")
|
||||
|
||||
file_location = f"/tmp/{file.filename}"
|
||||
try:
|
||||
with open(file_location, "wb+") as file_object:
|
||||
file_object.write(await file.read())
|
||||
|
||||
importer = GoogleTakeoutImporter()
|
||||
raw_locations = importer.import_locations(file_location)
|
||||
|
||||
new_locations = []
|
||||
for loc_data in raw_locations:
|
||||
if all(k in loc_data for k in ['latitude', 'longitude', 'timestamp']):
|
||||
if isinstance(loc_data['timestamp'], str):
|
||||
timestamp = dt.fromisoformat(loc_data['timestamp'].replace('Z', '+00:00'))
|
||||
else:
|
||||
timestamp = loc_data['timestamp']
|
||||
|
||||
point = WKTElement(f'POINT({loc_data["longitude"]} {loc_data["latitude"]})', srid=4326)
|
||||
new_location = Location(
|
||||
latitude=loc_data['latitude'],
|
||||
longitude=loc_data['longitude'],
|
||||
timestamp=timestamp,
|
||||
geom=point
|
||||
)
|
||||
new_locations.append(new_location)
|
||||
|
||||
analyzer = LocationAnalyzer()
|
||||
visits = analyzer.detect_visits(new_locations)
|
||||
trips = analyzer.detect_trips(new_locations, visits)
|
||||
|
||||
db.add_all(new_locations)
|
||||
db.add_all(visits)
|
||||
db.add_all(trips)
|
||||
db.commit()
|
||||
|
||||
# Invalidate cache
|
||||
redis_client.flushdb()
|
||||
|
||||
return JSONResponse(content={
|
||||
'message': f'Successfully imported {len(new_locations)} locations, detected {len(visits)} visits and {len(trips)} trips.'
|
||||
}, status_code=200)
|
||||
except Exception as e:
|
||||
if 'db' in locals():
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=f"Failed to process file: {str(e)}")
|
||||
finally:
|
||||
if os.path.exists(file_location):
|
||||
os.remove(file_location)
|
||||
|
||||
@app.get("/data/locations")
|
||||
def get_locations(db: Session = Depends(get_db)):
|
||||
cache_key = "locations"
|
||||
cached = redis_client.get(cache_key)
|
||||
if cached:
|
||||
return JSONResponse(content=json.loads(cached))
|
||||
|
||||
locations = db.query(Location).all()
|
||||
data = {"locations": [{"id": l.id, "lat": l.latitude, "lon": l.longitude, "time": l.timestamp.isoformat()} for l in locations]}
|
||||
|
||||
redis_client.setex(cache_key, 300, json.dumps(data)) # Cache for 5 min
|
||||
return data
|
||||
|
||||
@app.get("/data/visits")
|
||||
def get_visits(db: Session = Depends(get_db)):
|
||||
cache_key = "visits"
|
||||
cached = redis_client.get(cache_key)
|
||||
if cached:
|
||||
return JSONResponse(content=json.loads(cached))
|
||||
|
||||
visits = db.query(Visit).all()
|
||||
data = {"visits": [{"id": v.id, "start": v.start_time.isoformat(), "end": v.end_time.isoformat(),
|
||||
"centroid": str(v.centroid_geom) if v.centroid_geom else None} for v in visits]}
|
||||
|
||||
redis_client.setex(cache_key, 300, json.dumps(data))
|
||||
return data
|
||||
|
||||
@app.get("/data/trips")
|
||||
def get_trips(db: Session = Depends(get_db)):
|
||||
cache_key = "trips"
|
||||
cached = redis_client.get(cache_key)
|
||||
if cached:
|
||||
return JSONResponse(content=json.loads(cached))
|
||||
|
||||
trips = db.query(Trip).all()
|
||||
data = {"trips": [{"id": t.id, "start": t.start_time.isoformat(), "end": t.end_time.isoformat(),
|
||||
"mode": t.transport_mode, "path": str(t.path_geom) if t.path_geom else None} for t in trips]}
|
||||
|
||||
redis_client.setex(cache_key, 300, json.dumps(data))
|
||||
return data
|
||||
|
||||
@app.get("/export/report")
|
||||
def export_report(db: Session = Depends(get_db)):
|
||||
# Fetch data
|
||||
visits = db.query(Visit).count()
|
||||
trips = db.query(Trip).count()
|
||||
locations = db.query(Location).count()
|
||||
|
||||
buffer = BytesIO()
|
||||
doc = SimpleDocTemplate(buffer, pagesize=letter)
|
||||
styles = getSampleStyleSheet()
|
||||
story = []
|
||||
|
||||
# Title
|
||||
title = Paragraph("Travel Memories Report", styles['Title'])
|
||||
story.append(title)
|
||||
story.append(Spacer(1, 12))
|
||||
|
||||
# Stats
|
||||
stats_data = [
|
||||
['Metric', 'Count'],
|
||||
['Total Locations', locations],
|
||||
['Detected Visits', visits],
|
||||
['Detected Trips', trips]
|
||||
]
|
||||
stats_table = Table(stats_data)
|
||||
stats_table.setStyle([
|
||||
('BACKGROUND', (0, 0), (-1, 0), colors.grey),
|
||||
('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke),
|
||||
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
|
||||
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
|
||||
('FONTSIZE', (0, 0), (-1, 0), 14),
|
||||
('BOTTOMPADDING', (0, 0), (-1, 0), 12),
|
||||
('BACKGROUND', (0, 1), (-1, -1), colors.beige),
|
||||
('GRID', (0, 0), (-1, -1), 1, colors.black)
|
||||
])
|
||||
story.append(stats_table)
|
||||
|
||||
# Build PDF
|
||||
doc.build(story)
|
||||
buffer.seek(0)
|
||||
|
||||
return StreamingResponse(buffer, media_type="application/pdf", headers={"Content-Disposition": "attachment; filename=travel-report.pdf"})
|
||||
65
backend/models.py
Normal file
65
backend/models.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from sqlalchemy import create_engine, Column, Integer, String, DateTime, Float, ForeignKey
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker, relationship
|
||||
from geoalchemy2 import Geometry
|
||||
import datetime
|
||||
import os
|
||||
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://user:password@db:5432/memories_db")
|
||||
|
||||
engine = create_engine(DATABASE_URL)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
Base = declarative_base()
|
||||
|
||||
class Location(Base):
|
||||
__tablename__ = "locations"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
latitude = Column(Float, nullable=False)
|
||||
longitude = Column(Float, nullable=False)
|
||||
timestamp = Column(DateTime, default=datetime.datetime.utcnow, nullable=False)
|
||||
geom = Column(Geometry(geometry_type='POINT', srid=4326), nullable=False)
|
||||
|
||||
# Optional: Link to a user if multi-user is implemented
|
||||
# user_id = Column(Integer, ForeignKey("users.id"))
|
||||
# user = relationship("User")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Location(id={self.id}, lat={self.latitude}, lon={self.longitude}, time={self.timestamp})>"
|
||||
|
||||
class Visit(Base):
|
||||
__tablename__ = "visits"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
start_time = Column(DateTime, nullable=False)
|
||||
end_time = Column(DateTime, nullable=False)
|
||||
# A visit can be represented by a polygon or a centroid, for simplicity we'll use a point for now
|
||||
centroid_geom = Column(Geometry(geometry_type='POINT', srid=4326), nullable=True)
|
||||
|
||||
# Optional: Link to a user
|
||||
# user_id = Column(Integer, ForeignKey("users.id"))
|
||||
# user = relationship("User")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Visit(id={self.id}, start={self.start_time}, end={self.end_time})>"
|
||||
|
||||
class Trip(Base):
|
||||
__tablename__ = "trips"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
start_time = Column(DateTime, nullable=False)
|
||||
end_time = Column(DateTime, nullable=False)
|
||||
transport_mode = Column(String, default="unknown")
|
||||
# A trip can be represented by a LineString
|
||||
path_geom = Column(Geometry(geometry_type='LINESTRING', srid=4326), nullable=True)
|
||||
|
||||
# Optional: Link to a user
|
||||
# user_id = Column(Integer, ForeignKey("users.id"))
|
||||
# user = relationship("User")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Trip(id={self.id}, mode={self.transport_mode}, start={self.start_time}, end={self.end_time})>"
|
||||
|
||||
# Function to create all tables
|
||||
def create_db_tables():
|
||||
Base.metadata.create_all(bind=engine)
|
||||
13
backend/requirements.txt
Normal file
13
backend/requirements.txt
Normal file
@@ -0,0 +1,13 @@
|
||||
fastapi
|
||||
uvicorn
|
||||
SQLAlchemy
|
||||
psycopg2-binary
|
||||
python-dotenv
|
||||
pika
|
||||
pandas
|
||||
numpy
|
||||
scipy
|
||||
GeoAlchemy2
|
||||
python-multipart
|
||||
redis
|
||||
reportlab
|
||||
77
docker-compose.yml
Normal file
77
docker-compose.yml
Normal file
@@ -0,0 +1,77 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgis/postgis:16-3.4
|
||||
restart: always
|
||||
environment:
|
||||
POSTGRES_DB: memories_db
|
||||
POSTGRES_USER: user
|
||||
POSTGRES_PASSWORD: password
|
||||
volumes:
|
||||
- db_data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5432:5432"
|
||||
|
||||
rabbitmq:
|
||||
image: rabbitmq:3-management
|
||||
restart: always
|
||||
ports:
|
||||
- "5672:5672"
|
||||
- "15672:15672" # Management UI
|
||||
environment:
|
||||
RABBITMQ_DEFAULT_USER: user
|
||||
RABBITMQ_DEFAULT_PASS: password
|
||||
|
||||
redis:
|
||||
image: redis:alpine
|
||||
restart: always
|
||||
ports:
|
||||
- "6379:6379"
|
||||
command: redis-server --appendonly yes
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
|
||||
backend:
|
||||
build: ./backend
|
||||
restart: always
|
||||
command: uvicorn main:app --host 0.0.0.0 --port 8000
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
DATABASE_URL: postgresql://user:password@db:5432/memories_db
|
||||
RABBITMQ_URL: amqp://user:password@rabbitmq:5672/%2F
|
||||
REDIS_URL: redis://redis:6379
|
||||
depends_on:
|
||||
- db
|
||||
- rabbitmq
|
||||
- redis
|
||||
|
||||
frontend:
|
||||
build: ./frontend
|
||||
restart: always
|
||||
ports:
|
||||
- "3000:80" # Serve React app on port 80 inside container, map to 3000 outside
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
# Optional: Analysis worker for background tasks
|
||||
worker:
|
||||
build: ./backend
|
||||
command: python worker.py # Need to create worker.py
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
environment:
|
||||
DATABASE_URL: postgresql://user:password@db:5432/memories_db
|
||||
RABBITMQ_URL: amqp://user:password@rabbitmq:5672/%2F
|
||||
REDIS_URL: redis://redis:6379
|
||||
depends_on:
|
||||
- db
|
||||
- rabbitmq
|
||||
- redis
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
redis_data:
|
||||
11
frontend/Dockerfile
Normal file
11
frontend/Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
||||
FROM node:20-alpine as build
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
COPY . .
|
||||
RUN npm install
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:stable-alpine
|
||||
COPY --from=build /app/build /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
39
frontend/package.json
Normal file
39
frontend/package.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"@emotion/react": "^11.11.1",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@mui/material": "^5.14.18",
|
||||
"mapbox-gl": "^2.15.0",
|
||||
"react-router-dom": "^6.20.1"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
}
|
||||
}
|
||||
20
frontend/public/index.html
Normal file
20
frontend/public/index.html
Normal file
@@ -0,0 +1,20 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Web site created using create-react-app"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<title>Memories App</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
25
frontend/public/manifest.json
Normal file
25
frontend/public/manifest.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"short_name": "Memories",
|
||||
"name": "Travel Memories App",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
74
frontend/src/App.js
Normal file
74
frontend/src/App.js
Normal file
@@ -0,0 +1,74 @@
|
||||
import React from 'react';
|
||||
import { Typography, Container, Box, AppBar, Toolbar, Button } from '@mui/material';
|
||||
import { Routes, Route, Link } from 'react-router-dom';
|
||||
import Login from './components/Login';
|
||||
import Register from './components/Register';
|
||||
import Dashboard from './components/Dashboard';
|
||||
import UploadForm from './components/UploadForm';
|
||||
import MapView from './components/MapView';
|
||||
import ExportView from './components/ExportView';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<AppBar position="static">
|
||||
<Toolbar>
|
||||
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
|
||||
Memories
|
||||
</Typography>
|
||||
<Button color="inherit" component={Link} to="/">
|
||||
Home
|
||||
</Button>
|
||||
<Button color="inherit" component={Link} to="/dashboard">
|
||||
Dashboard
|
||||
</Button>
|
||||
<Button color="inherit" component={Link} to="/upload">
|
||||
Upload
|
||||
</Button>
|
||||
<Button color="inherit" component={Link} to="/map">
|
||||
Map
|
||||
</Button>
|
||||
<Button color="inherit" component={Link} to="/export">
|
||||
Export
|
||||
</Button>
|
||||
<Button color="inherit" component={Link} to="/login">
|
||||
Login
|
||||
</Button>
|
||||
<Button color="inherit" component={Link} to="/register">
|
||||
Register
|
||||
</Button>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
<Container maxWidth="lg" sx={{ mt: 4 }}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/upload" element={<UploadForm />} />
|
||||
<Route path="/map" element={<MapView />} />
|
||||
<Route path="/export" element={<ExportView />} />
|
||||
<Route path="/" element={
|
||||
<Box sx={{ my: 4 }}>
|
||||
<Typography variant="h4" component="h1" gutterBottom>
|
||||
Welcome to Memories!
|
||||
</Typography>
|
||||
<Typography variant="body1">
|
||||
Your journey to narrative-driven travel logs begins here.
|
||||
</Typography>
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<Button variant="contained" component={Link} to="/upload" sx={{ mr: 2 }}>
|
||||
Get Started - Upload Data
|
||||
</Button>
|
||||
<Button variant="contained" component={Link} to="/dashboard">
|
||||
View Dashboard
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
} />
|
||||
</Routes>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
77
frontend/src/components/Dashboard.js
Normal file
77
frontend/src/components/Dashboard.js
Normal file
@@ -0,0 +1,77 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Box, Typography, Card, CardContent, Button, Grid } from '@mui/material';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
|
||||
const Dashboard = () => {
|
||||
const [stats, setStats] = useState({ locations: 0, visits: 0, trips: 0 });
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
loadStats();
|
||||
}, []);
|
||||
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const [locRes, visitsRes, tripsRes] = await Promise.all([
|
||||
axios.get('http://localhost:8000/data/locations'), // Assume endpoint exists or add it
|
||||
axios.get('http://localhost:8000/data/visits'),
|
||||
axios.get('http://localhost:8000/data/trips')
|
||||
]);
|
||||
setStats({
|
||||
locations: locRes.data.locations?.length || 0,
|
||||
visits: visitsRes.data.visits?.length || 0,
|
||||
trips: tripsRes.data.trips?.length || 0
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to load stats:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Dashboard
|
||||
</Typography>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h5">Total Locations</Typography>
|
||||
<Typography variant="h2">{stats.locations}</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h5">Visits</Typography>
|
||||
<Typography variant="h2">{stats.visits}</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h5">Trips</Typography>
|
||||
<Typography variant="h2">{stats.trips}</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<Button variant="contained" onClick={() => navigate('/upload')} sx={{ mr: 2 }}>
|
||||
Upload Data
|
||||
</Button>
|
||||
<Button variant="contained" onClick={() => navigate('/map')} sx={{ mr: 2 }}>
|
||||
View Map
|
||||
</Button>
|
||||
<Button variant="contained" onClick={() => navigate('/export')}>
|
||||
Export Report
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
42
frontend/src/components/ExportView.js
Normal file
42
frontend/src/components/ExportView.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import { Box, Typography, Button, Alert } from '@mui/material';
|
||||
import axios from 'axios';
|
||||
|
||||
const ExportView = () => {
|
||||
const [message, setMessage] = React.useState('');
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
const response = await axios.get('http://localhost:8000/export/report', { responseType: 'blob' });
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', `travel-report-${new Date().toISOString().split('T')[0]}.pdf`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
setMessage('Report exported successfully!');
|
||||
} catch (err) {
|
||||
setMessage(`Export failed: ${err.response?.data?.detail || err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 500, mx: 'auto', mt: 4 }}>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Export Travel Report
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ mb: 2 }}>
|
||||
Generate a PDF report of your travels.
|
||||
</Typography>
|
||||
<Button variant="contained" onClick={handleExport} size="large">
|
||||
Download Report
|
||||
</Button>
|
||||
{message && <Alert severity={message.includes('failed') ? 'error' : 'success'} sx={{ mt: 2 }}>
|
||||
{message}
|
||||
</Alert>}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExportView;
|
||||
66
frontend/src/components/Login.js
Normal file
66
frontend/src/components/Login.js
Normal file
@@ -0,0 +1,66 @@
|
||||
import React, { useState } from 'react';
|
||||
import { TextField, Button, Typography, Container, Box, Paper } from '@mui/material';
|
||||
|
||||
function Login() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
const handleSubmit = (event) => {
|
||||
event.preventDefault();
|
||||
console.log('Login attempt with:', { email, password });
|
||||
// Here you would typically send a request to your backend
|
||||
};
|
||||
|
||||
return (
|
||||
<Container component="main" maxWidth="xs">
|
||||
<Box
|
||||
sx={{
|
||||
marginTop: 8,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography component="h1" variant="h5">
|
||||
Sign in
|
||||
</Typography>
|
||||
<Box component="form" onSubmit={handleSubmit} noValidate sx={{ mt: 1 }}>
|
||||
<TextField
|
||||
margin="normal"
|
||||
required
|
||||
fullWidth
|
||||
id="email"
|
||||
label="Email Address"
|
||||
name="email"
|
||||
autoComplete="email"
|
||||
autoFocus
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
margin="normal"
|
||||
required
|
||||
fullWidth
|
||||
name="password"
|
||||
label="Password"
|
||||
type="password"
|
||||
id="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
variant="contained"
|
||||
sx={{ mt: 3, mb: 2 }}
|
||||
>
|
||||
Sign In
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default Login;
|
||||
107
frontend/src/components/MapView.js
Normal file
107
frontend/src/components/MapView.js
Normal file
@@ -0,0 +1,107 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import { Box, Typography } from '@mui/material';
|
||||
import axios from 'axios';
|
||||
|
||||
mapboxgl.accessToken = 'YOUR_MAPBOX_TOKEN'; // Replace with your Mapbox token
|
||||
|
||||
const MapView = () => {
|
||||
const mapContainer = useRef(null);
|
||||
const map = useRef(null);
|
||||
const [dataLoaded, setDataLoaded] = React.useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (map.current) return; // initialize map only once
|
||||
|
||||
map.current = new mapboxgl.Map({
|
||||
container: mapContainer.current,
|
||||
style: 'mapbox://styles/mapbox/streets-v12',
|
||||
center: [-74.5, 40], // Initial center (NYC as example)
|
||||
zoom: 9
|
||||
});
|
||||
|
||||
map.current.on('load', () => {
|
||||
loadData();
|
||||
});
|
||||
}, []);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [visitsRes, tripsRes] = await Promise.all([
|
||||
axios.get('http://localhost:8000/data/visits'),
|
||||
axios.get('http://localhost:8000/data/trips')
|
||||
]);
|
||||
|
||||
const visits = visitsRes.data.visits;
|
||||
const trips = tripsRes.data.trips;
|
||||
|
||||
// Add visits as circles
|
||||
map.current.addSource('visits', {
|
||||
type: 'geojson',
|
||||
data: {
|
||||
type: 'FeatureCollection',
|
||||
features: visits.map(v => {
|
||||
const coords = v.centroid.match(/POINT\\(([^)]+)\\)/)[1].split(' ').map(Number);
|
||||
return {
|
||||
type: 'Feature',
|
||||
geometry: { type: 'Point', coordinates: coords },
|
||||
properties: { start: v.start, end: v.end }
|
||||
};
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
map.current.addLayer({
|
||||
id: 'visits',
|
||||
type: 'circle',
|
||||
source: 'visits',
|
||||
paint: {
|
||||
'circle-radius': 10,
|
||||
'circle-color': '#007cbf'
|
||||
}
|
||||
});
|
||||
|
||||
// Add trips as lines
|
||||
if (trips.length > 0) {
|
||||
map.current.addSource('trips', {
|
||||
type: 'geojson',
|
||||
data: {
|
||||
type: 'FeatureCollection',
|
||||
features: trips.map(t => {
|
||||
// Parse LINESTRING
|
||||
const coordsStr = t.path.match(/LINESTRING\\((.*)\\)/)[1];
|
||||
const coords = coordsStr.split(', ').map(pair => pair.split(' ').map(Number));
|
||||
return {
|
||||
type: 'Feature',
|
||||
geometry: { type: 'LineString', coordinates: coords },
|
||||
properties: { mode: t.mode, start: t.start, end: t.end }
|
||||
};
|
||||
}).filter(f => f.geometry.coordinates.length > 1)
|
||||
}
|
||||
});
|
||||
|
||||
map.current.addLayer({
|
||||
id: 'trips',
|
||||
type: 'line',
|
||||
source: 'trips',
|
||||
layout: { 'line-join': 'round', 'line-cap': 'round' },
|
||||
paint: { 'line-color': '#f03b20', 'line-width': 4 }
|
||||
});
|
||||
}
|
||||
|
||||
setDataLoaded(true);
|
||||
} catch (err) {
|
||||
console.error('Failed to load data:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ height: '100vh', width: '100%' }}>
|
||||
<Typography variant="h4" sx={{ p: 2 }}>Travel Map</Typography>
|
||||
<div ref={mapContainer} style={{ height: 'calc(100% - 64px)', width: '100%' }} />
|
||||
{!dataLoaded && <Typography sx={{ p: 2 }}>Loading map data...</Typography>}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MapView;
|
||||
83
frontend/src/components/Register.js
Normal file
83
frontend/src/components/Register.js
Normal file
@@ -0,0 +1,83 @@
|
||||
import React, { useState } from 'react';
|
||||
import { TextField, Button, Typography, Container, Box, Paper } from '@mui/material';
|
||||
|
||||
function Register() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
|
||||
const handleSubmit = (event) => {
|
||||
event.preventDefault();
|
||||
if (password !== confirmPassword) {
|
||||
alert("Passwords don't match!");
|
||||
return;
|
||||
}
|
||||
console.log('Register attempt with:', { email, password });
|
||||
// Here you would typically send a request to your backend
|
||||
};
|
||||
|
||||
return (
|
||||
<Container component="main" maxWidth="xs">
|
||||
<Box
|
||||
sx={{
|
||||
marginTop: 8,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography component="h1" variant="h5">
|
||||
Sign up
|
||||
</Typography>
|
||||
<Box component="form" onSubmit={handleSubmit} noValidate sx={{ mt: 1 }}>
|
||||
<TextField
|
||||
margin="normal"
|
||||
required
|
||||
fullWidth
|
||||
id="email"
|
||||
label="Email Address"
|
||||
name="email"
|
||||
autoComplete="email"
|
||||
autoFocus
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
margin="normal"
|
||||
required
|
||||
fullWidth
|
||||
name="password"
|
||||
label="Password"
|
||||
type="password"
|
||||
id="password"
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
margin="normal"
|
||||
required
|
||||
fullWidth
|
||||
name="confirmPassword"
|
||||
label="Confirm Password"
|
||||
type="password"
|
||||
id="confirmPassword"
|
||||
autoComplete="new-password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
variant="contained"
|
||||
sx={{ mt: 3, mb: 2 }}
|
||||
>
|
||||
Sign Up
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default Register;
|
||||
55
frontend/src/components/UploadForm.js
Normal file
55
frontend/src/components/UploadForm.js
Normal file
@@ -0,0 +1,55 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button, Box, Typography, TextField, Alert } from '@mui/material';
|
||||
import axios from 'axios';
|
||||
|
||||
const UploadForm = () => {
|
||||
const [file, setFile] = useState(null);
|
||||
const [message, setMessage] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleFileChange = (e) => {
|
||||
setFile(e.target.files[0]);
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!file) {
|
||||
setError('Please select a file');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
setError('');
|
||||
const response = await axios.post('http://localhost:8000/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
setMessage(response.data.message);
|
||||
} catch (err) {
|
||||
setError(`Upload failed: ${err.response?.data?.detail || err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 500, mx: 'auto', mt: 4 }}>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Upload Location Data
|
||||
</Typography>
|
||||
<TextField
|
||||
type="file"
|
||||
onChange={handleFileChange}
|
||||
inputProps={{ accept: '.json,.csv' }}
|
||||
fullWidth
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
<Button variant="contained" onClick={handleUpload} fullWidth sx={{ mb: 2 }}>
|
||||
Upload
|
||||
</Button>
|
||||
{message && <Alert severity="success">{message}</Alert>}
|
||||
{error && <Alert severity="error">{error}</Alert>}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default UploadForm;
|
||||
13
frontend/src/index.js
Normal file
13
frontend/src/index.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
Binary file not shown.
Binary file not shown.
49
location_visualizer/location_data_manager.py
Normal file
49
location_visualizer/location_data_manager.py
Normal file
@@ -0,0 +1,49 @@
|
||||
import googlemaps
|
||||
import json
|
||||
import datetime
|
||||
|
||||
class LocationDataManager:
|
||||
def __init__(self, api_key):
|
||||
self.gmaps = googlemaps.Client(key=api_key)
|
||||
|
||||
def get_coordinates(self, address):
|
||||
try:
|
||||
geocode_result = self.gmaps.geocode(address)
|
||||
if geocode_result:
|
||||
location = geocode_result[0]['geometry']['location']
|
||||
return location['lat'], location['lng']
|
||||
else:
|
||||
return None, None
|
||||
except Exception as e:
|
||||
print(f"Error during geocoding: {e}")
|
||||
return None, None
|
||||
|
||||
def get_address(self, latitude, longitude):
|
||||
try:
|
||||
reverse_geocode_result = self.gmaps.reverse_geocode((latitude, longitude))
|
||||
if reverse_geocode_result:
|
||||
return reverse_geocode_result[0]['formatted_address']
|
||||
else:
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"Error during reverse geocoding: {e}")
|
||||
return None
|
||||
|
||||
def load_location_history(self, json_filepath):
|
||||
locations = []
|
||||
try:
|
||||
with open(json_filepath, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
for location in data.get('locations', []):
|
||||
timestamp_ms = int(location['timestampMs'])
|
||||
timestamp = datetime.datetime.fromtimestamp(timestamp_ms / 1000.0)
|
||||
lat = location['latitudeE7'] / 1e7
|
||||
lng = location['longitudeE7'] / 1e7
|
||||
locations.append({'timestamp': timestamp, 'lat': lat, 'lng': lng})
|
||||
except FileNotFoundError:
|
||||
print(f"Error: File not found: {json_filepath}")
|
||||
except json.JSONDecodeError:
|
||||
print("Error: Invalid JSON format.")
|
||||
except Exception as e:
|
||||
print(f"An unexpected error occurred: {e}")
|
||||
return locations
|
||||
42
location_visualizer/location_history_visualizer.py
Normal file
42
location_visualizer/location_history_visualizer.py
Normal file
@@ -0,0 +1,42 @@
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
from location_data_manager import LocationDataManager
|
||||
|
||||
class LocationHistoryVisualizer:
|
||||
def __init__(self, api_key, json_filepath):
|
||||
self.root = tk.Tk()
|
||||
self.root.title("Location History Visualizer")
|
||||
|
||||
self.location_data_manager = LocationDataManager(api_key)
|
||||
self.json_filepath = json_filepath
|
||||
|
||||
self.create_widgets()
|
||||
self.load_and_display_location_history()
|
||||
|
||||
def create_widgets(self):
|
||||
history_label = ttk.Label(self.root, text="Location History:")
|
||||
history_label.grid(row=0, column=0, padx=5, pady=5)
|
||||
|
||||
self.history_display = tk.Text(self.root, wrap=tk.WORD, width=60, height=20)
|
||||
self.history_display.grid(row=1, column=0, columnspan=2, padx=5, pady=5)
|
||||
|
||||
def load_and_display_location_history(self):
|
||||
locations = self.location_data_manager.load_location_history(self.json_filepath)
|
||||
|
||||
if not locations:
|
||||
self.history_display.insert(tk.END, "No location history data found or an error occurred.")
|
||||
return
|
||||
|
||||
for location in locations:
|
||||
try:
|
||||
address = self.location_data_manager.get_address(location['lat'], location['lng'])
|
||||
if address:
|
||||
self.history_display.insert(tk.END, f"{location['timestamp']}: {address}\n")
|
||||
else:
|
||||
self.history_display.insert(tk.END, f"{location['timestamp']}: ({location['lat']}, {location['lng']})\n")
|
||||
except Exception as e:
|
||||
print(f"Error getting address: {e}")
|
||||
self.history_display.insert(tk.END, f"{location['timestamp']}: ({location['lat']}, {location['lng']}) - Error getting address\n")
|
||||
|
||||
def run(self):
|
||||
self.root.mainloop()
|
||||
12
location_visualizer/main.py
Normal file
12
location_visualizer/main.py
Normal file
@@ -0,0 +1,12 @@
|
||||
import os
|
||||
from location_history_visualizer import LocationHistoryVisualizer
|
||||
|
||||
if __name__ == "__main__":
|
||||
google_maps_api_key = os.environ.get("GOOGLE_MAPS_API_KEY")
|
||||
json_filepath = "/Users/maq/Desktop/Desktop2.7.2025/Desktop/MAYTE FILES2/takeout folders/New Folder With Items/LocationHistory.json" # Replace with your actual file path
|
||||
|
||||
if google_maps_api_key:
|
||||
app = LocationHistoryVisualizer(google_maps_api_key, json_filepath)
|
||||
app.run()
|
||||
else:
|
||||
print("Please set the GOOGLE_MAPS_API_KEY environment variable.")
|
||||
16
templates/index.html
Normal file
16
templates/index.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Memories App</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Welcome to Memories!</h1>
|
||||
<p>Upload your Google Takeout Location History JSON file to get started.</p>
|
||||
<form action="/upload" method="post" enctype="multipart/form-data">
|
||||
<input type="file" name="file" accept=".json">
|
||||
<input type="submit" value="Upload">
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user