socket added
14
.gitignore
vendored
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
/node_modules
|
/node_modules
|
||||||
|
*/node_modules
|
||||||
/.pnp
|
/.pnp
|
||||||
.pnp.*
|
.pnp.*
|
||||||
.yarn/*
|
.yarn/*
|
||||||
|
|
@ -31,7 +32,9 @@ yarn-error.log*
|
||||||
.pnpm-debug.log*
|
.pnpm-debug.log*
|
||||||
|
|
||||||
# env files (can opt-in for committing if needed)
|
# env files (can opt-in for committing if needed)
|
||||||
.env*
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.production
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|
@ -39,3 +42,12 @@ yarn-error.log*
|
||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
.next/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
1486
backend/package-lock.json
generated
Normal file
27
backend/package.json
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
{
|
||||||
|
"name": "backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1",
|
||||||
|
"start": "nodemon server.js",
|
||||||
|
"dev": "nodemon server.js"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"type": "commonjs",
|
||||||
|
"dependencies": {
|
||||||
|
"all-the-cities": "^3.1.0",
|
||||||
|
"axios": "^1.12.2",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^17.2.2",
|
||||||
|
"express": "^5.1.0",
|
||||||
|
"express-fileupload": "^1.5.2",
|
||||||
|
"helmet": "^8.1.0",
|
||||||
|
"morgan": "^1.10.1",
|
||||||
|
"socket.io": "^4.8.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
158
backend/server.js
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
require("dotenv").config();
|
||||||
|
const express = require("express");
|
||||||
|
const http = require("http");
|
||||||
|
const morgan = require("morgan");
|
||||||
|
const bodyParser = require("body-parser");
|
||||||
|
const cookieParser = require("cookie-parser");
|
||||||
|
const fileUpload = require("express-fileupload");
|
||||||
|
const helmet = require("helmet");
|
||||||
|
const cors = require("cors");
|
||||||
|
const flightStore = require("./src/stores/flightStore");
|
||||||
|
const flighData = require("./src/modules/data.js");
|
||||||
|
const app = express();
|
||||||
|
const { init } = require("./src/modules/socket.js");
|
||||||
|
const { initFlightSockets } = require("./src/controllers/flights");
|
||||||
|
|
||||||
|
const RELEASE = require("./package.json").version;
|
||||||
|
const NODE_ENV = process.env.NODE_ENV || "production";
|
||||||
|
const NODE_PORT = process.env.NODE_PORT || 3001;
|
||||||
|
|
||||||
|
app.set("port", NODE_PORT);
|
||||||
|
app.set("env", NODE_ENV);
|
||||||
|
app.set("ver", RELEASE);
|
||||||
|
|
||||||
|
function logRequests(req, res, next) {
|
||||||
|
console.log(`Incoming Request: ${req.method} ${req.url}`);
|
||||||
|
console.log(`Origin: ${req.headers.origin}`);
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
app.use(logRequests);
|
||||||
|
|
||||||
|
const allowedOrigins = ["http://localhost:3000"];
|
||||||
|
|
||||||
|
// if (NODE_ENV === "development" || NODE_ENV === "staging") {
|
||||||
|
// allowedOrigins.push("http://localhost:3000");
|
||||||
|
// }
|
||||||
|
|
||||||
|
const corsOptions = {
|
||||||
|
origin: function (origin, callback) {
|
||||||
|
if (!origin || allowedOrigins.indexOf(origin) !== -1) {
|
||||||
|
callback(null, true);
|
||||||
|
} else {
|
||||||
|
callback(new Error("Not allowed by CORS"));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
allowedHeaders: [
|
||||||
|
"Accept-Version",
|
||||||
|
"Authorization",
|
||||||
|
"Credentials",
|
||||||
|
"Content-Type",
|
||||||
|
"baggage",
|
||||||
|
],
|
||||||
|
credentials: true,
|
||||||
|
methods: "GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS",
|
||||||
|
optionsSuccessStatus: 204,
|
||||||
|
};
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
helmet({
|
||||||
|
contentSecurityPolicy: {
|
||||||
|
directives: {
|
||||||
|
"default-src": ["'self'"],
|
||||||
|
"connect-src": ["'self'", "ws:", "wss:", ...allowedOrigins],
|
||||||
|
"script-src": ["'self'", "'unsafe-inline'"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
app.disable("x-powered-by");
|
||||||
|
|
||||||
|
app.use(cors(corsOptions));
|
||||||
|
|
||||||
|
app.use(bodyParser.json({ limit: "200mb" }));
|
||||||
|
app.use(bodyParser.urlencoded({ extended: true, limit: "200mb" }));
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
fileUpload({
|
||||||
|
limits: { fileSize: 200 * 1024 * 1024 }, // 200MB
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
app.use(cookieParser());
|
||||||
|
|
||||||
|
//Socket io
|
||||||
|
const server = http.createServer(app);
|
||||||
|
const io = init(server, {
|
||||||
|
cors: { origin: allowedOrigins },
|
||||||
|
});
|
||||||
|
initFlightSockets(io);
|
||||||
|
|
||||||
|
app.options(/.*/, cors(corsOptions));
|
||||||
|
app.set("trust proxy", true);
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
morgan(
|
||||||
|
":remote-addr - :remote-user - [:date[clf]] ':method :url HTTP/:http-version' :status :res[content-length] ':referrer' ':user-agent'",
|
||||||
|
{
|
||||||
|
stream: {
|
||||||
|
write: (message, encoding) => {
|
||||||
|
console.log(message.substring(0, message.lastIndexOf("\n")));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
setInterval(flighData.getAllFlights, 2000);
|
||||||
|
|
||||||
|
const routesBasePath = require("./src/routes");
|
||||||
|
app.use("/api/v1", routesBasePath);
|
||||||
|
|
||||||
|
app.use((err, req, res, next) => {
|
||||||
|
console.error(err.stack); // Log the error for debugging
|
||||||
|
const statusCode = err.statusCode || 500;
|
||||||
|
const message = err.message || "Something went wrong!";
|
||||||
|
|
||||||
|
res.status(statusCode).json({
|
||||||
|
status: "error",
|
||||||
|
message: message,
|
||||||
|
path: req.path,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use((err, req, res, next) => {
|
||||||
|
console.error("Error Details:", {
|
||||||
|
message: err.message,
|
||||||
|
stack: err.stack,
|
||||||
|
url: req.url,
|
||||||
|
method: req.method,
|
||||||
|
ip: req.ip,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusCode = err.statusCode || err.status || 500;
|
||||||
|
|
||||||
|
const message =
|
||||||
|
process.env.NODE_ENV === "production"
|
||||||
|
? "Internal Server Error"
|
||||||
|
: err.message;
|
||||||
|
|
||||||
|
res.status(statusCode).json({
|
||||||
|
error: message,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
...(process.env.NODE_ENV !== "production" && {
|
||||||
|
stack: err.stack,
|
||||||
|
details: err,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set listening port
|
||||||
|
server.listen(NODE_PORT, async () => {
|
||||||
|
console.log(
|
||||||
|
`Express Server with Socket.io started on Port: ${app.get(
|
||||||
|
"port"
|
||||||
|
)} | Environment: ${app.get("env")} | Release: ${app.get("ver")}`
|
||||||
|
);
|
||||||
|
});
|
||||||
61
backend/src/controllers/flights.js
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
let ioInstance = null;
|
||||||
|
const api = require("../modules/api");
|
||||||
|
|
||||||
|
async function getAllFlights(req, res, next) {
|
||||||
|
try {
|
||||||
|
return res.status(200).json(global.flighsData.ac);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initFlightSockets(io) {
|
||||||
|
ioInstance = io;
|
||||||
|
|
||||||
|
io.on("connection", (socket) => {
|
||||||
|
console.log("User connected:", socket.id);
|
||||||
|
|
||||||
|
socket.on("flightsData", (bounds) => {
|
||||||
|
console.log(bounds);
|
||||||
|
socket.bounds = bounds;
|
||||||
|
|
||||||
|
// Send initial filtered flights
|
||||||
|
if (global.flightsData) {
|
||||||
|
const filtered = filterFlightsByBounds(global.flightsData, bounds);
|
||||||
|
socket.emit("flightsData", { ac: filtered });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("disconnect", () => {
|
||||||
|
console.log("User disconnected:", socket.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterFlightsByBounds(flights, bounds) {
|
||||||
|
if (!bounds) return flights;
|
||||||
|
return flights.ac.filter(
|
||||||
|
(flight) =>
|
||||||
|
flight.lat <= bounds.northEast.lat &&
|
||||||
|
flight.lat >= bounds.southWest.lat &&
|
||||||
|
flight.lon <= bounds.northEast.lng &&
|
||||||
|
flight.lon >= bounds.southWest.lng
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyFlightsUpdate(flights) {
|
||||||
|
if (!ioInstance) return;
|
||||||
|
|
||||||
|
ioInstance.sockets.sockets.forEach((socket) => {
|
||||||
|
if (socket.bounds) {
|
||||||
|
const filtered = filterFlightsByBounds(flights, socket.bounds);
|
||||||
|
socket.emit("flightsData", { ac: filtered });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getAllFlights,
|
||||||
|
initFlightSockets,
|
||||||
|
notifyFlightsUpdate,
|
||||||
|
};
|
||||||
134
backend/src/modules/api.js
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
function respond(req, res, code = 200, data = null) {
|
||||||
|
// Complete HTTP status code definitions
|
||||||
|
const STATUS_CODES = {
|
||||||
|
// 2xx Success
|
||||||
|
200: "OK",
|
||||||
|
201: "Created",
|
||||||
|
202: "Accepted",
|
||||||
|
204: "No Content",
|
||||||
|
|
||||||
|
// 3xx Redirection
|
||||||
|
301: "Moved Permanently",
|
||||||
|
302: "Found",
|
||||||
|
304: "Not Modified",
|
||||||
|
|
||||||
|
// 4xx Client Error
|
||||||
|
400: "Bad Request",
|
||||||
|
401: "Unauthorized",
|
||||||
|
403: "Forbidden",
|
||||||
|
404: "Not Found",
|
||||||
|
405: "Method Not Allowed",
|
||||||
|
409: "Conflict",
|
||||||
|
422: "Unprocessable Entity",
|
||||||
|
429: "Too Many Requests",
|
||||||
|
|
||||||
|
// 5xx Server Error
|
||||||
|
500: "Internal Server Error",
|
||||||
|
501: "Not Implemented",
|
||||||
|
502: "Bad Gateway",
|
||||||
|
503: "Service Unavailable",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validate status code
|
||||||
|
if (!Number.isInteger(code) || code < 100 || code > 599) {
|
||||||
|
throw new Error(`Invalid HTTP status code: ${code}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine status category based on first digit
|
||||||
|
const getStatusCategory = (statusCode) => {
|
||||||
|
const firstDigit = Math.floor(statusCode / 100);
|
||||||
|
switch (firstDigit) {
|
||||||
|
case 2:
|
||||||
|
return "success";
|
||||||
|
case 3:
|
||||||
|
return "redirect";
|
||||||
|
case 4:
|
||||||
|
return "fail";
|
||||||
|
case 5:
|
||||||
|
return "error";
|
||||||
|
default:
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build base response object
|
||||||
|
const baseResponse = {
|
||||||
|
code,
|
||||||
|
status: getStatusCategory(code),
|
||||||
|
message: STATUS_CODES[code] || "Unknown Status",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle different data types
|
||||||
|
if (data === null || data === false) {
|
||||||
|
// Simple status response
|
||||||
|
return res.status(code).json(baseResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof data === "string") {
|
||||||
|
// String message override
|
||||||
|
return res.status(code).json({
|
||||||
|
...baseResponse,
|
||||||
|
message: data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof data === "object" && data !== null) {
|
||||||
|
// Handle pagination links if present
|
||||||
|
if (data.links && req) {
|
||||||
|
const baseUrl = `${req.protocol}://${req.hostname}${
|
||||||
|
req.originalUrl.split("?")[0]
|
||||||
|
}?`;
|
||||||
|
|
||||||
|
const linkFields = ["self", "first", "previous", "next", "last"];
|
||||||
|
linkFields.forEach((field) => {
|
||||||
|
if (data.links[field]) {
|
||||||
|
data.links[field] = baseUrl + data.links[field];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return merged response
|
||||||
|
return res.status(code).json({
|
||||||
|
...baseResponse,
|
||||||
|
...data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for unexpected data types
|
||||||
|
return res.status(code).json({
|
||||||
|
...baseResponse,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function debug(output, detail = null) {
|
||||||
|
// TODO: re-enable if statement below
|
||||||
|
if (process.env.NODE_ENV !== "production") {
|
||||||
|
const err = new Error();
|
||||||
|
const stack = err.stack.split("\n");
|
||||||
|
const callerLine = stack[2];
|
||||||
|
const callerInfo = callerLine.match(/\((.*):(\d+):\d+\)/);
|
||||||
|
const lineNumber = callerInfo ? callerInfo[2] : "unknown";
|
||||||
|
const functionName = debug.caller ? debug.caller.name : "anonymous";
|
||||||
|
console.log(
|
||||||
|
`====== Function: ${functionName} | Line: ${lineNumber} | Detail: ${detail} ======`
|
||||||
|
);
|
||||||
|
console.log(output);
|
||||||
|
// //old
|
||||||
|
// +console.log(============= function ${debug.caller.name} ${detail ? ': ' + detail : ''} =============== );
|
||||||
|
// console.log(output);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Optional: Export with additional helper methods
|
||||||
|
module.exports = {
|
||||||
|
respond,
|
||||||
|
debug,
|
||||||
|
// Convenience methods for common responses
|
||||||
|
success: (req, res, data = null) => respond(req, res, 200, data),
|
||||||
|
created: (req, res, data = null) => respond(req, res, 201, data),
|
||||||
|
badRequest: (req, res, message = null) => respond(req, res, 400, message),
|
||||||
|
unauthorized: (req, res, message = null) => respond(req, res, 401, message),
|
||||||
|
forbidden: (req, res, message = null) => respond(req, res, 403, message),
|
||||||
|
notFound: (req, res, message = null) => respond(req, res, 404, message),
|
||||||
|
serverError: (req, res, message = null) => respond(req, res, 500, message),
|
||||||
|
};
|
||||||
41
backend/src/modules/data.js
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
const axios = require("axios");
|
||||||
|
const api = require("./api");
|
||||||
|
|
||||||
|
const { getIO } = require("./socket");
|
||||||
|
|
||||||
|
const controller = {
|
||||||
|
flightsController: require("../controllers/flights"),
|
||||||
|
};
|
||||||
|
|
||||||
|
async function getAllFlights(req, res) {
|
||||||
|
try {
|
||||||
|
/*
|
||||||
|
/v2/hex/[hex] GET Return all aircraft with an exact match on one or more Mode S hex IDs (max ~1000 per request).
|
||||||
|
/v2/callsign/[callsign] GET Return all aircraft exactly matching one or more callsigns.
|
||||||
|
/v2/reg/[reg] GET Return all aircraft exactly matching one or more registrations (tail numbers).
|
||||||
|
/v2/type/[type] GET Return all aircraft having specified ICAO aircraft type codes (e.g. “A321”, “B738”, etc.).
|
||||||
|
/v2/squawk/[squawk] GET Return all aircraft currently squawking a specified transponder code.
|
||||||
|
/v2/mil/ GET Returns all aircraft tagged as military.
|
||||||
|
/v2/ladd/ GET Returns all aircraft tagged as LADD. (LADD = “Low Altitude Demonstration Data” / some specific tag)
|
||||||
|
/v2/pia/ GET Returns all aircraft tagged as PIA (Privacy-oriented / some masking / private-or-government address)
|
||||||
|
/v2/point/[lat]/[lon]/[radius] GET Returns all aircraft within a given radius of a geographic point, up to 250 nautical miles radius.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const response = await axios.get(
|
||||||
|
// `https://api.adsb.one/v2/mil/`
|
||||||
|
`https://api.adsb.one/v2/point/43.6532/-79.3832/250`
|
||||||
|
);
|
||||||
|
|
||||||
|
// set flights details in global.
|
||||||
|
|
||||||
|
global.flightsData = response.data;
|
||||||
|
controller.flightsController.notifyFlightsUpdate(global.flightsData); // tell controller to send updates
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
api.respond(req, res, 404, "Error: failed to get flights information");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getAllFlights,
|
||||||
|
};
|
||||||
19
backend/src/modules/socket.js
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
let io;
|
||||||
|
const api = require("./api");
|
||||||
|
|
||||||
|
function init(server, cors) {
|
||||||
|
const { Server } = require("socket.io");
|
||||||
|
io = new Server(server, {
|
||||||
|
cors: cors,
|
||||||
|
});
|
||||||
|
return io;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIO() {
|
||||||
|
if (!io) {
|
||||||
|
throw new Error("Socket.io not initialized!");
|
||||||
|
}
|
||||||
|
return io;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { init, getIO };
|
||||||
15
backend/src/routes/flights.js
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
const express = require("express");
|
||||||
|
const router = express.Router();
|
||||||
|
const api = require("../modules/api");
|
||||||
|
const controllers = {
|
||||||
|
flightsController: require("../controllers/flights"),
|
||||||
|
};
|
||||||
|
|
||||||
|
router.use(async (req, res, next) => {
|
||||||
|
// run any additional pre operations
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/", controllers.flightsController.getAllFlights);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
16
backend/src/routes/index.js
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
const express = require("express");
|
||||||
|
const router = express.Router();
|
||||||
|
const api = require("../modules/api");
|
||||||
|
|
||||||
|
const routes = {
|
||||||
|
flights: require("./flights"),
|
||||||
|
};
|
||||||
|
|
||||||
|
router.use(async (req, res, next) => {
|
||||||
|
// run any additional pre operations
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
router.use("/flights", routes.flights);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
293
backend/src/stores/flightStore.js
Normal file
|
|
@ -0,0 +1,293 @@
|
||||||
|
const axios = require("axios");
|
||||||
|
|
||||||
|
// stores/flightStore.js
|
||||||
|
class FlightStore {
|
||||||
|
constructor() {
|
||||||
|
this.flights = new Map(); // flightId -> flightData
|
||||||
|
this.lastUpdated = new Map(); // flightId -> timestamp
|
||||||
|
this.subscribers = new Set(); // callback functions
|
||||||
|
this.updateInterval = null;
|
||||||
|
this.isUpdating = false;
|
||||||
|
this.lastApiCall = 0; // Track last API call time
|
||||||
|
this.minApiInterval = 1000; // 1 second between API calls
|
||||||
|
}
|
||||||
|
|
||||||
|
getFlight(flightId) {
|
||||||
|
return this.flights.get(flightId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllFlights() {
|
||||||
|
return Object.fromEntries(this.flights);
|
||||||
|
}
|
||||||
|
|
||||||
|
setFlight(flightId, flightData) {
|
||||||
|
const oldData = this.flights.get(flightId);
|
||||||
|
this.flights.set(flightId, flightData);
|
||||||
|
this.lastUpdated.set(flightId, new Date());
|
||||||
|
|
||||||
|
this.notifySubscribers("flightUpdated", {
|
||||||
|
flightId,
|
||||||
|
oldData,
|
||||||
|
newData: flightData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (global.emitFlightUpdate) {
|
||||||
|
global.emitFlightUpdate(flightId, flightData);
|
||||||
|
}
|
||||||
|
return flightData;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFlights(flightsData) {
|
||||||
|
Object.entries(flightsData).forEach(([flightId, flightData]) => {
|
||||||
|
this.setFlight(flightId, flightData);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
removeFlight(flightId) {
|
||||||
|
const removed = this.flights.delete(flightId);
|
||||||
|
this.lastUpdated.delete(flightId);
|
||||||
|
|
||||||
|
if (removed) {
|
||||||
|
this.notifySubscribers("flightRemoved", { flightId });
|
||||||
|
}
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
isFlightStale(flightId, maxAgeMs = 60000) {
|
||||||
|
const lastUpdate = this.lastUpdated.get(flightId);
|
||||||
|
if (!lastUpdate) return true;
|
||||||
|
return Date.now() - lastUpdate.getTime() > maxAgeMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
getStaleFlights(maxAgeMs = 60000) {
|
||||||
|
return Array.from(this.flights.keys()).filter((flightId) =>
|
||||||
|
this.isFlightStale(flightId, maxAgeMs)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(callback) {
|
||||||
|
this.subscribers.add(callback);
|
||||||
|
return () => this.subscribers.delete(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
notifySubscribers(event, data) {
|
||||||
|
this.subscribers.forEach((cb) => {
|
||||||
|
try {
|
||||||
|
cb(event, data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error in subscriber:", err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
startAutoUpdates(intervalMs = 1000) {
|
||||||
|
if (this.updateInterval) {
|
||||||
|
console.log("Auto updates already running");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intervalMs < this.minApiInterval) {
|
||||||
|
console.warn(
|
||||||
|
`⚠️ Interval ${intervalMs}ms < API rate limit. Adjusting to ${this.minApiInterval}ms`
|
||||||
|
);
|
||||||
|
intervalMs = this.minApiInterval;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`🚀 Starting flight auto-updates every ${intervalMs}ms`);
|
||||||
|
|
||||||
|
// Do first update immediately
|
||||||
|
this.updateAllFlights().catch((err) =>
|
||||||
|
console.error("Initial update failed:", err)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Safe async interval
|
||||||
|
this.updateInterval = setInterval(() => {
|
||||||
|
this.updateAllFlights().catch((err) =>
|
||||||
|
console.error("Auto update failed:", err)
|
||||||
|
);
|
||||||
|
}, intervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
stopAutoUpdates() {
|
||||||
|
if (this.updateInterval) {
|
||||||
|
clearInterval(this.updateInterval);
|
||||||
|
this.updateInterval = null;
|
||||||
|
console.log("Stopped auto-updates");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateAllFlights() {
|
||||||
|
if (this.isUpdating) {
|
||||||
|
console.log("Update already in progress, skipping...");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.isUpdating = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Rate limiting
|
||||||
|
const now = Date.now();
|
||||||
|
const elapsed = now - this.lastApiCall;
|
||||||
|
if (elapsed < this.minApiInterval) {
|
||||||
|
await new Promise((res) =>
|
||||||
|
setTimeout(res, this.minApiInterval - elapsed)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastApiCall = Date.now();
|
||||||
|
console.log("📡 Fetching flights from API...");
|
||||||
|
const flightsData = await this.fetchMultipleFlightsFromAPI();
|
||||||
|
|
||||||
|
if (flightsData && typeof flightsData === "object") {
|
||||||
|
global.emitFlightUpdate(flightsData);
|
||||||
|
// const changedFlights = [];
|
||||||
|
|
||||||
|
// for (const [flightId, flightData] of Object.entries(flightsData)) {
|
||||||
|
// const oldData = this.flights.get(flightId);
|
||||||
|
// if (!oldData || this.hasFlightDataChanged(oldData, flightData)) {
|
||||||
|
// this.setFlight(flightId, flightData);
|
||||||
|
// changedFlights.push(flightId);
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// this.notifySubscribers("allFlightsUpdated", {
|
||||||
|
// totalFlights: Object.keys(flightsData).length,
|
||||||
|
// changedFlights: changedFlights.length,
|
||||||
|
// updatedFlightIds: changedFlights,
|
||||||
|
// timestamp: new Date(),
|
||||||
|
// });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("❌ Error updating flights:", err);
|
||||||
|
this.notifySubscribers("updateError", {
|
||||||
|
error: err.message,
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
if (global.io) {
|
||||||
|
global.io.emit("flightUpdateError", {
|
||||||
|
error: err.message,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.isUpdating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hasFlightDataChanged(oldData, newData) {
|
||||||
|
const fields = [
|
||||||
|
"status",
|
||||||
|
"gate",
|
||||||
|
"departure",
|
||||||
|
"arrival",
|
||||||
|
"delay",
|
||||||
|
"terminal",
|
||||||
|
];
|
||||||
|
return fields.some(
|
||||||
|
(field) =>
|
||||||
|
JSON.stringify(oldData[field]) !== JSON.stringify(newData[field])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================
|
||||||
|
// API calls (mocked here)
|
||||||
|
// ===========================
|
||||||
|
async fetchMultipleFlightsFromAPI(flightIds = []) {
|
||||||
|
try {
|
||||||
|
// If you always fetch all flights near Toronto, you can ignore flightIds
|
||||||
|
const response = await axios.get(
|
||||||
|
"https://api.adsb.one/v2/point/43.6532/-79.3832/10"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The ADSB API likely returns an array of flights, not keyed by ID
|
||||||
|
// console.log("======================");
|
||||||
|
// console.log(flightsArray);
|
||||||
|
// Convert to an object keyed by ICAO or flightId
|
||||||
|
const flightsData = response.data.ac || [];
|
||||||
|
const flightsMap = [];
|
||||||
|
Object.entries(flightsData).forEach(([flightId, flightData]) => {
|
||||||
|
flightsMap.push({
|
||||||
|
flightId: flightData.flightId || flightId,
|
||||||
|
callsign: flightData.callsign?.trim() || null,
|
||||||
|
registration: flightData.registration || null,
|
||||||
|
type: flightData.type || null,
|
||||||
|
desc: flightData.desc || null,
|
||||||
|
altitude: flightData.altitude || null,
|
||||||
|
lat: flightData.lat || null,
|
||||||
|
lon: flightData.lon || null,
|
||||||
|
track: flightData.track || null,
|
||||||
|
lastSeen: flightData.lastSeen || Date.now(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
console.log("==============================");
|
||||||
|
console.log(flightsMap);
|
||||||
|
console.log("==============================");
|
||||||
|
return flightsMap;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("❌ Error fetching flights from ADSB:", err.message);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchFlightFromAPI(flightId) {
|
||||||
|
const result = await this.fetchMultipleFlightsFromAPI([flightId]);
|
||||||
|
return result[flightId];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getActiveFlightIds() {
|
||||||
|
return this.flights.size > 0
|
||||||
|
? Array.from(this.flights.keys())
|
||||||
|
: ["FL001", "FL002", "FL003"];
|
||||||
|
}
|
||||||
|
|
||||||
|
async addMultipleFlights(flightIds) {
|
||||||
|
const data = await this.fetchMultipleFlightsFromAPI(flightIds);
|
||||||
|
const results = {};
|
||||||
|
for (const id of flightIds) {
|
||||||
|
if (data[id]) {
|
||||||
|
this.setFlight(id, data[id]);
|
||||||
|
results[id] = data[id];
|
||||||
|
} else {
|
||||||
|
results[id] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
async initializeWithFlights(flightIds = null) {
|
||||||
|
if (!flightIds) {
|
||||||
|
flightIds = await this.getActiveFlightIds();
|
||||||
|
}
|
||||||
|
if (flightIds.length === 0) return;
|
||||||
|
|
||||||
|
await this.addMultipleFlights(flightIds);
|
||||||
|
this.notifySubscribers("storeInitialized", {
|
||||||
|
flightCount: flightIds.length,
|
||||||
|
flights: flightIds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================
|
||||||
|
// Utils
|
||||||
|
// ===========================
|
||||||
|
getStats() {
|
||||||
|
const stale = this.getStaleFlights().length;
|
||||||
|
return {
|
||||||
|
totalFlights: this.flights.size,
|
||||||
|
staleFlights: stale,
|
||||||
|
freshFlights: this.flights.size - stale,
|
||||||
|
isAutoUpdating: !!this.updateInterval,
|
||||||
|
isUpdating: this.isUpdating,
|
||||||
|
subscribers: this.subscribers.size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.flights.clear();
|
||||||
|
this.lastUpdated.clear();
|
||||||
|
this.notifySubscribers("storeCleared", {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton
|
||||||
|
const flightStore = new FlightStore();
|
||||||
|
module.exports = flightStore;
|
||||||
41
frontend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
6410
frontend/package-lock.json
generated
Normal file
31
frontend/package.json
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
{
|
||||||
|
"name": "aero-echo",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@emotion/react": "^11.14.0",
|
||||||
|
"@emotion/styled": "^11.14.1",
|
||||||
|
"@mui/icons-material": "^7.3.2",
|
||||||
|
"@mui/material": "^7.3.2",
|
||||||
|
"@types/leaflet": "^1.9.20",
|
||||||
|
"axios": "^1.12.2",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
|
"next": "15.5.3",
|
||||||
|
"react": "19.1.0",
|
||||||
|
"react-dom": "19.1.0",
|
||||||
|
"react-leaflet": "^5.0.0",
|
||||||
|
"react-leaflet-markercluster": "^5.0.0-rc.0",
|
||||||
|
"socket.io-client": "^4.8.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/eslintrc": "^3",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "15.5.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 391 B After Width: | Height: | Size: 391 B |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 128 B After Width: | Height: | Size: 128 B |
|
Before Width: | Height: | Size: 385 B After Width: | Height: | Size: 385 B |
|
|
@ -1,20 +1,13 @@
|
||||||
export async function OpenStreetGet(data) {
|
export async function getFlightsApi(data) {
|
||||||
console.log("get data", data);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/opensky", {
|
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/flights`, {
|
||||||
method: "POST",
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
|
||||||
southWest: data.southWest,
|
|
||||||
northEast: data.northEast,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
// Only consume the response once
|
|
||||||
let errorMessage;
|
let errorMessage;
|
||||||
try {
|
try {
|
||||||
const errorData = await response.json();
|
const errorData = await response.json();
|
||||||
|
|
@ -26,14 +19,8 @@ export async function OpenStreetGet(data) {
|
||||||
throw new Error(errorMessage);
|
throw new Error(errorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse the JSON only once and store it
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
console.log(result);
|
||||||
console.log("Response data:", {
|
|
||||||
state: result.states,
|
|
||||||
time: new Date(result.time * 1000).toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch aircraft data:", error);
|
console.error("Failed to fetch aircraft data:", error);
|
||||||
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
7
frontend/src/app/map/page.jsx
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import MapView from "@/views/map/page";
|
||||||
|
|
||||||
|
const Map = () => {
|
||||||
|
return <MapView />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Map;
|
||||||
|
|
@ -5,7 +5,6 @@ import { renderToString } from "react-dom/server";
|
||||||
import { divIcon } from "leaflet";
|
import { divIcon } from "leaflet";
|
||||||
|
|
||||||
export function MapBounds({ setBoundries }) {
|
export function MapBounds({ setBoundries }) {
|
||||||
// Destructure props properly
|
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -22,12 +21,11 @@ export function MapBounds({ setBoundries }) {
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log(data);
|
console.log(data);
|
||||||
setBoundries(data); // Actually call the setter function
|
setBoundries(data);
|
||||||
};
|
};
|
||||||
|
|
||||||
map.on("moveend", handleMoveEnd);
|
map.on("moveend", handleMoveEnd);
|
||||||
|
|
||||||
// Cleanup event listener
|
|
||||||
return () => {
|
return () => {
|
||||||
map.off("moveend", handleMoveEnd);
|
map.off("moveend", handleMoveEnd);
|
||||||
};
|
};
|
||||||
|
|
@ -37,16 +35,15 @@ export function MapBounds({ setBoundries }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createRotatedFlightIcon = (flight) => {
|
export const createRotatedFlightIcon = (flight) => {
|
||||||
const heading = flight[10] || 0;
|
const heading = flight.track || 0;
|
||||||
const velocity = flight[9] || 0;
|
const velocity = flight.gs || 0;
|
||||||
const altitude = flight[7] || 0;
|
const altitude = flight.alt_baro || 0;
|
||||||
|
|
||||||
// Different yellow shades based on flight characteristics
|
let iconColor = "#FFD700";
|
||||||
let iconColor = "#FFD700"; // Default gold yellow
|
|
||||||
if (altitude < 1000) {
|
if (altitude < 1000) {
|
||||||
iconColor = "#FFC107"; // Amber for low altitude
|
iconColor = "#FFC107";
|
||||||
} else if (velocity > 250) {
|
} else if (velocity > 250) {
|
||||||
iconColor = "#F9A825"; // Darker yellow for high speed
|
iconColor = "#F9A825";
|
||||||
}
|
}
|
||||||
|
|
||||||
const iconHtml = renderToString(
|
const iconHtml = renderToString(
|
||||||
231
frontend/src/views/map/page.jsx
Normal file
|
|
@ -0,0 +1,231 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { MapContainer, TileLayer, useMap, Pane } from "react-leaflet";
|
||||||
|
import "leaflet/dist/leaflet.css";
|
||||||
|
import { io } from "socket.io-client";
|
||||||
|
import L from "leaflet";
|
||||||
|
import { MapBounds } from "@/app/utils/map";
|
||||||
|
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
delete L.Icon.Default.prototype._getIconUrl;
|
||||||
|
L.Icon.Default.mergeOptions({
|
||||||
|
iconRetinaUrl:
|
||||||
|
"https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png",
|
||||||
|
iconUrl:
|
||||||
|
"https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png",
|
||||||
|
shadowUrl:
|
||||||
|
"https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const FlightsLayer = ({ flightsData, updateInterval = 5000 }) => {
|
||||||
|
const map = useMap();
|
||||||
|
const flightsRef = useRef({});
|
||||||
|
|
||||||
|
const drawMarker = (lat, lon, icon) => {
|
||||||
|
const longitudes = [lon, lon - 360, lon + 360];
|
||||||
|
return longitudes.map((lng) => {
|
||||||
|
const marker = L.marker([lat, lng], { icon }).addTo(map);
|
||||||
|
return marker;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!map) return;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
flightsData.forEach((f) => {
|
||||||
|
if (!f.lat || !f.lon) return;
|
||||||
|
|
||||||
|
const flightId = f.hex || f.flight || `${f.lat}_${f.lon}`;
|
||||||
|
const direction = f.track || f.nav_heading || f.true_heading || 0;
|
||||||
|
const icon = createPlaneIcon(direction);
|
||||||
|
|
||||||
|
if (!flightsRef.current[flightId]) {
|
||||||
|
const markers = drawMarker(f.lat, f.lon, icon);
|
||||||
|
|
||||||
|
const popupContent = `
|
||||||
|
<strong>Flight:</strong> ${f.flight?.trim() || "Unknown"}<br>
|
||||||
|
<strong>Registration:</strong> ${f.r || "Unknown"}<br>
|
||||||
|
<strong>Aircraft:</strong> ${f.desc || f.t || "Unknown"}<br>
|
||||||
|
<strong>Altitude:</strong> ${f.alt_baro || "Unknown"} ft<br>
|
||||||
|
<strong>Speed:</strong> ${f.gs || "Unknown"} kts<br>
|
||||||
|
<strong>Track:</strong> ${f.track || "Unknown"}°
|
||||||
|
`;
|
||||||
|
markers[0].bindPopup(popupContent);
|
||||||
|
|
||||||
|
flightsRef.current[flightId] = {
|
||||||
|
prev: { lat: f.lat, lon: f.lon },
|
||||||
|
next: { lat: f.lat, lon: f.lon },
|
||||||
|
startTime: now,
|
||||||
|
endTime: now + updateInterval,
|
||||||
|
markers,
|
||||||
|
flightInfo: f,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const flight = flightsRef.current[flightId];
|
||||||
|
flight.prev = flight.next;
|
||||||
|
flight.next = { lat: f.lat, lon: f.lon };
|
||||||
|
flight.startTime = now;
|
||||||
|
flight.endTime = now + updateInterval;
|
||||||
|
flight.flightInfo = f;
|
||||||
|
|
||||||
|
const newIcon = createPlaneIcon(direction);
|
||||||
|
|
||||||
|
const popupContent = `
|
||||||
|
<strong>Flight:</strong> ${f.flight?.trim() || "Unknown"}<br>
|
||||||
|
<strong>Registration:</strong> ${f.r || "Unknown"}<br>
|
||||||
|
<strong>Aircraft:</strong> ${f.desc || f.t || "Unknown"}<br>
|
||||||
|
<strong>Altitude:</strong> ${f.alt_baro || "Unknown"} ft<br>
|
||||||
|
<strong>Speed:</strong> ${f.gs || "Unknown"} kts<br>
|
||||||
|
<strong>Track:</strong> ${f.track || "Unknown"}°
|
||||||
|
`;
|
||||||
|
flight.markers[0].setPopupContent(popupContent);
|
||||||
|
|
||||||
|
flight.markers.forEach((m, i) => {
|
||||||
|
const lng = f.lon + (i === 1 ? -360 : i === 2 ? 360 : 0);
|
||||||
|
m.setLatLng([f.lat, lng]);
|
||||||
|
m.setIcon(newIcon);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentFlightIds = new Set(
|
||||||
|
flightsData.map((f) => f.hex || f.flight || `${f.lat}_${f.lon}`)
|
||||||
|
);
|
||||||
|
Object.keys(flightsRef.current).forEach((flightId) => {
|
||||||
|
if (!currentFlightIds.has(flightId)) {
|
||||||
|
flightsRef.current[flightId].markers.forEach((m) => map.removeLayer(m));
|
||||||
|
delete flightsRef.current[flightId];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [flightsData, map, updateInterval]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let animFrame;
|
||||||
|
const animate = () => {
|
||||||
|
const now = Date.now();
|
||||||
|
Object.values(flightsRef.current).forEach((f) => {
|
||||||
|
const t = Math.min(1, (now - f.startTime) / (f.endTime - f.startTime));
|
||||||
|
const lat = f.prev.lat + (f.next.lat - f.prev.lat) * t;
|
||||||
|
const lon = f.prev.lon + (f.next.lon - f.prev.lon) * t;
|
||||||
|
|
||||||
|
f.markers.forEach((m, i) => {
|
||||||
|
const newLng = lon + (i === 1 ? -360 : i === 2 ? 360 : 0);
|
||||||
|
m.setLatLng([lat, newLng]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
animFrame = requestAnimationFrame(animate);
|
||||||
|
};
|
||||||
|
animFrame = requestAnimationFrame(animate);
|
||||||
|
return () => cancelAnimationFrame(animFrame);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createPlaneIcon = (heading = 0) => {
|
||||||
|
const html = `
|
||||||
|
<div style="
|
||||||
|
transform: rotate(${heading}deg);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.4));
|
||||||
|
">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M21 16v-2l-8-5V3.5c0-.83-.67-1.5-1.5-1.5S10 2.67 10 3.5V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"
|
||||||
|
fill="#dc2626"
|
||||||
|
stroke="#000000"
|
||||||
|
stroke-width="1"
|
||||||
|
stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return L.divIcon({
|
||||||
|
html,
|
||||||
|
className: "",
|
||||||
|
iconSize: [28, 28],
|
||||||
|
iconAnchor: [14, 14],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const createSimplePlaneIcon = (heading = 0) => {
|
||||||
|
const html = `
|
||||||
|
<div style="
|
||||||
|
transform: rotate(${heading}deg);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
color: #dc2626;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
">
|
||||||
|
✈️
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return L.divIcon({
|
||||||
|
html,
|
||||||
|
className: "",
|
||||||
|
iconSize: [24, 24],
|
||||||
|
iconAnchor: [12, 12],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const MapView = () => {
|
||||||
|
const [boundaries, setBoundaries] = useState();
|
||||||
|
const [flights, setFlights] = useState([]);
|
||||||
|
const socketRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
|
socketRef.current = io(process.env.NEXT_PUBLIC_SOCKET_SERVER_URL);
|
||||||
|
|
||||||
|
socketRef.current.on("connect", () =>
|
||||||
|
console.log("Connected:", socketRef.current.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
socketRef.current.on("flightsData", (data) => {
|
||||||
|
console.log(data);
|
||||||
|
setFlights(data.ac || []);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (socketRef.current) {
|
||||||
|
socketRef.current.disconnect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (socketRef.current && boundaries) {
|
||||||
|
socketRef.current.emit("flightsData", boundaries);
|
||||||
|
}
|
||||||
|
}, [boundaries]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{flights && (
|
||||||
|
<MapContainer
|
||||||
|
center={[43.6532, -79.3832]}
|
||||||
|
zoom={9}
|
||||||
|
maxBoundsViscosity={4}
|
||||||
|
style={{ height: "100vh", width: "100%" }}
|
||||||
|
worldCopyJump={true}
|
||||||
|
>
|
||||||
|
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
|
||||||
|
<FlightsLayer flightsData={flights} updateInterval={2000} />
|
||||||
|
<MapBounds setBoundries={setBoundaries} />
|
||||||
|
</MapContainer>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MapView;
|
||||||
6407
package-lock.json
generated
31
package.json
|
|
@ -1,30 +1,15 @@
|
||||||
{
|
{
|
||||||
"name": "aero-echo",
|
"name": "my-fullstack-app",
|
||||||
"version": "0.1.0",
|
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "concurrently \"npm run backend:dev\" \"npm run frontend:dev\"",
|
||||||
"build": "next build",
|
"backend:dev": "cd backend && npm run dev",
|
||||||
"start": "next start",
|
"frontend:dev": "cd frontend && npm run dev",
|
||||||
"lint": "eslint"
|
"backend:start": "cd backend && npm start",
|
||||||
},
|
"frontend:start": "cd frontend && npm start",
|
||||||
"dependencies": {
|
"install:all": "cd backend && npm install && cd ../frontend && npm install"
|
||||||
"@emotion/react": "^11.14.0",
|
|
||||||
"@emotion/styled": "^11.14.1",
|
|
||||||
"@mui/icons-material": "^7.3.2",
|
|
||||||
"@mui/material": "^7.3.2",
|
|
||||||
"@types/leaflet": "^1.9.20",
|
|
||||||
"axios": "^1.12.2",
|
|
||||||
"leaflet": "^1.9.4",
|
|
||||||
"next": "15.5.3",
|
|
||||||
"react": "19.1.0",
|
|
||||||
"react-dom": "19.1.0",
|
|
||||||
"react-leaflet": "^5.0.0",
|
|
||||||
"react-leaflet-markercluster": "^5.0.0-rc.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3",
|
"concurrently": "^8.2.2"
|
||||||
"eslint": "^9",
|
|
||||||
"eslint-config-next": "15.5.3"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,111 +0,0 @@
|
||||||
import axios from "axios";
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
|
|
||||||
let accessToken = null;
|
|
||||||
let tokenExpiry = null;
|
|
||||||
|
|
||||||
// Server-side - no NEXT_PUBLIC_ prefix needed
|
|
||||||
const CLIENT_ID = process.env.CLIENT_ID;
|
|
||||||
const CLIENT_SECRET = process.env.CLIENT_SECRET;
|
|
||||||
|
|
||||||
async function getAccessToken() {
|
|
||||||
console.log("Server-side env check:", {
|
|
||||||
hasClientId: !!CLIENT_ID,
|
|
||||||
hasClientSecret: !!CLIENT_SECRET,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!CLIENT_ID || !CLIENT_SECRET) {
|
|
||||||
throw new Error("Missing OAuth credentials in server environment");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (accessToken && tokenExpiry && Date.now() < tokenExpiry) {
|
|
||||||
return accessToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log("Requesting new OAuth2 token...");
|
|
||||||
|
|
||||||
const response = await axios.post(
|
|
||||||
"https://auth.opensky-network.org/auth/realms/opensky-network/protocol/openid-connect/token",
|
|
||||||
new URLSearchParams({
|
|
||||||
grant_type: "client_credentials",
|
|
||||||
client_id: CLIENT_ID,
|
|
||||||
client_secret: CLIENT_SECRET,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
|
||||||
},
|
|
||||||
timeout: 15000,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
accessToken = response.data.access_token;
|
|
||||||
const expiresIn = response.data.expires_in || 3600;
|
|
||||||
tokenExpiry = Date.now() + expiresIn * 1000 - 60000;
|
|
||||||
|
|
||||||
console.log(`✅ OAuth2 token acquired, expires in ${expiresIn} seconds`);
|
|
||||||
return accessToken;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("❌ Failed to get OAuth2 token:", {
|
|
||||||
status: error.response?.status,
|
|
||||||
data: error.response?.data,
|
|
||||||
message: error.message,
|
|
||||||
});
|
|
||||||
throw new Error(`OAuth2 authentication failed: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request) {
|
|
||||||
try {
|
|
||||||
const body = await request.json();
|
|
||||||
const { southWest, northEast } = body;
|
|
||||||
|
|
||||||
const params = {
|
|
||||||
lamin: southWest?.lat || 40,
|
|
||||||
lamax: northEast?.lat || 50,
|
|
||||||
lonmin: southWest?.lng || -10,
|
|
||||||
lonmax: northEast?.lng || 10,
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log("API Parameters:", params);
|
|
||||||
|
|
||||||
const token = await getAccessToken();
|
|
||||||
|
|
||||||
const response = await axios.get(
|
|
||||||
`https://opensky-network.org/api/states/all?lamin=${southWest?.lat}&lamax=${northEast?.lat}&lomin=${southWest?.lng}&lomax=${northEast?.lng}`,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
"User-Agent": "NextJS-App/1.0",
|
|
||||||
},
|
|
||||||
timeout: 30000,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log("✅ Authenticated request successful");
|
|
||||||
console.log("Response data:", {
|
|
||||||
statesCount: response.data.states || 0,
|
|
||||||
time: new Date(response.data.time * 1000).toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json(response.data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("❌ API Request failed:", error.message);
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: error.message,
|
|
||||||
details: error.response?.data,
|
|
||||||
},
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optional: Add GET method for testing
|
|
||||||
export async function GET() {
|
|
||||||
return NextResponse.json({
|
|
||||||
message: "OpenSky API endpoint is working",
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
import MapView from '@/views/map/page';
|
|
||||||
|
|
||||||
|
|
||||||
const Map = () => {
|
|
||||||
return <MapView/>
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Map;
|
|
||||||
63
src/controllers/flights.js
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
const axios = require("axios");
|
||||||
|
|
||||||
|
const CLIENT_ID = process.env.CLIENT_ID;
|
||||||
|
const CLIENT_SECRET = process.env.CLIENT_SECRET;
|
||||||
|
let accessToken = null;
|
||||||
|
let tokenExpiry = null;
|
||||||
|
|
||||||
|
async function getAccessToken() {
|
||||||
|
if (!CLIENT_ID || !CLIENT_SECRET) {
|
||||||
|
throw new Error("Missing OAuth credentials in server environment");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accessToken && tokenExpiry && Date.now() < tokenExpiry) {
|
||||||
|
return accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post(
|
||||||
|
"https://auth.opensky-network.org/auth/realms/opensky-network/protocol/openid-connect/token",
|
||||||
|
new URLSearchParams({
|
||||||
|
grant_type: "client_credentials",
|
||||||
|
client_id: CLIENT_ID,
|
||||||
|
client_secret: CLIENT_SECRET,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
timeout: 15000,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
accessToken = response.data.access_token;
|
||||||
|
const expiresIn = response.data.expires_in || 3600;
|
||||||
|
tokenExpiry = Date.now() + expiresIn * 1000 - 60000;
|
||||||
|
|
||||||
|
return accessToken;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ Failed to get OAuth2 token:", {
|
||||||
|
status: error.response?.status,
|
||||||
|
data: error.response?.data,
|
||||||
|
message: error.message,
|
||||||
|
});
|
||||||
|
throw new Error(`OAuth2 authentication failed: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAllFlights(req, res) {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(
|
||||||
|
`https://api.adsb.one/v2/point/43.6532/-79.3832/250`
|
||||||
|
);
|
||||||
|
console.log(response);
|
||||||
|
|
||||||
|
return res.status(200).json(response.data.ac);
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(404).message("Error: failed to get flights information");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getAllFlights,
|
||||||
|
};
|
||||||
15
src/routes/flights.js
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
const express = require("express");
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
const controllers = {
|
||||||
|
flights: require("../controllers/flights"),
|
||||||
|
};
|
||||||
|
|
||||||
|
router.use(async (req, res, next) => {
|
||||||
|
// run any additional pre operations
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
router.use("/", controllers.flights.getAllFlights);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
14
src/routes/index.js
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
const express = require("express");
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
const routes = {
|
||||||
|
flights: require("./flights"),
|
||||||
|
};
|
||||||
|
|
||||||
|
router.use(async (req, res, next) => {
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
router.use("/flights", routes.flights);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import { MapContainer, TileLayer, useMap, Marker, Popup } from "react-leaflet";
|
|
||||||
import "leaflet/dist/leaflet.css";
|
|
||||||
import { createRotatedFlightIcon, MapBounds } from "@/app/utils/map";
|
|
||||||
import { OpenStreetGet } from "@/app/api/Api";
|
|
||||||
|
|
||||||
// Example usage component
|
|
||||||
const MapView = () => {
|
|
||||||
const [bounderies, setBoundries] = useState();
|
|
||||||
const [flights, setFlights] = useState();
|
|
||||||
const sampleMarkers = [
|
|
||||||
{
|
|
||||||
lat: 51.505,
|
|
||||||
lng: -0.09,
|
|
||||||
popup: "<b>London</b><br>Capital of England",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
lat: 51.51,
|
|
||||||
lng: -0.1,
|
|
||||||
popup: "<b>Westminster</b><br>Government district",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
console.log("Boundaries updated:", bounderies);
|
|
||||||
if (bounderies) {
|
|
||||||
getFlights();
|
|
||||||
console.log(bounderies);
|
|
||||||
}
|
|
||||||
}, [bounderies]); // Add bounderies as dependency
|
|
||||||
|
|
||||||
const getFlights = async () => {
|
|
||||||
await OpenStreetGet(bounderies).then((resp) => {
|
|
||||||
console.log(resp);
|
|
||||||
setFlights(resp.states);
|
|
||||||
});
|
|
||||||
console.log("flights:", flights);
|
|
||||||
};
|
|
||||||
|
|
||||||
const position = [43.6792, -79.6178];
|
|
||||||
return (
|
|
||||||
<MapContainer
|
|
||||||
center={[43.6532, -79.3832]} // Toronto
|
|
||||||
zoom={13}
|
|
||||||
style={{ height: "100vh", width: "100%" }}
|
|
||||||
>
|
|
||||||
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
|
|
||||||
{flights &&
|
|
||||||
flights.map((flight, index) => {
|
|
||||||
console.log(flight);
|
|
||||||
const position = [flight[6], flight[5]];
|
|
||||||
return (
|
|
||||||
<Marker
|
|
||||||
icon={createRotatedFlightIcon(flight)}
|
|
||||||
key={index}
|
|
||||||
position={position}
|
|
||||||
>
|
|
||||||
<Popup>
|
|
||||||
A pretty CSS3 popup. <br /> Easily customizable.
|
|
||||||
</Popup>
|
|
||||||
</Marker>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<MapBounds setBoundries={setBoundries} />
|
|
||||||
</MapContainer>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MapView;
|
|
||||||