2026-03-26 22:27:42 +08:00
|
|
|
const express = require('express');
|
|
|
|
|
const path = require('path');
|
2026-03-26 23:38:21 +08:00
|
|
|
const swaggerJsdoc = require('swagger-jsdoc');
|
|
|
|
|
const swaggerUi = require('swagger-ui-express');
|
2026-03-26 22:27:42 +08:00
|
|
|
|
|
|
|
|
const app = express();
|
|
|
|
|
const PORT = process.env.PORT || 3000;
|
|
|
|
|
|
2026-03-29 00:23:50 +08:00
|
|
|
const markers = [];
|
|
|
|
|
const circles = [];
|
2026-03-26 23:38:21 +08:00
|
|
|
const alerts = [];
|
|
|
|
|
|
2026-03-26 22:27:42 +08:00
|
|
|
app.use(express.static(path.join(__dirname, 'public')));
|
|
|
|
|
|
2026-03-29 00:23:50 +08:00
|
|
|
app.get('/openapi.json', (req, res) => {
|
|
|
|
|
res.sendFile(path.join(__dirname, 'openapi.json'));
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-26 23:38:21 +08:00
|
|
|
const swaggerOptions = {
|
|
|
|
|
definition: {
|
|
|
|
|
openapi: '3.0.0',
|
|
|
|
|
info: {
|
|
|
|
|
title: 'OpenStreetMap Service API',
|
|
|
|
|
version: '1.0.0',
|
|
|
|
|
description: 'API for displaying markers and circles on OpenStreetMap',
|
|
|
|
|
contact: {
|
|
|
|
|
name: 'API Support'
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
servers: [
|
|
|
|
|
{
|
|
|
|
|
url: `http://localhost:${PORT}`,
|
|
|
|
|
description: 'Development server'
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
tags: [
|
|
|
|
|
{
|
|
|
|
|
name: 'Map',
|
|
|
|
|
description: 'Map visualization endpoints'
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
},
|
|
|
|
|
apis: ['./server.js'],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const swaggerSpec = swaggerJsdoc(swaggerOptions);
|
|
|
|
|
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec));
|
|
|
|
|
app.get('/api-docs.json', (req, res) => {
|
|
|
|
|
res.setHeader('Content-Type', 'application/json');
|
|
|
|
|
res.send(swaggerSpec);
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-26 22:27:42 +08:00
|
|
|
app.get('/', (req, res) => {
|
|
|
|
|
res.sendFile(path.join(__dirname, 'public', 'index.html'));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.get('/api/marker', (req, res) => {
|
|
|
|
|
const { lat, lng, icon, title } = req.query;
|
|
|
|
|
|
|
|
|
|
if (!lat || !lng) {
|
|
|
|
|
return res.status(400).json({
|
|
|
|
|
error: 'Missing required parameters: lat and lng'
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const latNum = parseFloat(lat);
|
|
|
|
|
const lngNum = parseFloat(lng);
|
|
|
|
|
|
|
|
|
|
if (isNaN(latNum) || isNaN(lngNum)) {
|
|
|
|
|
return res.status(400).json({
|
|
|
|
|
error: 'Invalid coordinates: lat and lng must be numbers'
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (latNum < -90 || latNum > 90 || lngNum < -180 || lngNum > 180) {
|
|
|
|
|
return res.status(400).json({
|
|
|
|
|
error: 'Invalid coordinates: lat must be between -90 and 90, lng must be between -180 and 180'
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-29 00:23:50 +08:00
|
|
|
const marker = {
|
|
|
|
|
id: Date.now().toString(),
|
|
|
|
|
lat: latNum,
|
|
|
|
|
lng: lngNum,
|
|
|
|
|
icon: icon || 'default',
|
|
|
|
|
title: title || `Marker at ${latNum}, ${lngNum}`
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
markers.push(marker);
|
|
|
|
|
|
2026-03-26 22:27:42 +08:00
|
|
|
res.json({
|
|
|
|
|
success: true,
|
|
|
|
|
type: 'marker',
|
2026-03-29 00:23:50 +08:00
|
|
|
data: marker
|
2026-03-26 22:27:42 +08:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-29 00:23:50 +08:00
|
|
|
app.get('/api/markers', (req, res) => {
|
|
|
|
|
res.json({ markers });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.post('/api/markers/clear', (req, res) => {
|
|
|
|
|
markers.length = 0;
|
|
|
|
|
res.json({ success: true });
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-26 22:27:42 +08:00
|
|
|
app.get('/api/circle', (req, res) => {
|
|
|
|
|
const { lat, lng, radius, icon, title } = req.query;
|
|
|
|
|
|
|
|
|
|
if (!lat || !lng || !radius) {
|
|
|
|
|
return res.status(400).json({
|
|
|
|
|
error: 'Missing required parameters: lat, lng, and radius'
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const latNum = parseFloat(lat);
|
|
|
|
|
const lngNum = parseFloat(lng);
|
|
|
|
|
const radiusNum = parseFloat(radius);
|
|
|
|
|
|
|
|
|
|
if (isNaN(latNum) || isNaN(lngNum) || isNaN(radiusNum)) {
|
|
|
|
|
return res.status(400).json({
|
|
|
|
|
error: 'Invalid parameters: lat, lng, and radius must be numbers'
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (latNum < -90 || latNum > 90 || lngNum < -180 || lngNum > 180) {
|
|
|
|
|
return res.status(400).json({
|
|
|
|
|
error: 'Invalid coordinates: lat must be between -90 and 90, lng must be between -180 and 180'
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (radiusNum <= 0) {
|
|
|
|
|
return res.status(400).json({
|
|
|
|
|
error: 'Invalid radius: must be a positive number'
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-29 00:23:50 +08:00
|
|
|
const circle = {
|
|
|
|
|
id: Date.now().toString(),
|
|
|
|
|
lat: latNum,
|
|
|
|
|
lng: lngNum,
|
|
|
|
|
radius: radiusNum,
|
|
|
|
|
icon: icon || 'default',
|
|
|
|
|
title: title || `Circle at ${latNum}, ${lngNum} with radius ${radiusNum}`
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
circles.push(circle);
|
|
|
|
|
|
2026-03-26 22:27:42 +08:00
|
|
|
res.json({
|
|
|
|
|
success: true,
|
|
|
|
|
type: 'circle',
|
2026-03-29 00:23:50 +08:00
|
|
|
data: circle
|
2026-03-26 22:27:42 +08:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-29 00:23:50 +08:00
|
|
|
app.get('/api/circles', (req, res) => {
|
|
|
|
|
res.json({ circles });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.post('/api/circles/clear', (req, res) => {
|
|
|
|
|
circles.length = 0;
|
|
|
|
|
res.json({ success: true });
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-26 23:38:21 +08:00
|
|
|
app.post('/api/alert', (req, res) => {
|
|
|
|
|
const { message, type } = req.query || req.body;
|
|
|
|
|
|
|
|
|
|
if (!message) {
|
|
|
|
|
return res.status(400).json({ error: 'Missing required parameter: message' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const alertType = ['danger', 'warning', 'info', 'success'].includes(type) ? type : 'danger';
|
|
|
|
|
|
|
|
|
|
const alert = {
|
|
|
|
|
id: Date.now().toString(),
|
|
|
|
|
message: message,
|
|
|
|
|
type: alertType,
|
|
|
|
|
timestamp: new Date().toISOString()
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
alerts.push(alert);
|
|
|
|
|
|
|
|
|
|
res.json({ success: true, alert });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.get('/api/alerts', (req, res) => {
|
|
|
|
|
res.json({ alerts });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.post('/api/alerts/clear', (req, res) => {
|
|
|
|
|
alerts.length = 0;
|
|
|
|
|
res.json({ success: true });
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-29 00:23:50 +08:00
|
|
|
app.get('/api/embed', (req, res) => {
|
|
|
|
|
res.send(`<!DOCTYPE html>
|
|
|
|
|
<html lang="en">
|
|
|
|
|
<head>
|
|
|
|
|
<meta charset="UTF-8">
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
|
|
|
<title>OpenStreetMap Embed</title>
|
|
|
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
|
|
|
|
<style>
|
|
|
|
|
body { margin: 0; padding: 0; }
|
|
|
|
|
#map { width: 100vw; height: 100vh; }
|
|
|
|
|
</style>
|
|
|
|
|
</head>
|
|
|
|
|
<body>
|
|
|
|
|
<div id="map"></div>
|
|
|
|
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
|
|
|
|
<script>
|
|
|
|
|
const map = L.map('map').setView([20, 0], 2);
|
|
|
|
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
|
|
|
attribution: '© OpenStreetMap contributors',
|
|
|
|
|
maxZoom: 19
|
|
|
|
|
}).addTo(map);
|
|
|
|
|
|
|
|
|
|
const defaultIcon = L.Icon.Default;
|
|
|
|
|
const customIcons = {};
|
|
|
|
|
|
|
|
|
|
function createCustomIcon(iconUrl) {
|
|
|
|
|
if (!iconUrl || iconUrl === 'default') return defaultIcon;
|
|
|
|
|
if (customIcons[iconUrl]) return customIcons[iconUrl];
|
|
|
|
|
const icon = L.icon({
|
|
|
|
|
iconUrl: iconUrl,
|
|
|
|
|
iconSize: [32, 32],
|
|
|
|
|
iconAnchor: [16, 32],
|
|
|
|
|
popupAnchor: [0, -32]
|
|
|
|
|
});
|
|
|
|
|
customIcons[iconUrl] = icon;
|
|
|
|
|
return icon;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadMarkers() {
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch('/api/markers');
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
if (data.markers) {
|
|
|
|
|
data.markers.forEach(marker => {
|
|
|
|
|
const icon = createCustomIcon(marker.icon);
|
|
|
|
|
L.marker([marker.lat, marker.lng], { icon: icon })
|
|
|
|
|
.addTo(map)
|
|
|
|
|
.bindPopup(marker.title);
|
|
|
|
|
});
|
|
|
|
|
if (data.markers.length > 0) {
|
|
|
|
|
const last = data.markers[data.markers.length - 1];
|
|
|
|
|
map.setView([last.lat, last.lng], 13);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (e) { console.error('Error loading markers:', e); }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadCircles() {
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch('/api/circles');
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
if (data.circles) {
|
|
|
|
|
data.circles.forEach(circle => {
|
|
|
|
|
const circleLayer = L.circle([circle.lat, circle.lng], {
|
|
|
|
|
radius: circle.radius,
|
|
|
|
|
color: '#3388ff',
|
|
|
|
|
fillColor: '#3388ff',
|
|
|
|
|
fillOpacity: 0.3,
|
|
|
|
|
weight: 2
|
|
|
|
|
}).addTo(map);
|
|
|
|
|
if (circle.icon && circle.icon !== 'default') {
|
|
|
|
|
const icon = createCustomIcon(circle.icon);
|
|
|
|
|
L.marker([circle.lat, circle.lng], { icon: icon })
|
|
|
|
|
.addTo(map)
|
|
|
|
|
.bindPopup(circle.title);
|
|
|
|
|
} else {
|
|
|
|
|
circleLayer.bindPopup(circle.title);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
} catch (e) { console.error('Error loading circles:', e); }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function pollAlerts() {
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch('/api/alerts');
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
if (data.alerts) {
|
|
|
|
|
const colors = { danger: '#dc2626', warning: '#f59e0b', info: '#2563eb', success: '#16a34a' };
|
|
|
|
|
data.alerts.forEach(alert => {
|
|
|
|
|
const color = colors[alert.type] || colors.danger;
|
|
|
|
|
L.popup()
|
|
|
|
|
.setLatLng(map.getCenter())
|
|
|
|
|
.setContent(\`<div style="background:\${color};color:white;padding:10px;border-radius:5px;">\${alert.message}</div>\`)
|
|
|
|
|
.openOn(map);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
} catch (e) { console.error('Error polling alerts:', e); }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
loadMarkers();
|
|
|
|
|
loadCircles();
|
|
|
|
|
setInterval(pollAlerts, 5000);
|
|
|
|
|
</script>
|
|
|
|
|
</body>
|
|
|
|
|
</html>`);
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-26 22:27:42 +08:00
|
|
|
app.listen(PORT, () => {
|
|
|
|
|
console.log(`OpenStreetMap Service running at http://localhost:${PORT}`);
|
2026-03-26 23:38:21 +08:00
|
|
|
console.log(`Swagger docs available at http://localhost:${PORT}/api-docs`);
|
2026-03-29 00:23:50 +08:00
|
|
|
console.log(`Embed API available at http://localhost:${PORT}/api/embed`);
|
2026-03-26 22:27:42 +08:00
|
|
|
});
|