UNPKG

signalk-parquet

Version:

Vessel data Parquet file archive with automated value and geospatial triggers. History API compliant with cloud backups and queries.

339 lines (293 loc) 9.46 kB
/** * Spatial filtering utilities for History API * Supports bbox and radius filters for DuckDB Parquet queries and JavaScript buffer filtering */ import { BoundingBox, DataRecord } from '../types'; import { isPointInBoundingBox, calculateDistance, createBoundingBoxFromRadius, } from './geo-calculator'; /** * Spatial filter configuration */ export interface SpatialFilter { type: 'bbox' | 'radius'; bbox: BoundingBox; // For radius filters, store the center and radius for precise distance checks centerLat?: number; centerLon?: number; radiusMeters?: number; } /** * Parse bbox query parameter in "west,south,east,north" format * @param bboxStr - Comma-separated bbox string * @returns BoundingBox or null if invalid */ export function parseBboxParam(bboxStr: string): BoundingBox | null { if (!bboxStr) return null; const parts = bboxStr.split(',').map(s => parseFloat(s.trim())); if (parts.length !== 4 || parts.some(isNaN)) { return null; } const [west, south, east, north] = parts; // Validate latitude range (-90 to 90) if (south < -90 || south > 90 || north < -90 || north > 90) { return null; } // Validate longitude range (-180 to 180) if (west < -180 || west > 180 || east < -180 || east > 180) { return null; } // Validate south <= north if (south > north) { return null; } return { west, south, east, north }; } /** * Parse radius query parameter in "lon,lat,meters" format (GeoJSON convention) * @param radiusStr - Comma-separated radius string * @returns SpatialFilter with bbox approximation and precise radius info, or null if invalid */ export function parseRadiusParam(radiusStr: string): SpatialFilter | null { if (!radiusStr) return null; const parts = radiusStr.split(',').map(s => parseFloat(s.trim())); if (parts.length !== 3 || parts.some(isNaN)) { return null; } const [lon, lat, meters] = parts; // Validate latitude range (-90 to 90) if (lat < -90 || lat > 90) { return null; } // Validate longitude range (-180 to 180) if (lon < -180 || lon > 180) { return null; } // Validate positive radius if (meters <= 0) { return null; } // Convert radius to bounding box for initial filtering const bbox = createBoundingBoxFromRadius(lat, lon, meters); return { type: 'radius', bbox, centerLat: lat, centerLon: lon, radiusMeters: meters, }; } /** * Parse bbox or radius query parameters into a SpatialFilter * @param bboxStr - Optional bbox parameter * @param radiusStr - Optional radius parameter * @returns SpatialFilter or null if no valid spatial params */ export function parseSpatialParams( bboxStr?: string, radiusStr?: string ): SpatialFilter | null { // Radius takes precedence over bbox if (radiusStr) { return parseRadiusParam(radiusStr); } if (bboxStr) { const bbox = parseBboxParam(bboxStr); if (bbox) { return { type: 'bbox', bbox }; } } return null; } /** * Build DuckDB WHERE clause for spatial filtering on position data * Uses value_latitude and value_longitude columns * @param filter - Spatial filter configuration * @param latColumn - Column name for latitude (default: 'value_latitude') * @param lonColumn - Column name for longitude (default: 'value_longitude') * @returns SQL WHERE clause fragment (without leading AND) */ export function buildSpatialSqlClause( filter: SpatialFilter, latColumn: string = 'TRY_CAST(value_latitude AS DOUBLE)', lonColumn: string = 'TRY_CAST(value_longitude AS DOUBLE)' ): string { const { bbox } = filter; // Latitude check is always simple (no wrap-around) const latCondition = `${latColumn} >= ${bbox.south} AND ${latColumn} <= ${bbox.north}`; // Longitude check needs to handle 180° meridian crossing let lonCondition: string; if (bbox.west <= bbox.east) { // Normal case: bbox doesn't cross 180° meridian lonCondition = `${lonColumn} >= ${bbox.west} AND ${lonColumn} <= ${bbox.east}`; } else { // Box crosses 180° meridian (e.g., west=170, east=-170) // Point is in bbox if lon >= west OR lon <= east lonCondition = `(${lonColumn} >= ${bbox.west} OR ${lonColumn} <= ${bbox.east})`; } // For radius filters, we use the bbox as a coarse filter in SQL // The precise distance check happens in post-processing or JS filter // For now, just return bbox filter - DuckDB will do the heavy lifting if ( filter.type === 'radius' && filter.centerLat !== undefined && filter.centerLon !== undefined && filter.radiusMeters !== undefined ) { // Use ST_Distance_Spheroid for precise radius filtering in DuckDB // This requires the spatial extension which is already loaded const distanceCondition = `ST_Distance_Spheroid( ST_Point(${lonColumn}, ${latColumn}), ST_Point(${filter.centerLon}, ${filter.centerLat}) ) <= ${filter.radiusMeters}`; // Return bbox filter AND precise distance check return `(${latCondition}) AND (${lonCondition}) AND (${distanceCondition})`; } return `(${latCondition}) AND (${lonCondition})`; } /** * Filter buffer records spatially using JavaScript * @param records - DataRecord array from SQLite buffer * @param filter - Spatial filter configuration * @returns Filtered records */ export function filterBufferRecordsSpatially( records: DataRecord[], filter: SpatialFilter ): DataRecord[] { return records.filter(record => { // Extract lat/lon from record - could be in multiple formats const lat = extractLatitude(record); const lon = extractLongitude(record); if (lat === null || lon === null) { // Can't filter without position data return true; // Include it (non-position record) } // Check bounding box first (quick rejection) if (!isPointInBoundingBox(lat, lon, filter.bbox)) { return false; } // For radius filters, do precise distance check if ( filter.type === 'radius' && filter.centerLat !== undefined && filter.centerLon !== undefined && filter.radiusMeters !== undefined ) { const distance = calculateDistance( lat, lon, filter.centerLat, filter.centerLon ); return distance <= filter.radiusMeters; } return true; }); } /** * Extract latitude from a DataRecord * Handles multiple storage formats */ function extractLatitude(record: DataRecord): number | null { // Check for flattened component columns (value_latitude) if (typeof record.value_latitude === 'number') { return record.value_latitude; } // Check for value object with latitude property if (record.value && typeof record.value === 'object') { if (typeof record.value.latitude === 'number') { return record.value.latitude; } } // Check for value_json as object if (record.value_json && typeof record.value_json === 'object') { const json = record.value_json as { latitude?: number }; if (typeof json.latitude === 'number') { return json.latitude; } } // Check for value_json as string if (typeof record.value_json === 'string') { try { const parsed = JSON.parse(record.value_json); if (typeof parsed.latitude === 'number') { return parsed.latitude; } } catch { // Not valid JSON } } return null; } /** * Extract longitude from a DataRecord * Handles multiple storage formats */ function extractLongitude(record: DataRecord): number | null { // Check for flattened component columns (value_longitude) if (typeof record.value_longitude === 'number') { return record.value_longitude; } // Check for value object with longitude property if (record.value && typeof record.value === 'object') { if (typeof record.value.longitude === 'number') { return record.value.longitude; } } // Check for value_json as object if (record.value_json && typeof record.value_json === 'object') { const json = record.value_json as { longitude?: number }; if (typeof json.longitude === 'number') { return json.longitude; } } // Check for value_json as string if (typeof record.value_json === 'string') { try { const parsed = JSON.parse(record.value_json); if (typeof parsed.longitude === 'number') { return parsed.longitude; } } catch { // Not valid JSON } } return null; } /** * Check if a SignalK path supports spatial filtering * Only paths that contain position data can be spatially filtered * @param signalkPath - The SignalK path to check * @returns true if path contains position data */ export function isPositionPath(signalkPath: string): boolean { // Common position-related paths const positionPatterns = [ 'navigation.position', 'navigation.gnss.antennaPosition', 'navigation.anchor.position', 'navigation.destination.waypoint.position', '.position', // Any path ending in .position ]; const lowerPath = signalkPath.toLowerCase(); for (const pattern of positionPatterns) { if (pattern.startsWith('.')) { // Suffix pattern if (lowerPath.endsWith(pattern.toLowerCase())) { return true; } } else { // Exact or prefix match if ( lowerPath === pattern.toLowerCase() || lowerPath.startsWith(pattern.toLowerCase() + '.') ) { return true; } } } return false; }