Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c26f458df7 | |||
| 8d646ecd74 | |||
| 8890e64d0e | |||
| 411d10bbc6 | |||
| 76eed69c8f | |||
| 0bba5b1abe | |||
| 6a2f8856fe | |||
| 394394b387 | |||
| eda4697b03 | |||
| 29048e9d2a | |||
| 0826c606ba | |||
| e5413b4f0c | |||
| b1dc09c714 | |||
| 62959398d5 | |||
| b98bac9cff | |||
| c83049aca5 | |||
| 234ed8ae94 |
@@ -3,7 +3,8 @@
|
|||||||
"allow": [
|
"allow": [
|
||||||
"Bash(npm test:*)",
|
"Bash(npm test:*)",
|
||||||
"Bash(npm run:*)",
|
"Bash(npm run:*)",
|
||||||
"Bash(npx playwright:*)"
|
"Bash(npx playwright:*)",
|
||||||
|
"Bash(node -e ':*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
### Docker (preferred workflow)
|
### Docker (preferred workflow)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose up --build # Start all services (frontend :3050, backend :3001, postgres)
|
docker-compose up --build # Start all services (frontend :3050, backend :3051, postgres)
|
||||||
docker-compose down # Stop all services
|
docker-compose down # Stop all services
|
||||||
docker-compose logs -f # View logs
|
docker-compose logs -f # View logs
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ docker-compose exec frontend npm run test:e2e
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Backend
|
# Backend
|
||||||
cd backend && npm run dev # Nodemon dev server on :3001
|
cd backend && npm run dev # Nodemon dev server on :3051
|
||||||
|
|
||||||
# Frontend
|
# Frontend
|
||||||
cd frontend && npm run start # Vite dev server on :3050
|
cd frontend && npm run start # Vite dev server on :3050
|
||||||
|
|||||||
@@ -68,6 +68,17 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 📊 Data Management
|
||||||
|
The project includes a compressed dataset of ~68,000 cities from Natural Earth.
|
||||||
|
|
||||||
|
| Action | Command |
|
||||||
|
| :--- | :--- |
|
||||||
|
| **Import/Refresh Data** | `./import-cities.sh` |
|
||||||
|
|
||||||
|
*Note: The script will truncate the `cities` table and perform a fresh import of the global dataset stored in `docker/data/cities.csv.gz`.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 📝 Roadmap Highlights
|
## 📝 Roadmap Highlights
|
||||||
- [ ] Implement real ST_DWithin() PostGIS queries in the backend.
|
- [ ] Implement real ST_DWithin() PostGIS queries in the backend.
|
||||||
- [ ] Import full Natural Earth/GeoNames datasets into the `cities` table.
|
- [ ] Import full Natural Earth/GeoNames datasets into the `cities` table.
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ docker-compose up --build
|
|||||||
### 3. Access Application
|
### 3. Access Application
|
||||||
|
|
||||||
- **Frontend**: http://localhost:3050
|
- **Frontend**: http://localhost:3050
|
||||||
- **Backend API**: http://localhost:3001
|
- **Backend API**: http://localhost:3051
|
||||||
- **Database**: localhost:5432 (PostgreSQL with PostGIS)
|
- **Database**: localhost:5432 (PostgreSQL with PostGIS)
|
||||||
|
|
||||||
## 🎮 How to Use
|
## 🎮 How to Use
|
||||||
@@ -220,7 +220,7 @@ npm run dev
|
|||||||
```bash
|
```bash
|
||||||
# Find process using port
|
# Find process using port
|
||||||
lsof -i :3050
|
lsof -i :3050
|
||||||
lsof -i :3001
|
lsof -i :3051
|
||||||
|
|
||||||
# Kill process
|
# Kill process
|
||||||
kill -9 <PID>
|
kill -9 <PID>
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@ RUN npm install
|
|||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Expose port
|
# Expose port
|
||||||
EXPOSE 3001
|
EXPOSE 3051
|
||||||
|
|
||||||
# Start server
|
# Start server
|
||||||
CMD ["npm", "start"]
|
CMD ["npm", "start"]
|
||||||
|
|||||||
+86
-41
@@ -4,9 +4,8 @@ const { Pool } = require('pg');
|
|||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3051;
|
||||||
|
|
||||||
// Database connection pool
|
|
||||||
const pool = new Pool({
|
const pool = new Pool({
|
||||||
connectionString: process.env.DATABASE_URL || 'postgresql://line_of_sight:line_of_sight_pass@postgres:5432/line_of_sight'
|
connectionString: process.env.DATABASE_URL || 'postgresql://line_of_sight:line_of_sight_pass@postgres:5432/line_of_sight'
|
||||||
});
|
});
|
||||||
@@ -14,7 +13,6 @@ const pool = new Pool({
|
|||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
// Helper to calculate destination point given start, bearing, and distance (km)
|
|
||||||
const calculateDestination = (lat, lon, bearing, distance) => {
|
const calculateDestination = (lat, lon, bearing, distance) => {
|
||||||
const R = 6371;
|
const R = 6371;
|
||||||
const brng = (bearing * Math.PI) / 180;
|
const brng = (bearing * Math.PI) / 180;
|
||||||
@@ -39,41 +37,58 @@ const calculateDestination = (lat, lon, bearing, distance) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// Real API endpoint - uses PostGIS for spatial queries
|
const haversineKm = (lat1, lon1, lat2, lon2) => {
|
||||||
|
const R = 6371;
|
||||||
|
const toRad = (d) => d * Math.PI / 180;
|
||||||
|
const dLat = toRad(lat2 - lat1);
|
||||||
|
const dLon = toRad(lon2 - lon1);
|
||||||
|
const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
||||||
|
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
|
};
|
||||||
|
|
||||||
app.get('/api/line-of-sight', async (req, res) => {
|
app.get('/api/line-of-sight', async (req, res) => {
|
||||||
const { lat, lon, direction, tolerance } = req.query;
|
const { lat, lon, direction, tolerance } = req.query;
|
||||||
|
|
||||||
const startLat = parseFloat(lat) || 51.5074;
|
const startLat = parseFloat(lat) || 51.5074;
|
||||||
const startLon = parseFloat(lon) || -0.1278;
|
const startLon = parseFloat(lon) || -0.1278;
|
||||||
const bearing = parseInt(direction) || 0;
|
const bearing = parseInt(direction) || 0;
|
||||||
const toleranceKm = parseInt(tolerance) || 50;
|
const toleranceKm = parseInt(tolerance) || 50;
|
||||||
|
|
||||||
console.log(`Processing real request: lat=${startLat}, lon=${startLon}, bearing=${bearing}, tolerance=${toleranceKm}`);
|
console.log(`Processing request: lat=${startLat}, lon=${startLon}, bearing=${bearing}, tolerance=${toleranceKm}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Generate path points for visualization and spatial query
|
|
||||||
const pathPoints = [];
|
const pathPoints = [];
|
||||||
const totalDistance = 20000;
|
const totalDistance = 40074;
|
||||||
const steps = 80; // More steps for smoother speed transition
|
const steps = 160;
|
||||||
|
|
||||||
for (let i = 0; i <= steps; i++) {
|
for (let i = 0; i <= steps; i++) {
|
||||||
const dist = (totalDistance * i) / steps;
|
const dist = (totalDistance * i) / steps;
|
||||||
pathPoints.push(calculateDestination(startLat, startLon, bearing, dist));
|
pathPoints.push(calculateDestination(startLat, startLon, bearing, dist));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Batch check for 'over water' status for all path points
|
// Bulk land check: use a single query for all points to avoid connection pool exhaustion
|
||||||
// We'll consider a point 'over water' if no city is within 500km
|
const waterStart = Date.now();
|
||||||
const waterChecks = await Promise.all(pathPoints.map(async (p) => {
|
const lons = pathPoints.map(p => p.lon);
|
||||||
const checkQuery = `
|
const lats = pathPoints.map(p => p.lat);
|
||||||
SELECT EXISTS (
|
|
||||||
SELECT 1 FROM cities
|
const waterCheckQuery = `
|
||||||
WHERE ST_DWithin(geom, ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography, 500000)
|
WITH points AS (
|
||||||
LIMIT 1
|
SELECT i, ST_SetSRID(ST_MakePoint(lon, lat), 4326)::geography as p_geom
|
||||||
) as has_land;
|
FROM unnest($1::float8[], $2::float8[]) WITH ORDINALITY AS t(lon, lat, i)
|
||||||
`;
|
)
|
||||||
const res = await pool.query(checkQuery, [p.lon, p.lat]);
|
SELECT i, EXISTS (
|
||||||
return !res.rows[0].has_land;
|
SELECT 1 FROM cities
|
||||||
}));
|
WHERE ST_DWithin(geom, p_geom, 500000)
|
||||||
|
LIMIT 1
|
||||||
|
) as has_land
|
||||||
|
FROM points
|
||||||
|
ORDER BY i;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const waterResults = await pool.query(waterCheckQuery, [lons, lats]);
|
||||||
|
const waterChecks = waterResults.rows.map(r => !r.has_land);
|
||||||
|
|
||||||
|
console.log(`Bulk water check completed in ${Date.now() - waterStart}ms for ${pathPoints.length} points.`);
|
||||||
|
|
||||||
const pathPointsWithWater = pathPoints.map((p, i) => ({
|
const pathPointsWithWater = pathPoints.map((p, i) => ({
|
||||||
...p,
|
...p,
|
||||||
@@ -81,37 +96,67 @@ app.get('/api/line-of-sight', async (req, res) => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const lineWKT = `LINESTRING(${pathPoints.map(p => `${p.lon} ${p.lat}`).join(',')})`;
|
const lineWKT = `LINESTRING(${pathPoints.map(p => `${p.lon} ${p.lat}`).join(',')})`;
|
||||||
|
|
||||||
|
// Top 5 cities per 100 km bin, ranked by population descending
|
||||||
const query = `
|
const query = `
|
||||||
WITH path AS (
|
WITH path AS (
|
||||||
SELECT ST_GeogFromText($1) as route,
|
SELECT ST_GeogFromText($1) as route,
|
||||||
ST_MakePoint($3, $4)::geography as start_node
|
ST_MakePoint($3, $4)::geography as start_node
|
||||||
|
),
|
||||||
|
candidates AS (
|
||||||
|
SELECT
|
||||||
|
id, name, population, country,
|
||||||
|
ST_Y(geom::geometry) as lat,
|
||||||
|
ST_X(geom::geometry) as lon,
|
||||||
|
ST_Distance(geom, (SELECT route FROM path)) / 1000 as distance_off_line_km,
|
||||||
|
ST_Distance(geom, (SELECT start_node FROM path)) / 1000 as distance_from_start_km,
|
||||||
|
ST_LineLocatePoint((SELECT route FROM path)::geography::geometry, geom::geometry) as pos_on_line,
|
||||||
|
FLOOR(ST_LineLocatePoint((SELECT route FROM path)::geography::geometry, geom::geometry) * 200)::int as bin_200km
|
||||||
|
FROM cities
|
||||||
|
WHERE ST_DWithin(geom, (SELECT route FROM path), $2 * 1000)
|
||||||
|
),
|
||||||
|
ranked AS (
|
||||||
|
SELECT *,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY bin_200km ORDER BY population DESC NULLS LAST) as rank_in_bin
|
||||||
|
FROM candidates
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT id, name, population, country, lat, lon,
|
||||||
id,
|
distance_off_line_km, distance_from_start_km, pos_on_line
|
||||||
name,
|
FROM ranked
|
||||||
population,
|
WHERE rank_in_bin <= 10
|
||||||
country,
|
ORDER BY pos_on_line ASC;
|
||||||
ST_Y(geom::geometry) as lat,
|
|
||||||
ST_X(geom::geometry) as lon,
|
|
||||||
ST_Distance(geom, (SELECT route FROM path)) / 1000 as distance_off_line_km,
|
|
||||||
ST_Distance(geom, (SELECT start_node FROM path)) / 1000 as distance_from_start_km,
|
|
||||||
ST_LineLocatePoint((SELECT route FROM path)::geometry, geom::geometry) as pos_on_line
|
|
||||||
FROM cities
|
|
||||||
WHERE ST_DWithin(geom, (SELECT route FROM path), $2 * 1000)
|
|
||||||
ORDER BY pos_on_line ASC
|
|
||||||
LIMIT 200;
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
const result = await pool.query(query, [lineWKT, toleranceKm, startLon, startLat]);
|
const result = await pool.query(query, [lineWKT, toleranceKm, startLon, startLat]);
|
||||||
|
const queryTime = Date.now() - startTime;
|
||||||
|
console.log(`Query completed in ${queryTime}ms. Found ${result.rows.length} candidates.`);
|
||||||
|
|
||||||
|
const startDedupe = Date.now();
|
||||||
|
// Greedy dynamic deduplication...
|
||||||
|
const byPopulation = [...result.rows].sort((a, b) => (b.population || 0) - (a.population || 0));
|
||||||
|
const accepted = [];
|
||||||
|
for (const city of byPopulation) {
|
||||||
|
const cityPop = city.population || 0;
|
||||||
|
const tooClose = accepted.some(s => {
|
||||||
|
const dist = haversineKm(s.lat, s.lon, city.lat, city.lon);
|
||||||
|
const sPop = s.population || 0;
|
||||||
|
if (cityPop > 1000000 && sPop > 1000000) return dist < 30;
|
||||||
|
if (cityPop > 100000 || sPop > 100000) return dist < 50;
|
||||||
|
return dist < 80;
|
||||||
|
});
|
||||||
|
if (!tooClose) accepted.push(city);
|
||||||
|
}
|
||||||
|
accepted.sort((a, b) => a.pos_on_line - b.pos_on_line);
|
||||||
|
console.log(`Deduplication completed in ${Date.now() - startDedupe}ms. Final count: ${accepted.length}`);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
start_point: { lat: startLat, lon: startLon },
|
start_point: { lat: startLat, lon: startLon },
|
||||||
direction: bearing,
|
direction: bearing,
|
||||||
tolerance_km: toleranceKm,
|
tolerance_km: toleranceKm,
|
||||||
conurbations: result.rows.map(row => ({
|
conurbations: accepted.map(row => ({
|
||||||
...row,
|
...row,
|
||||||
name: row.name || 'Unknown',
|
name: row.name || 'Unknown',
|
||||||
country: row.country || 'Unknown',
|
country: row.country || 'Unknown',
|
||||||
@@ -121,13 +166,13 @@ app.get('/api/line-of-sight', async (req, res) => {
|
|||||||
line_coordinates: pathPointsWithWater
|
line_coordinates: pathPointsWithWater
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
console.log(`Request fully processed in ${Date.now() - startTime}ms`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Database query error:', err);
|
console.error('Database query error:', err);
|
||||||
res.status(500).json({ success: false, error: 'Database query failed' });
|
res.status(500).json({ success: false, error: 'Database query failed' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Health check endpoint
|
|
||||||
app.get('/api/health', (req, res) => {
|
app.get('/api/health', (req, res) => {
|
||||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||||
});
|
});
|
||||||
|
|||||||
+5
-10
@@ -1,17 +1,11 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: kartoza/postgis:latest
|
image: kartoza/postgis:latest
|
||||||
platform: linux/arm64
|
|
||||||
container_name: line-of-sight-db
|
container_name: line-of-sight-db
|
||||||
environment:
|
environment:
|
||||||
- POSTGRES_DB=line_of_sight
|
- POSTGRES_DB=line_of_sight
|
||||||
- POSTGRES_USER=line_of_sight
|
- POSTGRES_USER=line_of_sight
|
||||||
- POSTGRES_PASS=line_of_sight_pass
|
- POSTGRES_PASS=line_of_sight_pass
|
||||||
- ALLOW_IP_RANGE=0.0.0.0/0
|
|
||||||
ports:
|
|
||||||
- "5432:5432"
|
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql
|
- pgdata:/var/lib/postgresql
|
||||||
- ./docker/init.sql:/docker-entrypoint-initdb.d/init.sql
|
- ./docker/init.sql:/docker-entrypoint-initdb.d/init.sql
|
||||||
@@ -26,11 +20,12 @@ services:
|
|||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: line-of-sight-backend
|
container_name: line-of-sight-backend
|
||||||
ports:
|
# Removed public port exposure for security; accessible via frontend proxy
|
||||||
- "3001:3001"
|
# ports:
|
||||||
|
# - "3051:3051"
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=postgresql://line_of_sight:line_of_sight_pass@postgres:5432/line_of_sight
|
- DATABASE_URL=postgresql://line_of_sight:line_of_sight_pass@postgres:5432/line_of_sight
|
||||||
- PORT=3001
|
- PORT=3051
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -46,7 +41,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "3050:3050"
|
- "3050:3050"
|
||||||
environment:
|
environment:
|
||||||
- VITE_API_URL=http://localhost:3001/api
|
- VITE_API_URL=/api
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
Binary file not shown.
+2
-2
@@ -12,8 +12,8 @@ COPY . .
|
|||||||
# Expose port
|
# Expose port
|
||||||
EXPOSE 3050
|
EXPOSE 3050
|
||||||
|
|
||||||
# Set environment variable for API URL
|
# Set environment variable for API URL (relative to the frontend host)
|
||||||
ENV VITE_API_URL=http://localhost:3001/api
|
ENV VITE_API_URL=/api
|
||||||
|
|
||||||
# Start development server
|
# Start development server
|
||||||
CMD ["npm", "start"]
|
CMD ["npm", "start"]
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ class Map {
|
|||||||
flyTo() {}
|
flyTo() {}
|
||||||
jumpTo() {}
|
jumpTo() {}
|
||||||
setStyle() { Promise.resolve().then(() => this._emit('style.load')); }
|
setStyle() { Promise.resolve().then(() => this._emit('style.load')); }
|
||||||
|
setProjection() {}
|
||||||
|
hasImage() { return false; }
|
||||||
|
addImage() {}
|
||||||
getCanvas() { return document.createElement('canvas'); }
|
getCanvas() { return document.createElement('canvas'); }
|
||||||
project() { return { x: 0, y: 0 }; }
|
project() { return { x: 0, y: 0 }; }
|
||||||
unproject() { return { lng: 0, lat: 0 }; }
|
unproject() { return { lng: 0, lat: 0 }; }
|
||||||
|
|||||||
+545
-275
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,9 @@ vi.mock('maplibre-gl', () => {
|
|||||||
isStyleLoaded: vi.fn(() => true),
|
isStyleLoaded: vi.fn(() => true),
|
||||||
setSky: vi.fn(),
|
setSky: vi.fn(),
|
||||||
setTerrain: vi.fn(),
|
setTerrain: vi.fn(),
|
||||||
|
setProjection: vi.fn(),
|
||||||
|
hasImage: vi.fn(() => false),
|
||||||
|
addImage: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001/api';
|
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api';
|
||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL: API_BASE_URL,
|
baseURL: API_BASE_URL,
|
||||||
timeout: 10000,
|
timeout: 30000
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const getLineOfSight = async (lat, lon, direction, tolerance) => {
|
export const getLineOfSight = async (lat, lon, direction, tolerance) => {
|
||||||
|
|||||||
+160
-59
@@ -13,6 +13,30 @@
|
|||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.locate-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
left: 10px;
|
||||||
|
z-index: 10;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
background: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 1px 4px rgba(0,0,0,0.3);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #333;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locate-btn:hover {
|
||||||
|
background: #f0f0f0;
|
||||||
|
color: #1a73e8;
|
||||||
|
}
|
||||||
|
|
||||||
.controls {
|
.controls {
|
||||||
width: 350px;
|
width: 350px;
|
||||||
background: white;
|
background: white;
|
||||||
@@ -176,6 +200,7 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
table-layout: fixed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.results-panel th {
|
.results-panel th {
|
||||||
@@ -189,6 +214,13 @@
|
|||||||
padding: 8px 4px;
|
padding: 8px 4px;
|
||||||
border-bottom: 1px solid #ddd;
|
border-bottom: 1px solid #ddd;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-panel tr {
|
||||||
|
will-change: transform, background-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
.results-panel tr:nth-child(even) {
|
.results-panel tr:nth-child(even) {
|
||||||
@@ -322,63 +354,6 @@
|
|||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.city-marker {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
font-family: sans-serif;
|
|
||||||
z-index: 100;
|
|
||||||
cursor: pointer;
|
|
||||||
pointer-events: auto !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.marker-number {
|
|
||||||
background: #2c3e50;
|
|
||||||
color: white;
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: bold;
|
|
||||||
margin-bottom: -4px;
|
|
||||||
border: 1px solid white;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.marker-dot {
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
background: #e74c3c;
|
|
||||||
border: 2px solid white;
|
|
||||||
border-radius: 50%;
|
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.marker-label {
|
|
||||||
background: white;
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: bold;
|
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
|
||||||
margin-top: 2px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.marker-pop {
|
|
||||||
background: #34495e;
|
|
||||||
color: white;
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 10px;
|
|
||||||
margin-top: 2px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.map-info-popup {
|
.map-info-popup {
|
||||||
padding: 5px;
|
padding: 5px;
|
||||||
font-family: sans-serif;
|
font-family: sans-serif;
|
||||||
@@ -411,17 +386,48 @@
|
|||||||
padding: 15px !important;
|
padding: 15px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.projection-row {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.projection-buttons {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
flex: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.projection-buttons button {
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
background: white;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.projection-buttons button:hover {
|
||||||
|
background: #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.projection-buttons button.active-style {
|
||||||
|
background: #34495e;
|
||||||
|
color: white;
|
||||||
|
border-color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.app-container {
|
.app-container {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls {
|
.controls {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: 40vh;
|
max-height: 40vh;
|
||||||
order: -1;
|
order: -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.map-container {
|
.map-container {
|
||||||
height: 60vh;
|
height: 60vh;
|
||||||
}
|
}
|
||||||
@@ -434,3 +440,98 @@
|
|||||||
top: auto;
|
top: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.results-panel tr.passed-row {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-panel tr.current-row {
|
||||||
|
background: #1abc9c !important;
|
||||||
|
color: white;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-panel tr.upcoming-row {
|
||||||
|
background: #eaf4fb !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.now-passing {
|
||||||
|
background: #1abc9c;
|
||||||
|
color: white;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-banner {
|
||||||
|
background: #fdecea;
|
||||||
|
color: #d32f2f;
|
||||||
|
padding: 10px 15px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border-left: 4px solid #d32f2f;
|
||||||
|
font-size: 13px;
|
||||||
|
margin: 10px 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fly-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fly-button {
|
||||||
|
flex: 3;
|
||||||
|
padding: 12px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fly-button.play {
|
||||||
|
background: #3498db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fly-button.play:hover {
|
||||||
|
background: #2980b9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fly-button.pause {
|
||||||
|
background: #e74c3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fly-button.pause:hover {
|
||||||
|
background: #c0392b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reset-flight-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 12px;
|
||||||
|
background: #95a5a6;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reset-flight-btn:hover:not(:disabled) {
|
||||||
|
background: #7f8c8d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reset-flight-btn:disabled {
|
||||||
|
background: #ecf0f1;
|
||||||
|
color: #bdc3c7;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
port: 3050,
|
port: 3050,
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
|
allowedHosts: true,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://backend:3051',
|
||||||
|
changeOrigin: true
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
outDir: 'build',
|
outDir: 'build',
|
||||||
|
|||||||
Executable
+66
@@ -0,0 +1,66 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
CONTAINER_NAME="line-of-sight-db"
|
||||||
|
DB_USER="line_of_sight"
|
||||||
|
DB_NAME="line_of_sight"
|
||||||
|
DATA_FILE="./docker/data/cities.csv.gz"
|
||||||
|
|
||||||
|
# Check if docker is available using the known path
|
||||||
|
DOCKER_BIN="/usr/local/bin/docker"
|
||||||
|
if [ ! -f "$DOCKER_BIN" ]; then
|
||||||
|
DOCKER_BIN=$(which docker)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$DOCKER_BIN" ]; then
|
||||||
|
echo "Error: docker command not found."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if data file exists
|
||||||
|
if [ ! -f "$DATA_FILE" ]; then
|
||||||
|
echo "Error: Data file $DATA_FILE not found."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if container is running
|
||||||
|
if ! "$DOCKER_BIN" ps | grep -q "$CONTAINER_NAME"; then
|
||||||
|
echo "Error: Container $CONTAINER_NAME is not running."
|
||||||
|
echo "Please run 'docker-compose up -d' first."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "🚀 Starting database initialization..."
|
||||||
|
|
||||||
|
# Use a HEREDOC to execute multi-line SQL
|
||||||
|
# We load into a temp table first to handle the WKT -> Geography conversion
|
||||||
|
IMPORT_SQL=$(cat <<EOF
|
||||||
|
CREATE TEMP TABLE tmp_cities (
|
||||||
|
name VARCHAR(255),
|
||||||
|
population INTEGER,
|
||||||
|
country VARCHAR(100),
|
||||||
|
geom_wkt TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
COPY tmp_cities FROM STDIN WITH CSV HEADER;
|
||||||
|
|
||||||
|
TRUNCATE cities;
|
||||||
|
|
||||||
|
INSERT INTO cities (name, population, country, geom)
|
||||||
|
SELECT
|
||||||
|
name,
|
||||||
|
population,
|
||||||
|
country,
|
||||||
|
ST_GeomFromText(geom_wkt, 4326)::geography
|
||||||
|
FROM tmp_cities;
|
||||||
|
|
||||||
|
DROP TABLE tmp_cities;
|
||||||
|
|
||||||
|
SELECT count(*) || ' cities successfully imported.' as result FROM cities;
|
||||||
|
EOF
|
||||||
|
)
|
||||||
|
|
||||||
|
# Stream the gzipped data into psql
|
||||||
|
gunzip -c "$DATA_FILE" | "$DOCKER_BIN" exec -i -e PGPASSWORD=line_of_sight_pass "$CONTAINER_NAME" psql -U "$DB_USER" -d "$DB_NAME" -h 127.0.0.1 -c "$IMPORT_SQL"
|
||||||
|
|
||||||
|
echo "✅ Initialization complete."
|
||||||
Reference in New Issue
Block a user