Add new /vessel/track endpoints.

This is a variation on /navdata but returns data more suitable
for plotting vessel tracks on the map.
This commit is contained in:
D. Berge
2025-08-10 21:39:35 +02:00
parent b9e0975d3d
commit acdf118a67
8 changed files with 241 additions and 1 deletions

View File

@@ -311,6 +311,30 @@ app.map({
get: [ mw.etag.noSave, mw.gis.navdata.get ]
}
},
'/vessel/track': {
get: [ /*mw.etag.noSave,*/ mw.vessel.track.get ], // JSON array
'/line': {
get: [ // GeoJSON Feature: type = LineString
//mw.etag.noSave,
(req, res, next) => { req.query.geojson = 'LineString'; next(); },
mw.vessel.track.get
]
},
'/point': {
get: [ // GeoJSON FeatureCollection: feature types = Point
//mw.etag.noSave,
(req, res, next) => { req.query.geojson = 'Point'; next(); },
mw.vessel.track.get
]
},
'/points': {
get: [ // JSON array of (Feature: type = Point)
mw.etag.noSave,
(req, res, next) => { req.query.geojson = true; next(); },
mw.vessel.track.get
],
},
},
'/info/': {
':path(*)': {
get: [ mw.auth.operations, mw.info.get ],

View File

@@ -11,6 +11,7 @@ module.exports = {
gis: require('./gis'),
label: require('./label'),
navdata: require('./navdata'),
vessel: require('./vessel'),
queue: require('./queue'),
qc: require('./qc'),
configuration: require('./configuration'),

View File

@@ -0,0 +1,4 @@
module.exports = {
track: require('./track'),
}

View File

@@ -0,0 +1,10 @@
const { gis } = require('../../../../lib/db');
module.exports = async function (req, res, next) {
try {
res.status(200).send(await gis.vesseltrack.get(req.query));
next();
} catch (err) {
next(err);
}
}

View File

@@ -0,0 +1,4 @@
module.exports = {
get: require('./get'),
}

View File

@@ -1,6 +1,7 @@
module.exports = {
project: require('./project'),
navdata: require('./navdata')
navdata: require('./navdata'),
vesseltrack: require('./vesseltrack'),
// line: require('./line')
};

View File

@@ -0,0 +1,191 @@
const { pool } = require('../../connection');
const { project } = require('../../utils');
async function get(options = {}) {
/*
* ts0: earliest timestamp (default: NOW - 7 days)
* ts1: latest timestamp (if null, assume NOW)
* di: decimation interval (return every di-th record, if null: no decimation)
* l: limit (return no more than l records, default: 1000, max: 1,000,000)
* geojson: 'LineString' (GeoJSON LineString Feature), 'Point' (GeoJSON FeatureCollection),
* truthy (array of Point features), falsy (array of {x, y, tstamp, meta})
*/
let { l, di, ts1, ts0, geojson, projection } = {
l: 1000,
di: null,
ts1: null,
ts0: null,
geojson: false,
...options
};
// Input validation and sanitization
l = Math.max(1, Math.min(parseInt(l) || 1000, 1000000)); // Enforce 1 <= l <= 1,000,000
di = di != null ? Math.max(1, parseInt(di)) : null; // Ensure di is positive integer or null
ts0 = ts0 ? new Date(ts0).toISOString() : new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(); // Default: 7 days ago
ts1 = ts1 ? new Date(ts1).toISOString() : null; // Convert to ISO string or null
geojson = geojson === 'LineString' || geojson === 'Point' ? geojson : !!geojson; // Normalize geojson
const client = await pool.connect();
// Build the WHERE clause and values array dynamically
let whereClauses = [];
let values = [];
let paramIndex = 1;
if (ts0) {
whereClauses.push(`tstamp >= $${paramIndex++}`);
values.push(ts0);
}
if (ts1) {
whereClauses.push(`tstamp <= $${paramIndex++}`);
values.push(ts1);
}
// Add limit to values
values.push(l);
const limitClause = `LIMIT $${paramIndex++}`;
// Base query with conditional geometry selection
let queryText = `
SELECT
tstamp,
CASE
WHEN meta->>'lineStatus' = 'offline' THEN
ARRAY[COALESCE((meta->>'longitude')::float, ST_X(geometry)), COALESCE((meta->>'latitude')::float, ST_Y(geometry))]
ELSE
ARRAY[
COALESCE(
(meta->>'longitudeMaster')::float,
ST_X(geometry)
),
COALESCE(
(meta->>'latitudeMaster')::float,
ST_Y(geometry)
)
]
END AS coordinates,
meta::json AS meta
FROM public.real_time_inputs
${whereClauses.length ? 'WHERE ' + whereClauses.join(' AND ') : ''}
ORDER BY tstamp DESC
${limitClause}
`;
// If decimation is requested, wrap the query in a subquery with ROW_NUMBER
if (di != null && di > 1) {
values.push(di);
queryText = `
SELECT tstamp, coordinates, meta
FROM (
SELECT
tstamp,
CASE
WHEN meta->>'lineStatus' = 'offline' THEN
ARRAY[COALESCE((meta->>'longitude')::float, ST_X(geometry)), COALESCE((meta->>'latitude')::float, ST_Y(geometry))]
ELSE
ARRAY[
COALESCE(
(meta->>'longitudeMaster')::float,
ST_X(geometry)
),
COALESCE(
(meta->>'latitudeMaster')::float,
ST_Y(geometry)
)
]
END AS coordinates,
meta::json AS meta,
ROW_NUMBER() OVER (ORDER BY tstamp DESC) AS rn
FROM public.real_time_inputs
${whereClauses.length ? 'WHERE ' + whereClauses.join(' AND ') : ''}
) sub
WHERE rn % $${paramIndex} = 0
ORDER BY tstamp DESC
${limitClause}
`;
}
try {
const res = await client.query(queryText, values);
if (!res.rows?.length) {
throw { status: 204 }; // No Content
}
// Process rows: Convert tstamp to Date and extract coordinates
let processed = res.rows.map(row => ({
tstamp: new Date(row.tstamp),
x: row.coordinates[0], // Longitude
y: row.coordinates[1], // Latitude
meta: projection != null
? projection == ""
? undefined
: project([row.meta], projection)[0] : row.meta
}));
// Handle geojson output formats
if (geojson === 'LineString') {
// Compute line length (haversine formula in JavaScript for simplicity)
let length = 0;
for (let i = 1; i < processed.length; i++) {
const p1 = processed[i - 1];
const p2 = processed[i];
const R = 6371e3; // Earth's radius in meters
const φ1 = p1.y * Math.PI / 180;
const φ2 = p2.y * Math.PI / 180;
const Δφ = (p2.y - p1.y) * Math.PI / 180;
const Δλ = (p2.x - p1.x) * Math.PI / 180;
const a = Math.sin(Δφ / 2) ** 2 +
Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) ** 2;
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
length += R * c;
}
return {
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: processed.map(p => [p.x, p.y])
},
properties: {
ts0: processed[processed.length - 1].tstamp.toISOString(),
ts1: processed[0].tstamp.toISOString(),
distance: length
}
};
} else if (geojson === 'Point') {
return {
type: 'FeatureCollection',
features: processed.map(p => ({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [p.x, p.y]
},
properties: {
...p.meta,
tstamp: p.tstamp.toISOString()
}
}))
};
} else if (geojson) {
return processed.map(p => ({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [p.x, p.y]
},
properties: {
...p.meta,
tstamp: p.tstamp.toISOString()
}
}));
} else {
return processed;
}
} finally {
client.release();
}
}
module.exports = get;

View File

@@ -0,0 +1,5 @@
module.exports = {
get: require('./get')
};