sc/full-world-wrap #6
@@ -3,7 +3,8 @@
|
||||
"allow": [
|
||||
"Bash(npm test:*)",
|
||||
"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)
|
||||
|
||||
```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 logs -f # View logs
|
||||
|
||||
@@ -25,7 +25,7 @@ docker-compose exec frontend npm run test:e2e
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
cd backend && npm run dev # Nodemon dev server on :3001
|
||||
cd backend && npm run dev # Nodemon dev server on :3051
|
||||
|
||||
# Frontend
|
||||
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
|
||||
- [ ] Implement real ST_DWithin() PostGIS queries in the backend.
|
||||
- [ ] Import full Natural Earth/GeoNames datasets into the `cities` table.
|
||||
|
||||
@@ -52,7 +52,7 @@ docker-compose up --build
|
||||
### 3. Access Application
|
||||
|
||||
- **Frontend**: http://localhost:3050
|
||||
- **Backend API**: http://localhost:3001
|
||||
- **Backend API**: http://localhost:3051
|
||||
- **Database**: localhost:5432 (PostgreSQL with PostGIS)
|
||||
|
||||
## 🎮 How to Use
|
||||
@@ -220,7 +220,7 @@ npm run dev
|
||||
```bash
|
||||
# Find process using port
|
||||
lsof -i :3050
|
||||
lsof -i :3001
|
||||
lsof -i :3051
|
||||
|
||||
# Kill process
|
||||
kill -9 <PID>
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ RUN npm install
|
||||
COPY . .
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3001
|
||||
EXPOSE 3051
|
||||
|
||||
# Start server
|
||||
CMD ["npm", "start"]
|
||||
|
||||
+86
-41
@@ -4,9 +4,8 @@ const { Pool } = require('pg');
|
||||
require('dotenv').config();
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
const PORT = process.env.PORT || 3051;
|
||||
|
||||
// Database connection pool
|
||||
const pool = new Pool({
|
||||
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(express.json());
|
||||
|
||||
// Helper to calculate destination point given start, bearing, and distance (km)
|
||||
const calculateDestination = (lat, lon, bearing, distance) => {
|
||||
const R = 6371;
|
||||
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) => {
|
||||
const { lat, lon, direction, tolerance } = req.query;
|
||||
|
||||
|
||||
const startLat = parseFloat(lat) || 51.5074;
|
||||
const startLon = parseFloat(lon) || -0.1278;
|
||||
const bearing = parseInt(direction) || 0;
|
||||
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 {
|
||||
// Generate path points for visualization and spatial query
|
||||
const pathPoints = [];
|
||||
const totalDistance = 20000;
|
||||
const steps = 80; // More steps for smoother speed transition
|
||||
|
||||
const totalDistance = 40074;
|
||||
const steps = 160;
|
||||
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const dist = (totalDistance * i) / steps;
|
||||
pathPoints.push(calculateDestination(startLat, startLon, bearing, dist));
|
||||
}
|
||||
|
||||
// Batch check for 'over water' status for all path points
|
||||
// We'll consider a point 'over water' if no city is within 500km
|
||||
const waterChecks = await Promise.all(pathPoints.map(async (p) => {
|
||||
const checkQuery = `
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM cities
|
||||
WHERE ST_DWithin(geom, ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography, 500000)
|
||||
LIMIT 1
|
||||
) as has_land;
|
||||
`;
|
||||
const res = await pool.query(checkQuery, [p.lon, p.lat]);
|
||||
return !res.rows[0].has_land;
|
||||
}));
|
||||
// Bulk land check: use a single query for all points to avoid connection pool exhaustion
|
||||
const waterStart = Date.now();
|
||||
const lons = pathPoints.map(p => p.lon);
|
||||
const lats = pathPoints.map(p => p.lat);
|
||||
|
||||
const waterCheckQuery = `
|
||||
WITH points AS (
|
||||
SELECT i, ST_SetSRID(ST_MakePoint(lon, lat), 4326)::geography as p_geom
|
||||
FROM unnest($1::float8[], $2::float8[]) WITH ORDINALITY AS t(lon, lat, i)
|
||||
)
|
||||
SELECT i, EXISTS (
|
||||
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) => ({
|
||||
...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(',')})`;
|
||||
|
||||
|
||||
// Top 5 cities per 100 km bin, ranked by population descending
|
||||
const query = `
|
||||
WITH path AS (
|
||||
SELECT ST_GeogFromText($1) as route,
|
||||
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
|
||||
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)::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;
|
||||
SELECT id, name, population, country, lat, lon,
|
||||
distance_off_line_km, distance_from_start_km, pos_on_line
|
||||
FROM ranked
|
||||
WHERE rank_in_bin <= 10
|
||||
ORDER BY pos_on_line ASC;
|
||||
`;
|
||||
|
||||
const startTime = Date.now();
|
||||
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({
|
||||
success: true,
|
||||
data: {
|
||||
start_point: { lat: startLat, lon: startLon },
|
||||
direction: bearing,
|
||||
tolerance_km: toleranceKm,
|
||||
conurbations: result.rows.map(row => ({
|
||||
conurbations: accepted.map(row => ({
|
||||
...row,
|
||||
name: row.name || 'Unknown',
|
||||
country: row.country || 'Unknown',
|
||||
@@ -121,13 +166,13 @@ app.get('/api/line-of-sight', async (req, res) => {
|
||||
line_coordinates: pathPointsWithWater
|
||||
}
|
||||
});
|
||||
console.log(`Request fully processed in ${Date.now() - startTime}ms`);
|
||||
} catch (err) {
|
||||
console.error('Database query error:', err);
|
||||
res.status(500).json({ success: false, error: 'Database query failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
+5
-10
@@ -1,17 +1,11 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: kartoza/postgis:latest
|
||||
platform: linux/arm64
|
||||
container_name: line-of-sight-db
|
||||
environment:
|
||||
- POSTGRES_DB=line_of_sight
|
||||
- POSTGRES_USER=line_of_sight
|
||||
- POSTGRES_PASS=line_of_sight_pass
|
||||
- ALLOW_IP_RANGE=0.0.0.0/0
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql
|
||||
- ./docker/init.sql:/docker-entrypoint-initdb.d/init.sql
|
||||
@@ -26,11 +20,12 @@ services:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: line-of-sight-backend
|
||||
ports:
|
||||
- "3001:3001"
|
||||
# Removed public port exposure for security; accessible via frontend proxy
|
||||
# ports:
|
||||
# - "3051:3051"
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://line_of_sight:line_of_sight_pass@postgres:5432/line_of_sight
|
||||
- PORT=3001
|
||||
- PORT=3051
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
@@ -46,7 +41,7 @@ services:
|
||||
ports:
|
||||
- "3050:3050"
|
||||
environment:
|
||||
- VITE_API_URL=http://localhost:3001/api
|
||||
- VITE_API_URL=/api
|
||||
depends_on:
|
||||
- backend
|
||||
volumes:
|
||||
|
||||
Binary file not shown.
+2
-2
@@ -12,8 +12,8 @@ COPY . .
|
||||
# Expose port
|
||||
EXPOSE 3050
|
||||
|
||||
# Set environment variable for API URL
|
||||
ENV VITE_API_URL=http://localhost:3001/api
|
||||
# Set environment variable for API URL (relative to the frontend host)
|
||||
ENV VITE_API_URL=/api
|
||||
|
||||
# Start development server
|
||||
CMD ["npm", "start"]
|
||||
|
||||
@@ -33,6 +33,9 @@ class Map {
|
||||
flyTo() {}
|
||||
jumpTo() {}
|
||||
setStyle() { Promise.resolve().then(() => this._emit('style.load')); }
|
||||
setProjection() {}
|
||||
hasImage() { return false; }
|
||||
addImage() {}
|
||||
getCanvas() { return document.createElement('canvas'); }
|
||||
project() { return { x: 0, y: 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),
|
||||
setSky: vi.fn(),
|
||||
setTerrain: vi.fn(),
|
||||
setProjection: vi.fn(),
|
||||
hasImage: vi.fn(() => false),
|
||||
addImage: vi.fn(),
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
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({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
timeout: 30000
|
||||
});
|
||||
|
||||
export const getLineOfSight = async (lat, lon, direction, tolerance) => {
|
||||
|
||||
+160
-59
@@ -13,6 +13,30 @@
|
||||
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 {
|
||||
width: 350px;
|
||||
background: white;
|
||||
@@ -176,6 +200,7 @@
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.results-panel th {
|
||||
@@ -189,6 +214,13 @@
|
||||
padding: 8px 4px;
|
||||
border-bottom: 1px solid #ddd;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.results-panel tr {
|
||||
will-change: transform, background-color;
|
||||
}
|
||||
|
||||
.results-panel tr:nth-child(even) {
|
||||
@@ -322,63 +354,6 @@
|
||||
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 {
|
||||
padding: 5px;
|
||||
font-family: sans-serif;
|
||||
@@ -411,17 +386,48 @@
|
||||
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) {
|
||||
.app-container {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
|
||||
.controls {
|
||||
width: 100%;
|
||||
max-height: 40vh;
|
||||
order: -1;
|
||||
}
|
||||
|
||||
|
||||
.map-container {
|
||||
height: 60vh;
|
||||
}
|
||||
@@ -434,3 +440,98 @@
|
||||
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: {
|
||||
port: 3050,
|
||||
host: '0.0.0.0',
|
||||
allowedHosts: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://backend:3051',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
},
|
||||
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