Initial commit: witsy project setup with docs and code

This commit is contained in:
maq
2025-11-06 09:48:46 -08:00
commit 66ed48b1b8
9 changed files with 2268 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules/
.env
.DS_Store
logs/
*.log

77
Push to Gitea for Memories.sh Executable file
View 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
View 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

File diff suppressed because it is too large Load Diff

29
package.json Normal file
View 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
View 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
View 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
View 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
View 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)');
});