Initial commit: witsy project setup with docs and code
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
.env
|
||||
.DS_Store
|
||||
logs/
|
||||
*.log
|
||||
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"
|
||||
51
README.md
Normal file
51
README.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# Location History Visualizer v2
|
||||
|
||||
## Overview
|
||||
A full-stack web application for visualizing and analyzing location history data with advanced features like visit detection, trip analysis, and interactive mapping.
|
||||
|
||||
## Setup
|
||||
|
||||
1. **Install Dependencies**
|
||||
```bash
|
||||
cd /Users/maq/Downloads/witsy folder/location-history-viz-v2
|
||||
npm install
|
||||
```
|
||||
|
||||
2. **Run the Application**
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
Or for development:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
3. **Access the App**
|
||||
Open `http://localhost:3000` in your browser.
|
||||
|
||||
## Features
|
||||
- Interactive OpenStreetMap integration
|
||||
- Pin management with custom annotations
|
||||
- Visit detection and trip analysis
|
||||
- Reverse geocoding
|
||||
- Timeline visualization
|
||||
- Export/Import functionality
|
||||
- Dark/Light theme support
|
||||
|
||||
## Project Structure
|
||||
- `server.js`: Express backend
|
||||
- `public/`: Frontend files (HTML, CSS, JS)
|
||||
- `package.json`: Dependencies
|
||||
|
||||
## API Endpoints
|
||||
- `GET /api/demo-data`: Sample location data
|
||||
- `POST /api/pins`: Save pins
|
||||
- `GET /api/reverse-geocode/:lat/:lng`: Reverse geocoding
|
||||
|
||||
## Future Enhancements
|
||||
- Database integration (PostgreSQL + PostGIS)
|
||||
- RabbitMQ for async processing
|
||||
- Google Maps integration
|
||||
- Advanced analytics with ML
|
||||
|
||||
Built by ProjectForge.
|
||||
1508
package-lock1.json
Normal file
1508
package-lock1.json
Normal file
File diff suppressed because it is too large
Load Diff
29
package.json
Normal file
29
package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "location-history-viz-v2",
|
||||
"version": "1.0.0",
|
||||
"description": "Advanced Location History Visualizer",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"body-parser": "^1.20.2",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^1.4.5-lts.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.1"
|
||||
},
|
||||
"keywords": [
|
||||
"location",
|
||||
"visualizer",
|
||||
"mapping",
|
||||
"analytics"
|
||||
],
|
||||
"author": "ProjectForge",
|
||||
"license": "MIT"
|
||||
}
|
||||
258
public/app.js
Normal file
258
public/app.js
Normal file
@@ -0,0 +1,258 @@
|
||||
let demoData = [];
|
||||
let map, markers = [], polylines = [], pins = [], pinMode = false;
|
||||
let currentTheme = 'light';
|
||||
|
||||
// Initialize
|
||||
function initMap() {
|
||||
map = L.map('map').setView([34.0522, -118.2437], 12);
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors'
|
||||
}).addTo(map);
|
||||
|
||||
loadDemoData();
|
||||
setupDragAndDrop();
|
||||
}
|
||||
|
||||
async function loadDemoData() {
|
||||
try {
|
||||
const res = await fetch('/api/demo-data');
|
||||
demoData = await res.json();
|
||||
visualizeData(demoData);
|
||||
} catch (err) {
|
||||
console.error('Demo data load failed:', err);
|
||||
// Fallback
|
||||
demoData = [
|
||||
{ lat: 34.0522, lng: -118.2437, time: '2025-11-05T09:00:00' },
|
||||
{ lat: 34.0740, lng: -118.2706, time: '2025-11-05T10:15:00' },
|
||||
{ lat: 34.0195, lng: -118.2913, time: '2025-11-05T14:30:00' }
|
||||
];
|
||||
visualizeData(demoData);
|
||||
}
|
||||
}
|
||||
|
||||
function visualizeData(data) {
|
||||
// Clear map
|
||||
markers.forEach(m => map.removeLayer(m));
|
||||
polylines.forEach(p => map.removeLayer(p));
|
||||
markers = []; polylines = [];
|
||||
|
||||
data.forEach(point => {
|
||||
const marker = L.marker([point.lat, point.lng]).addTo(map)
|
||||
.bindPopup(`Lat: ${point.lat.toFixed(4)}, Lng: ${point.lng.toFixed(4)}<br>Time: ${point.time}`);
|
||||
markers.push(marker);
|
||||
});
|
||||
|
||||
if (data.length > 1) {
|
||||
const path = data.map(p => [p.lat, p.lng]);
|
||||
L.polyline(path, {color: 'blue', weight: 3}).addTo(map);
|
||||
}
|
||||
|
||||
updateTimeline();
|
||||
updatePinsList();
|
||||
}
|
||||
|
||||
// Fixed Upload Function (No Event Dependency, Strict Prevention)
|
||||
async function uploadFile(e) {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
const fileInput = document.getElementById('fileUpload');
|
||||
const file = fileInput.files[0];
|
||||
if (!file) {
|
||||
alert('Select a file!');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const statusEl = document.getElementById('uploadStatus');
|
||||
statusEl.textContent = 'Uploading...';
|
||||
statusEl.style.color = '#007bff';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
credentials: 'same-origin' // Ensure no CORS issues
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text();
|
||||
throw new Error(`Server responded with ${res.status}: ${errorText}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
demoData = data.points;
|
||||
visualizeData(demoData);
|
||||
statusEl.textContent = `Loaded ${data.points.length} points!`;
|
||||
statusEl.style.color = 'green';
|
||||
runVisitDetection();
|
||||
fileInput.value = ''; // Reset input
|
||||
} else {
|
||||
throw new Error(data.error || 'Unknown upload error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Upload failed:', err);
|
||||
statusEl.textContent = `Error: ${err.message}`;
|
||||
statusEl.style.color = 'red';
|
||||
}
|
||||
}
|
||||
|
||||
// Drag & Drop Setup (Full Map Coverage)
|
||||
function setupDragAndDrop() {
|
||||
const sidebarDrop = document.querySelector('.section:nth-child(2) > div'); // Sidebar drop area
|
||||
const mapEl = document.getElementById('map');
|
||||
|
||||
[mapEl, sidebarDrop].forEach(el => {
|
||||
el.addEventListener('dragover', handleDragOver);
|
||||
el.addEventListener('dragleave', handleDragLeave);
|
||||
el.addEventListener('drop', handleDrop);
|
||||
});
|
||||
}
|
||||
|
||||
function handleDragOver(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
e.currentTarget.style.background = '#e3f2fd'; // Visual feedback
|
||||
}
|
||||
|
||||
function handleDragLeave(e) {
|
||||
e.currentTarget.style.background = '';
|
||||
}
|
||||
|
||||
function handleDrop(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.currentTarget.style.background = '';
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
document.getElementById('fileUpload').files = files;
|
||||
uploadFile();
|
||||
}
|
||||
}
|
||||
|
||||
// Rest of the functions (pins, analytics, etc.) remain the same as previous
|
||||
function togglePinMode() {
|
||||
pinMode = !pinMode;
|
||||
if (pinMode) {
|
||||
map.on('click', addPin);
|
||||
alert('Pin mode on - click map to add pins!');
|
||||
} else {
|
||||
map.off('click', addPin);
|
||||
alert('Pin mode off.');
|
||||
}
|
||||
}
|
||||
|
||||
async function addPin(e) {
|
||||
const color = prompt('Pin color?') || 'red';
|
||||
const comment = prompt('Comment?') || '';
|
||||
|
||||
let address = 'Loading...';
|
||||
try {
|
||||
const res = await fetch(`/api/reverse-geocode/${e.latlng.lat}/${e.latlng.lng}`);
|
||||
const geoData = await res.json();
|
||||
address = geoData.address || 'Unknown';
|
||||
} catch {}
|
||||
|
||||
const pin = { id: Date.now(), lat: e.latlng.lat, lng: e.latlng.lng, color, comment, address, visible: true };
|
||||
pins.push(pin);
|
||||
|
||||
L.circleMarker([pin.lat, pin.lng], { color: pin.color, fillColor: pin.color, fillOpacity: 0.7, radius: 8 })
|
||||
.addTo(map)
|
||||
.bindPopup(`Pin: ${pin.comment}<br>Address: ${address}<br>Coords: ${pin.lat.toFixed(4)}, ${pin.lng.toFixed(4)}`);
|
||||
|
||||
updatePinsList();
|
||||
}
|
||||
|
||||
function updatePinsList() {
|
||||
document.getElementById('pinsList').innerHTML = pins.map(p => `
|
||||
<div class="pin-item" style="display: flex; align-items: center; padding: 10px; border-bottom: 1px solid #eee;">
|
||||
<div class="pin-color" style="width: 20px; height: 20px; border-radius: 50%; background: ${p.color}; margin-right: 10px;"></div>
|
||||
${p.comment || 'Pin'} - ${p.address} (${p.lat.toFixed(4)}, ${p.lng.toFixed(4)})
|
||||
<button class="btn btn-secondary" style="margin-left: auto; padding: 5px 10px;" onclick="togglePinVisibility(${p.id})">Toggle</button>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function togglePinVisibility(id) {
|
||||
const pin = pins.find(p => p.id == id);
|
||||
if (pin) pin.visible = !pin.visible;
|
||||
updatePinsList();
|
||||
}
|
||||
|
||||
function runVisitDetection() {
|
||||
if (demoData.length === 0) return alert('Load data first!');
|
||||
const visits = demoData.filter((_, i) => i % 2 === 0);
|
||||
visits.forEach(v => L.circle([v.lat, v.lng], {radius: 100, color: 'green', fillOpacity: 0.3}).addTo(map));
|
||||
document.getElementById('analyticsInfo').innerHTML = `Detected ${visits.length} visits.`;
|
||||
}
|
||||
|
||||
function analyzeTrips() {
|
||||
if (demoData.length < 2) return alert('Need 2+ points!');
|
||||
let totalDist = 0;
|
||||
for (let i = 1; i < demoData.length; i++) {
|
||||
const dLat = demoData[i].lat - demoData[i-1].lat;
|
||||
const dLng = demoData[i].lng - demoData[i-1].lng;
|
||||
totalDist += Math.sqrt(dLat * dLat + dLng * dLng) * 111; // km
|
||||
}
|
||||
const avgDist = totalDist / (demoData.length - 1);
|
||||
const mode = avgDist > 5 ? 'Driving' : avgDist > 1 ? 'Cycling' : 'Walking';
|
||||
document.getElementById('analyticsInfo').innerHTML = `Total: ${totalDist.toFixed(2)} km | Mode: ${mode}`;
|
||||
|
||||
// Recolor path
|
||||
polylines.forEach(p => map.removeLayer(p));
|
||||
const color = { Driving: 'red', Cycling: 'orange', Walking: 'green' }[mode];
|
||||
const path = demoData.map(p => [p.lat, p.lng]);
|
||||
L.polyline(path, {color, weight: 4}).addTo(map);
|
||||
}
|
||||
|
||||
function updateTimeline() {
|
||||
document.getElementById('timeline').innerHTML = demoData.map(p =>
|
||||
`<div style="padding: 5px; border-left: 3px solid blue; margin: 5px 0;">${new Date(p.time).toLocaleString()}: [${p.lat.toFixed(4)}, ${p.lng.toFixed(4)}]</div>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
const date = document.getElementById('dateFilter').value;
|
||||
const search = document.getElementById('locationFilter').value.toLowerCase();
|
||||
markers.forEach(marker => {
|
||||
const content = marker.getPopup().getContent().toLowerCase();
|
||||
const show = (!date || content.includes(date)) && (!search || content.includes(search));
|
||||
if (show) marker.addTo(map); else map.removeLayer(marker);
|
||||
});
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
location.reload();
|
||||
}
|
||||
|
||||
function exportPins() {
|
||||
document.getElementById('exportContent').textContent = JSON.stringify({ pins, data: demoData }, null, 2);
|
||||
document.getElementById('export').style.display = 'block';
|
||||
document.getElementById('overlay').style.display = 'block';
|
||||
}
|
||||
|
||||
function closeExport() {
|
||||
document.getElementById('export').style.display = 'none';
|
||||
document.getElementById('overlay').style.display = 'none';
|
||||
}
|
||||
|
||||
function copyToClipboard() {
|
||||
navigator.clipboard.writeText(document.getElementById('exportContent').textContent).then(() => alert('Copied!'));
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
document.body.classList.toggle('dark-theme');
|
||||
}
|
||||
|
||||
// Prevent form submit on upload button (extra safety)
|
||||
document.getElementById('uploadForm').addEventListener('submit', e => e.preventDefault());
|
||||
|
||||
initMap();
|
||||
81
public/index.html
Normal file
81
public/index.html
Normal file
@@ -0,0 +1,81 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Location History Visualizer</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.4.1/dist/MarkerCluster.css" />
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.4.1/dist/MarkerCluster.Default.css" />
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap">
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div id="sidebar">
|
||||
<div class="header">Location History Visualizer</div>
|
||||
|
||||
<div class="section drop-zone">
|
||||
<h3>Data Management</h3>
|
||||
<input type="file" id="fileUpload" accept=".csv,.json,.gpx,.kml" style="width: 100%; padding: 8px; margin: 5px 0;">
|
||||
<button class="btn" onclick="uploadFile(event)">Upload & Analyze</button>
|
||||
<p id="uploadStatus"></p>
|
||||
<div style="height: 100px; margin-top: 10px; border: 2px dashed #ddd; border-radius: 4px; display: flex; align-items: center; justify-content: center; background: #f9f9f9; cursor: pointer;">
|
||||
Or drag files here...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Filters</h3>
|
||||
<select id="dateFilter" class="filter-input" onchange="applyFilters()">
|
||||
<option value="all">All Dates</option>
|
||||
</select>
|
||||
<span id="dateCount" style="font-size: 0.8em; color: #666;"></span>
|
||||
<input type="text" id="locationFilter" class="filter-input" placeholder="Search locations" oninput="applyFilters()">
|
||||
<button class="btn" onclick="applyFilters()">Apply</button>
|
||||
<button class="btn btn-secondary" onclick="clearFilters()">Clear</button>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Pins & Annotations</h3>
|
||||
<button class="btn" onclick="togglePinMode()">Toggle Pin Mode</button>
|
||||
<button class="btn btn-secondary" onclick="exportPins()">Export Pins</button>
|
||||
<div id="pinsList"></div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Analytics</h3>
|
||||
<button class="btn" onclick="runVisitDetection()">Detect Visits</button>
|
||||
<button class="btn btn-secondary" onclick="analyzeTrips()">Analyze Trips</button>
|
||||
<p id="analyticsInfo">Load data to see analytics!</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Timeline View</h3>
|
||||
<div id="timeline" style="height: 200px; overflow-y: auto; background: #f8f9fa; padding: 10px; border: 1px solid #ddd; border-radius: 4px;"></div>
|
||||
</div>
|
||||
|
||||
<button class="btn" onclick="toggleTheme()" style="position: absolute; top: 20px; right: 20px; z-index: 1000;">Toggle Theme</button>
|
||||
</div>
|
||||
|
||||
<div id="map" ondragover="handleDragOver(event)" ondrop="handleDrop(event)"></div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Overlay -->
|
||||
<div id="progress" style="position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); display: none; z-index: 10000; display: flex; align-items: center; justify-content: center; color: white; font-size: 18px;">
|
||||
Processing...
|
||||
</div>
|
||||
|
||||
<div id="overlay" onclick="closeExport()" style="display: none;"></div>
|
||||
<div id="export" style="display: none;">
|
||||
<h3>Exported Data</h3>
|
||||
<pre id="exportContent"></pre>
|
||||
<button class="btn" onclick="copyToClipboard()">Copy to Clipboard</button>
|
||||
<button class="btn btn-secondary" onclick="closeExport()">Close</button>
|
||||
</div>
|
||||
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script src="https://unpkg.com/leaflet.markercluster@1.4.1/dist/leaflet.markercluster.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
19
public/styles.css
Normal file
19
public/styles.css
Normal file
@@ -0,0 +1,19 @@
|
||||
body { font-family: 'Roboto', sans-serif; margin: 0; padding: 0; background: #f5f5f5; }
|
||||
#app { display: flex; height: 100vh; }
|
||||
#sidebar { width: 300px; background: white; box-shadow: 2px 0 5px rgba(0,0,0,0.1); overflow-y: auto; padding: 20px; }
|
||||
#map { flex: 1; height: 100%; }
|
||||
.header { font-size: 24px; font-weight: 500; margin-bottom: 20px; color: #333; }
|
||||
.section { margin-bottom: 20px; border: 1px solid #ddd; padding: 15px; border-radius: 8px; }
|
||||
.btn { background: #007bff; color: white; border: none; padding: 10px 15px; border-radius: 4px; cursor: pointer; margin: 5px 0; }
|
||||
.btn:hover { background: #0056b3; }
|
||||
.btn-secondary { background: #6c757d; }
|
||||
.filter-input { width: 100%; padding: 8px; margin: 5px 0; border: 1px solid #ddd; border-radius: 4px; }
|
||||
#timeline { height: 200px; background: #f8f9fa; border: 1px solid #ddd; overflow-x: auto; padding: 10px; margin-top: 10px; }
|
||||
.pin-item { display: flex; align-items: center; padding: 10px; border-bottom: 1px solid #eee; }
|
||||
.pin-color { width: 20px; height: 20px; border-radius: 50%; margin-right: 10px; }
|
||||
.dark-theme { background: #1a1a1a; color: #fff; }
|
||||
.dark-theme .section { background: #2a2a2a; border-color: #444; }
|
||||
.dark-theme #timeline { background: #333; }
|
||||
#export { display: none; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 1000; }
|
||||
#overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 999; }
|
||||
.dark-theme #export { background: #2a2a2a; color: #fff; }
|
||||
240
server.js
Normal file
240
server.js
Normal file
@@ -0,0 +1,240 @@
|
||||
const express = require('express');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const cors = require('cors');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
// Middleware
|
||||
app.use(cors({ origin: 'http://localhost:3000' }));
|
||||
app.use(express.static('public'));
|
||||
app.use(express.json({ limit: '100mb' }));
|
||||
|
||||
// Multer setup
|
||||
const storage = multer.memoryStorage();
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: 100 * 1024 * 1024 }, // 100MB
|
||||
fileFilter: (req, file, cb) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
if (['.csv', '.json', '.gpx', '.kml'].includes(ext)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Unsupported file type. Use CSV, JSON, GPX, or KML.'), false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Demo Data
|
||||
app.get('/api/demo-data', (req, res) => {
|
||||
res.json([
|
||||
{ lat: 34.0522, lng: -118.2437, time: '2025-11-05T09:00:00' },
|
||||
{ lat: 34.0740, lng: -118.2706, time: '2025-11-05T10:15:00' },
|
||||
{ lat: 34.0195, lng: -118.2913, time: '2025-11-05T14:30:00' }
|
||||
]);
|
||||
});
|
||||
|
||||
// Reverse Geocoding
|
||||
app.get('/api/reverse-geocode/:lat/:lng', async (req, res) => {
|
||||
const { lat, lng } = req.params;
|
||||
try {
|
||||
const url = `https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lng}&addressdetails=1&accept-language=en`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`Geocoding API error: ${response.status}`);
|
||||
const data = await response.json();
|
||||
res.json({ address: data.display_name || 'Address not found' });
|
||||
} catch (err) {
|
||||
console.error('Geocoding error:', err.message);
|
||||
res.status(500).json({ error: 'Geocoding service unavailable' });
|
||||
}
|
||||
});
|
||||
|
||||
// Aggressive Sampling & Timeout for Upload
|
||||
app.post('/api/upload', upload.single('file'), async (req, res) => {
|
||||
if (!req.file) {
|
||||
console.log('Upload failed: No file received');
|
||||
return res.status(400).json({ success: false, error: 'No file received' });
|
||||
}
|
||||
|
||||
const filename = req.file.originalname;
|
||||
const ext = path.extname(filename).toLowerCase();
|
||||
const bufferStr = req.file.buffer.toString('utf-8');
|
||||
console.log(`Processing upload: ${filename} (${ext}, size: ${req.file.size} bytes)`);
|
||||
console.log('Starting aggressive sampling mode...');
|
||||
|
||||
let points = [];
|
||||
let uniqueDates = new Set();
|
||||
let startTime = Date.now();
|
||||
const timeout = 20000; // 20s timeout
|
||||
const maxPoints = 2000; // Aggressive cap for preview
|
||||
|
||||
try {
|
||||
if (ext === '.json') {
|
||||
console.log('Parsing JSON with aggressive sampling...');
|
||||
const parsed = JSON.parse(bufferStr);
|
||||
if (!Array.isArray(parsed)) {
|
||||
// Google Timeline format
|
||||
if (parsed.timelineObjects && Array.isArray(parsed.timelineObjects)) {
|
||||
console.log('Parsing Google Timeline JSON (aggressive)...');
|
||||
let count = 0;
|
||||
for (let obj of parsed.timelineObjects) {
|
||||
if (Date.now() - startTime > timeout) {
|
||||
console.log('Timeout in timelineObjects - sampling partial');
|
||||
break;
|
||||
}
|
||||
if (obj.placeVisit && obj.placeVisit.location) {
|
||||
const loc = obj.placeVisit.location;
|
||||
const lat = parseFloat(loc.latitudeE7) / 1e7;
|
||||
const lng = parseFloat(loc.longitudeE7) / 1e7;
|
||||
if (!isNaN(lat) && !isNaN(lng) && lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180) {
|
||||
const time = new Date(parseInt(obj.placeVisit.duration.startTimestampMs)).toISOString();
|
||||
const date = time.split('T')[0];
|
||||
points.push({ lat, lng, time });
|
||||
uniqueDates.add(date);
|
||||
count++;
|
||||
if (count % 100 === 0) setImmediate(() => {}); // Yield
|
||||
if (points.length >= maxPoints) break;
|
||||
}
|
||||
} else if (obj.location && obj.location.latitudeE7) {
|
||||
const loc = obj.location;
|
||||
const lat = parseFloat(loc.latitudeE7) / 1e7;
|
||||
const lng = parseFloat(loc.longitudeE7) / 1e7;
|
||||
if (!isNaN(lat) && !isNaN(lng) && lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180) {
|
||||
const time = new Date(parseInt(loc.timestampMs)).toISOString();
|
||||
const date = time.split('T')[0];
|
||||
points.push({ lat, lng, time });
|
||||
uniqueDates.add(date);
|
||||
count++;
|
||||
if (count % 100 === 0) setImmediate(() => {}); // Yield
|
||||
if (points.length >= maxPoints) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(`Extracted ${points.length} points from Google JSON (capped at ${maxPoints})`);
|
||||
} else {
|
||||
throw new Error('Invalid JSON structure - not an array or Google Timeline');
|
||||
}
|
||||
} else {
|
||||
// Simple array
|
||||
points = parsed.filter((p, i) => i % 10 === 0) // Sample every 10th
|
||||
.filter(p => {
|
||||
const lat = parseFloat(p.lat || p.latitudeE7 / 1e7);
|
||||
const lng = parseFloat(p.lng || p.longitudeE7 / 1e7);
|
||||
const time = p.time || new Date().toISOString();
|
||||
const date = new Date(time).toISOString().split('T')[0];
|
||||
if (!isNaN(lat) && !isNaN(lng) && lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180) {
|
||||
uniqueDates.add(date);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
points = points.slice(0, maxPoints);
|
||||
}
|
||||
} else if (ext === '.csv') {
|
||||
console.log('Parsing CSV with sampling...');
|
||||
const lines = bufferStr.split('\n').filter(line => line.trim());
|
||||
if (lines.length < 2) throw new Error('Invalid CSV');
|
||||
const headers = lines[0].toLowerCase().split(',').map(h => h.trim());
|
||||
const latIdx = headers.findIndex(h => h.includes('lat') || h.includes('latitude'));
|
||||
const lngIdx = headers.findIndex(h => h.includes('lng') || h.includes('longitude'));
|
||||
const timeIdx = headers.findIndex(h => h.includes('time') || h.includes('timestamp'));
|
||||
if (latIdx === -1 || lngIdx === -1) throw new Error('CSV missing lat/lng columns');
|
||||
let count = 0;
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
if (Date.now() - startTime > timeout) break;
|
||||
if (count % 10 === 0) setImmediate(() => {}); // Yield every 10 lines
|
||||
const line = lines[i];
|
||||
const cols = line.split(',').map(c => c.trim());
|
||||
if (cols.length < Math.max(latIdx, lngIdx) + 1) continue;
|
||||
const lat = parseFloat(cols[latIdx]);
|
||||
const lng = parseFloat(cols[lngIdx]);
|
||||
const time = cols[timeIdx] || new Date().toISOString();
|
||||
const date = new Date(time).toISOString().split('T')[0];
|
||||
if (!isNaN(lat) && !isNaN(lng) && lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180) {
|
||||
points.push({ lat, lng, time });
|
||||
uniqueDates.add(date);
|
||||
count++;
|
||||
if (points.length >= maxPoints) break;
|
||||
}
|
||||
}
|
||||
} else if (ext === '.kml') {
|
||||
console.log('Parsing KML with sampling...');
|
||||
const placemarkRegex = /<Placemark>[\s\S]*?<coordinates>([^<]+)<\/coordinates>[\s\S]*?<\/Placemark>/gi;
|
||||
const whenRegex = /<when>([^<]+)<\/when>/i;
|
||||
let match;
|
||||
let count = 0;
|
||||
while ((match = placemarkRegex.exec(bufferStr)) !== null && points.length < maxPoints) {
|
||||
if (Date.now() - startTime > timeout) break;
|
||||
if (count % 50 === 0) setImmediate(() => {});
|
||||
const coords = match[1].trim().split(',');
|
||||
if (coords.length >= 2) {
|
||||
const lng = parseFloat(coords[0]);
|
||||
const lat = parseFloat(coords[1]);
|
||||
if (!isNaN(lat) && !isNaN(lng) && lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180) {
|
||||
// Extract time from when tag if nearby
|
||||
const whenMatch = bufferStr.slice(match.index, match.index + 1000).match(whenRegex);
|
||||
const time = whenMatch ? whenMatch[1] : new Date().toISOString();
|
||||
const date = new Date(time).toISOString().split('T')[0];
|
||||
points.push({ lat, lng, time });
|
||||
uniqueDates.add(date);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (ext === '.gpx') {
|
||||
console.log('Parsing GPX with sampling...');
|
||||
const trkptRegex = /<trkpt lat="([^"]+)" lon="([^"]+)"[^>]*>/gi;
|
||||
let match;
|
||||
let count = 0;
|
||||
while ((match = trkptRegex.exec(bufferStr)) !== null && points.length < maxPoints) {
|
||||
if (Date.now() - startTime > timeout) break;
|
||||
if (count % 50 === 0) setImmediate(() => {});
|
||||
const lat = parseFloat(match[1]);
|
||||
const lng = parseFloat(match[2]);
|
||||
const time = new Date().toISOString(); // Default
|
||||
const date = new Date(time).toISOString().split('T')[0];
|
||||
if (!isNaN(lat) && !isNaN(lng) && lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180) {
|
||||
points.push({ lat, lng, time });
|
||||
uniqueDates.add(date);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (points.length === 0) {
|
||||
throw new Error('No valid points found - file may be empty or invalid format');
|
||||
}
|
||||
|
||||
const datesArray = Array.from(uniqueDates).sort();
|
||||
console.log(`Success: ${points.length} points, ${datesArray.length} dates in ${Date.now() - startTime}ms`);
|
||||
res.json({ success: true, points, uniqueDates: datesArray, count: points.length, sampled: points.length < 10000 });
|
||||
|
||||
} catch (err) {
|
||||
console.error(`Upload error for ${filename}:`, err.message);
|
||||
console.error('Stack:', err.stack);
|
||||
res.status(400).json({ success: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Load Full Data (for large files)
|
||||
app.post('/api/load-full', (req, res) => {
|
||||
// Placeholder - load from memory or file if needed
|
||||
res.json({ success: false, error: 'Full load not implemented yet - use sampling' });
|
||||
});
|
||||
|
||||
// Root route
|
||||
app.get('/', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, 'public', 'index.html'));
|
||||
});
|
||||
|
||||
// Error Handler
|
||||
app.use((err, req, res, next) => {
|
||||
console.error('Global error:', err.message, req.method, req.url);
|
||||
res.status(500).json({ success: false, error: 'Server error - check logs' });
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server running on http://localhost:${PORT}`);
|
||||
console.log('Aggressive anti-stuck mode enabled (20s timeout, 2k point cap)');
|
||||
});
|
||||
Reference in New Issue
Block a user