UNPKG

signalk-parquet

Version:

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

1,651 lines (1,436 loc) 68.8 kB
/** * Map Explorer Module * Spatial querying, map visualization, playback, and export of SignalK historical data. * Replicates ZedDisplay Historical Data Explorer widget functionality. */ import { getPluginPath } from './utils.js'; // ============================================================================= // A. STATE MANAGEMENT // ============================================================================= const STATES = { IDLE: 'idle', DRAWING_BBOX: 'drawing_bbox', DRAWING_RADIUS: 'drawing_radius', QUERY_CONFIG: 'queryConfig', LOADING: 'loading', RESULTS: 'results', }; const PATH_COLORS = ['#2196F3', '#FF9800', '#4CAF50', '#9C27B0', '#00BCD4']; let state = { mode: STATES.IDLE, // Map map: null, mapInitialized: false, openSeaMapLayer: null, openSeaMapVisible: false, // Drawing drawClickCount: 0, drawStartLatLng: null, drawPreview: null, areaLayer: null, areaType: null, // 'bbox' or 'radius' areaGeometry: null, // {west,south,east,north} or {lat,lon,radius} handles: [], isDraggingArea: false, isDraggingHandle: null, dragStartLatLng: null, // Query timeMode: 'lookback', // 'lookback' or 'range' lookbackDays: 7, fromDate: null, toDate: null, context: 'self', selectedPaths: new Set(), // selected SignalK path strings pathFilter: '', // search filter text aggregation: 'average', smoothing: 'none', smoothingParam: 5, availablePaths: [], pathMeta: {}, // { 'nav.speedOverGround': { formula, symbol, displayFormat } } availableContexts: [], vesselNames: new Map(), // Results results: null, trackLayer: null, markerLayers: [], dataPoints: [], selectedIndex: -1, // Playback playing: false, playReverse: false, playSpeed: 1, playInterval: null, // Legend — ZedDisplay 3-state toggle: visible set + active index visibleSeriesIndices: new Set([0]), activeSeries: 0, // Saved areas savedAreas: [], }; // ============================================================================= // B. MAP INITIALIZATION // ============================================================================= export function initMapExplorer() { if (state.mapInitialized) { if (state.map) { setTimeout(() => state.map.invalidateSize(), 100); } return; } const container = document.getElementById('me-map'); if (!container) return; const lat = parseFloat(document.getElementById('homePortLat')?.value) || 26.78; const lon = parseFloat(document.getElementById('homePortLon')?.value) || -80.05; state.map = L.map('me-map', { center: [lat, lon], zoom: 12, zoomControl: true, }); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '&copy; OpenStreetMap contributors', maxZoom: 19, }).addTo(state.map); state.openSeaMapLayer = L.tileLayer( 'https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png', { attribution: '&copy; OpenSeaMap contributors', maxZoom: 19, opacity: 0.8, } ); // Map click handler for drawing state.map.on('click', onMapClick); state.map.on('mousemove', onMapMouseMove); state.mapInitialized = true; state._pathsStale = true; loadSavedAreas(); loadAvailablePaths(); updateQueryPanelState(); setTimeout(() => state.map.invalidateSize(), 200); } // ============================================================================= // C. BBOX DRAWING // ============================================================================= export function startBboxDraw() { clearDrawing(); state.mode = STATES.DRAWING_BBOX; state.drawClickCount = 0; state.drawStartLatLng = null; document.getElementById('me-map').style.cursor = 'crosshair'; setToolbarStatus('Click first corner of bounding box...'); } export function startRadiusDraw() { clearDrawing(); state.mode = STATES.DRAWING_RADIUS; state.drawClickCount = 0; state.drawStartLatLng = null; document.getElementById('me-map').style.cursor = 'crosshair'; setToolbarStatus('Click center point...'); } function onMapClick(e) { if (state.mode === STATES.DRAWING_BBOX) { handleBboxClick(e.latlng); } else if (state.mode === STATES.DRAWING_RADIUS) { handleRadiusClick(e.latlng); } } function onMapMouseMove(e) { if (state.mode === STATES.DRAWING_BBOX && state.drawClickCount === 1) { updateBboxPreview(e.latlng); } else if (state.mode === STATES.DRAWING_RADIUS && state.drawClickCount === 1) { updateRadiusPreview(e.latlng); } if (state.isDraggingArea && state.areaLayer) { handleAreaDrag(e.latlng); } if (state.isDraggingHandle !== null) { handleHandleDrag(e.latlng); } } function handleBboxClick(latlng) { if (state.drawClickCount === 0) { state.drawStartLatLng = latlng; state.drawClickCount = 1; setToolbarStatus('Click second corner...'); } else { finalizeBbox(state.drawStartLatLng, latlng); state.mode = STATES.QUERY_CONFIG; state.drawClickCount = 0; document.getElementById('me-map').style.cursor = ''; setToolbarStatus(''); } } function updateBboxPreview(latlng) { if (state.drawPreview) { state.map.removeLayer(state.drawPreview); } const bounds = L.latLngBounds(state.drawStartLatLng, latlng); state.drawPreview = L.rectangle(bounds, { color: '#9C27B0', weight: 2, fillOpacity: 0.15, dashArray: '5,5', }).addTo(state.map); } function finalizeBbox(corner1, corner2) { if (state.drawPreview) { state.map.removeLayer(state.drawPreview); state.drawPreview = null; } const bounds = L.latLngBounds(corner1, corner2); const sw = bounds.getSouthWest(); const ne = bounds.getNorthEast(); state.areaType = 'bbox'; state.areaGeometry = { west: sw.lng, south: sw.lat, east: ne.lng, north: ne.lat, }; drawAreaOnMap(); updateQueryPanelState(); } // ============================================================================= // D. RADIUS DRAWING // ============================================================================= function handleRadiusClick(latlng) { if (state.drawClickCount === 0) { state.drawStartLatLng = latlng; state.drawClickCount = 1; setToolbarStatus('Click to set radius...'); } else { const dist = state.drawStartLatLng.distanceTo(latlng); finalizeRadius(state.drawStartLatLng, dist); state.mode = STATES.QUERY_CONFIG; state.drawClickCount = 0; document.getElementById('me-map').style.cursor = ''; setToolbarStatus(''); } } function updateRadiusPreview(latlng) { if (state.drawPreview) { state.map.removeLayer(state.drawPreview); } const dist = state.drawStartLatLng.distanceTo(latlng); state.drawPreview = L.circle(state.drawStartLatLng, { radius: dist, color: '#9C27B0', weight: 2, fillOpacity: 0.15, dashArray: '5,5', }).addTo(state.map); } function finalizeRadius(center, radius) { if (state.drawPreview) { state.map.removeLayer(state.drawPreview); state.drawPreview = null; } state.areaType = 'radius'; state.areaGeometry = { lat: center.lat, lon: center.lng, radius: Math.round(radius), }; drawAreaOnMap(); updateQueryPanelState(); } // ============================================================================= // AREA RENDERING + HANDLES // ============================================================================= function drawAreaOnMap() { clearAreaLayer(); if (state.areaType === 'bbox') { const { west, south, east, north } = state.areaGeometry; const bounds = L.latLngBounds([south, west], [north, east]); state.areaLayer = L.rectangle(bounds, { color: '#9C27B0', weight: 2, fillOpacity: 0.3, fillColor: '#9C27B0', }).addTo(state.map); // Drag to move enableAreaDrag(); addBboxHandles(); } else if (state.areaType === 'radius') { const { lat, lon, radius } = state.areaGeometry; state.areaLayer = L.circle([lat, lon], { radius, color: '#9C27B0', weight: 2, fillOpacity: 0.3, fillColor: '#9C27B0', }).addTo(state.map); enableAreaDrag(); addRadiusHandles(); } } function clearAreaLayer() { if (state.areaLayer) { state.map.removeLayer(state.areaLayer); state.areaLayer = null; } state.handles.forEach((h) => state.map.removeLayer(h)); state.handles = []; } function updateQueryPanelState() { const panel = document.querySelector('.me-query-panel'); if (!panel) return; if (state.areaGeometry) { panel.style.opacity = '1'; panel.style.pointerEvents = 'auto'; } else { panel.style.opacity = '0.4'; panel.style.pointerEvents = 'none'; } } function clearDrawing() { clearAreaLayer(); if (state.drawPreview) { state.map.removeLayer(state.drawPreview); state.drawPreview = null; } state.areaType = null; state.areaGeometry = null; state.drawClickCount = 0; state.drawStartLatLng = null; document.getElementById('me-map').style.cursor = ''; setToolbarStatus(''); updateQueryPanelState(); } function makeHandle(latlng, index) { const h = L.circleMarker(latlng, { radius: 6, color: '#9C27B0', fillColor: 'white', fillOpacity: 1, weight: 2, className: 'me-handle', }).addTo(state.map); h.on('mousedown', (e) => { L.DomEvent.stopPropagation(e); state.isDraggingHandle = index; state.map.dragging.disable(); }); state.map.on('mouseup', () => { if (state.isDraggingHandle !== null) { state.isDraggingHandle = null; state.map.dragging.enable(); if (state.results) executeMapQuery(); } }); state.handles.push(h); return h; } function addBboxHandles() { const { west, south, east, north } = state.areaGeometry; const midLat = (south + north) / 2; const midLon = (west + east) / 2; // corners: 0=SW, 1=NW, 2=NE, 3=SE makeHandle(L.latLng(south, west), 0); makeHandle(L.latLng(north, west), 1); makeHandle(L.latLng(north, east), 2); makeHandle(L.latLng(south, east), 3); // edges: 4=W, 5=N, 6=E, 7=S makeHandle(L.latLng(midLat, west), 4); makeHandle(L.latLng(north, midLon), 5); makeHandle(L.latLng(midLat, east), 6); makeHandle(L.latLng(south, midLon), 7); } function addRadiusHandles() { const { lat, lon } = state.areaGeometry; // Center handle makeHandle(L.latLng(lat, lon), 0); // Edge handle (east) const edgePt = state.areaLayer.getBounds().getEast(); makeHandle(L.latLng(lat, edgePt), 1); } function enableAreaDrag() { if (!state.areaLayer) return; state.areaLayer.on('mousedown', (e) => { L.DomEvent.stopPropagation(e); state.isDraggingArea = true; state.dragStartLatLng = e.latlng; state.map.dragging.disable(); }); state.map.on('mouseup', () => { if (state.isDraggingArea) { state.isDraggingArea = false; state.map.dragging.enable(); if (state.results) executeMapQuery(); } }); } function handleAreaDrag(latlng) { if (!state.dragStartLatLng) return; const dLat = latlng.lat - state.dragStartLatLng.lat; const dLng = latlng.lng - state.dragStartLatLng.lng; state.dragStartLatLng = latlng; if (state.areaType === 'bbox') { state.areaGeometry.west += dLng; state.areaGeometry.east += dLng; state.areaGeometry.south += dLat; state.areaGeometry.north += dLat; } else { state.areaGeometry.lat += dLat; state.areaGeometry.lon += dLng; } drawAreaOnMap(); } function handleHandleDrag(latlng) { if (state.areaType === 'bbox') { const g = state.areaGeometry; switch (state.isDraggingHandle) { case 0: g.south = latlng.lat; g.west = latlng.lng; break; case 1: g.north = latlng.lat; g.west = latlng.lng; break; case 2: g.north = latlng.lat; g.east = latlng.lng; break; case 3: g.south = latlng.lat; g.east = latlng.lng; break; case 4: g.west = latlng.lng; break; case 5: g.north = latlng.lat; break; case 6: g.east = latlng.lng; break; case 7: g.south = latlng.lat; break; } drawAreaOnMap(); } else if (state.areaType === 'radius') { if (state.isDraggingHandle === 0) { // Move center state.areaGeometry.lat = latlng.lat; state.areaGeometry.lon = latlng.lng; } else { // Resize radius const center = L.latLng(state.areaGeometry.lat, state.areaGeometry.lon); state.areaGeometry.radius = Math.round(center.distanceTo(latlng)); } drawAreaOnMap(); } } // ============================================================================= // E. QUERY CONFIGURATION // ============================================================================= export function setTimeMode(mode) { state.timeMode = mode; document.getElementById('me-lookback-btns').style.display = mode === 'lookback' ? 'flex' : 'none'; document.getElementById('me-date-range').style.display = mode === 'range' ? 'flex' : 'none'; document .querySelectorAll('#me-time-toggle button') .forEach((b) => b.classList.remove('me-active')); document .querySelector(`#me-time-toggle button[data-mode="${mode}"]`) .classList.add('me-active'); // Only reload if we have valid date info (lookback always valid; range needs both inputs) if (mode === 'lookback' || (document.getElementById('me-from').value && document.getElementById('me-to').value)) { state._pathsStale = true; loadAvailablePaths(); if (document.getElementById('me-lookup-contexts').checked) { lookupContexts(); } } } export function onDateRangeChange() { const fromEl = document.getElementById('me-from'); const toEl = document.getElementById('me-to'); // When start date changes, auto-suggest end = start + 1 day if (fromEl.value && (!toEl.value || toEl.value < fromEl.value)) { const start = new Date(fromEl.value); start.setDate(start.getDate() + 1); toEl.value = start.toISOString().split('T')[0]; } if (!fromEl.value || !toEl.value) return; // wait until both dates are set state._pathsStale = true; loadAvailablePaths(); if (document.getElementById('me-lookup-contexts').checked) { lookupContexts(); } } export function setLookback(days) { state.lookbackDays = days; state._pathsStale = true; document.querySelectorAll('#me-lookback-btns button').forEach((b) => { b.classList.toggle('me-active', parseInt(b.dataset.days) === days); }); loadAvailablePaths(); if (document.getElementById('me-lookup-contexts').checked) { lookupContexts(); } } function contextDisplayName(ctx, vesselNames) { if (ctx === 'self' || ctx === 'vessels.self') return 'Self'; const mmsiMatch = ctx.match(/mmsi:(\d+)/); const mmsi = mmsiMatch ? mmsiMatch[1] : null; const name = vesselNames ? vesselNames.get(ctx) : null; if (name && mmsi) return `${name} (${mmsi})`; if (name) return name; if (mmsi) return `MMSI ${mmsi}`; const lastDot = ctx.lastIndexOf('.'); return lastDot >= 0 ? ctx.substring(lastDot + 1) : ctx; } async function fetchVesselNames(contexts, from, to) { const names = new Map(); // Batch in groups of 10 to avoid overwhelming the server const ctxList = (Array.isArray(contexts) ? contexts : []) .map((c) => c.context || c) .filter((ctx) => ctx !== 'self' && ctx !== 'vessels.self'); for (let i = 0; i < ctxList.length; i += 10) { const batch = ctxList.slice(i, i + 10); await Promise.all(batch.map(async (ctx) => { try { const url = `/signalk/v1/history/values?context=${encodeURIComponent(ctx)}&paths=name&from=${from}&to=${to}&resolution=86400`; const resp = await fetch(url); if (!resp.ok) return; const data = await resp.json(); const result = Array.isArray(data) ? data[0] : data; if (result && result.data && result.data.length > 0) { const lastRow = result.data[result.data.length - 1]; if (lastRow && lastRow[1]) { names.set(ctx, lastRow[1]); } } } catch (err) { // no name data for this context } })); } console.log(`Fetched ${names.size} vessel names from history API`); return names; } function renderContextList(filter) { const listEl = document.getElementById('me-context-list'); if (!listEl) return; listEl.innerHTML = ''; const filterLower = (filter || '').toLowerCase(); // Self option if (!filterLower || 'self'.includes(filterLower)) { const row = document.createElement('label'); row.className = 'me-path-row'; row.innerHTML = `<input type="radio" name="me-ctx" value="self" ${state.context === 'self' ? 'checked' : ''} onchange="meSelectContext('self')" /> <span>Self</span>`; listEl.appendChild(row); } (Array.isArray(state.availableContexts) ? state.availableContexts : []).forEach((c) => { const ctx = c.context || c; const label = contextDisplayName(ctx, state.vesselNames); if (filterLower && !label.toLowerCase().includes(filterLower) && !ctx.toLowerCase().includes(filterLower)) return; const row = document.createElement('label'); row.className = 'me-path-row'; row.innerHTML = `<input type="radio" name="me-ctx" value="${ctx}" ${state.context === ctx ? 'checked' : ''} onchange="meSelectContext('${ctx}')" /> ${label}`; listEl.appendChild(row); }); } export function filterContexts(value) { renderContextList(value); } export function selectContext(ctx) { state.context = ctx; state._pathsStale = true; loadAvailablePaths(); } export async function lookupContexts() { const cb = document.getElementById('me-lookup-contexts'); if (!cb.checked) { state.context = 'self'; document.getElementById('me-context-select').style.display = 'none'; return; } const { from, to } = getQueryTimeRange(); let url; if (state.areaType === 'bbox') { const g = state.areaGeometry; url = `/api/history/contexts/spatial?from=${from}&to=${to}&bbox=${g.west},${g.south},${g.east},${g.north}`; } else if (state.areaType === 'radius') { const g = state.areaGeometry; url = `/api/history/contexts/spatial?from=${from}&to=${to}&radius=${g.lon},${g.lat},${g.radius}`; } else { url = `/signalk/v1/history/contexts?from=${from}&to=${to}`; } try { const resp = await fetch(url); const data = await resp.json(); state.availableContexts = data.contexts || data || []; const countEl = document.getElementById('me-context-count'); if (countEl) { const n = Array.isArray(state.availableContexts) ? state.availableContexts.length : 0; countEl.textContent = `${n} vessel${n !== 1 ? 's' : ''} found`; } // Clear filter and render list immediately with MMSI-only labels const filterEl = document.getElementById('me-context-filter'); if (filterEl) filterEl.value = ''; renderContextList(''); document.getElementById('me-context-select').style.display = 'block'; // Fetch vessel names in background, then re-render with names fetchVesselNames(state.availableContexts, from, to).then((names) => { state.vesselNames = names; renderContextList(filterEl ? filterEl.value : ''); }).catch((err) => { console.error('Failed to fetch vessel names:', err); }); } catch (err) { console.error('Failed to load contexts:', err); } } export async function loadAvailablePaths() { // Avoid redundant fetches — only reload if we have no paths yet if (state.availablePaths.length > 0 && !state._pathsStale) return; const { from, to } = getQueryTimeRange(); const ctx = state.context === 'self' ? '' : state.context; // History API routes are on the main server router, not the plugin router const url = `/api/history/paths?context=${encodeURIComponent(ctx)}&from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`; try { const resp = await fetch(url); const data = await resp.json(); // Response is a plain string array like ["navigation.speedOverGround", ...] state.availablePaths = Array.isArray(data) ? data : []; state._pathsStale = false; // Remove any selected paths that no longer exist in available paths for (const p of state.selectedPaths) { if (!state.availablePaths.includes(p)) state.selectedPaths.delete(p); } renderPathSelector(); } catch (err) { console.error('Failed to load paths:', err); } } export function togglePathSelection(path) { if (state.selectedPaths.has(path)) { state.selectedPaths.delete(path); } else { if (state.selectedPaths.size >= 3) return; state.selectedPaths.add(path); } renderPathSelector(); } export function removeSelectedPath(path) { state.selectedPaths.delete(path); renderPathSelector(); } export function setPathFilter(value) { state.pathFilter = value.toLowerCase(); renderPathSelector(); } function renderPathSelector() { const container = document.getElementById('me-path-selector'); if (!container) return; const chipsEl = container.querySelector('.me-path-chips'); const listEl = container.querySelector('.me-path-list'); const filterEl = container.querySelector('.me-path-filter'); // Render chips chipsEl.innerHTML = ''; state.selectedPaths.forEach((path) => { const chip = document.createElement('span'); chip.className = 'me-path-chip'; chip.innerHTML = `${path} <button onclick="meRemoveSelectedPath('${path}')">\u00d7</button>`; chipsEl.appendChild(chip); }); // Preserve filter input value (don't replace it) if (filterEl && filterEl.value.toLowerCase() !== state.pathFilter) { filterEl.value = state.pathFilter; } // Render checkbox list listEl.innerHTML = ''; const filter = state.pathFilter; const atMax = state.selectedPaths.size >= 3; state.availablePaths.forEach((path) => { if (filter && !path.toLowerCase().includes(filter)) return; const isSelected = state.selectedPaths.has(path); const disabled = atMax && !isSelected; const row = document.createElement('label'); row.className = 'me-path-row' + (disabled ? ' me-path-disabled' : ''); row.innerHTML = `<input type="checkbox" ${isSelected ? 'checked' : ''} ${disabled ? 'disabled' : ''} onchange="meTogglePathSelection('${path}')" /> ${path}`; listEl.appendChild(row); }); } function getSelectedPaths() { const paths = []; state.selectedPaths.forEach((pathStr) => { let p = pathStr; const agg = document.getElementById('me-aggregation').value; if (agg && agg !== 'average') p += `:${agg}`; const smooth = document.getElementById('me-smoothing').value; if (smooth === 'sma') { p += `:sma:${document.getElementById('me-smooth-param').value || 5}`; } else if (smooth === 'ema') { p += `:ema:${document.getElementById('me-smooth-param').value || 0.3}`; } paths.push(p); }); return paths; } function getQueryTimeRange() { if (state.timeMode === 'range') { const fromEl = document.getElementById('me-from'); const toEl = document.getElementById('me-to'); const fromDate = fromEl && fromEl.value ? new Date(fromEl.value) : null; const toDate = toEl && toEl.value ? new Date(toEl.value + 'T23:59:59') : null; // Fall back to 7-day lookback if inputs are empty/invalid if (!fromDate || isNaN(fromDate.getTime()) || !toDate || isNaN(toDate.getTime())) { const now = new Date(); return { from: new Date(now.getTime() - 7 * 86400000).toISOString(), to: now.toISOString() }; } return { from: fromDate.toISOString(), to: toDate.toISOString() }; } const now = new Date(); const from = new Date(now.getTime() - state.lookbackDays * 86400000); return { from: from.toISOString(), to: now.toISOString() }; } // ============================================================================= // F. API CALLS // ============================================================================= export async function executeMapQuery() { if (!state.areaGeometry) { setToolbarStatus('Draw an area on the map first.'); return; } const paths = getSelectedPaths(); if (paths.length === 0) { setToolbarStatus('Select at least one path.'); return; } state.mode = STATES.LOADING; document.getElementById('me-loading').style.display = 'block'; document.getElementById('me-results-panel').style.display = 'none'; const exportBtns = document.getElementById('me-export-btns'); if (exportBtns) exportBtns.style.display = 'none'; const { from, to } = getQueryTimeRange(); const ctx = state.context === 'self' ? '' : state.context; // Always include navigation.position for track const allPaths = ['navigation.position', ...paths.filter((p) => !p.startsWith('navigation.position'))]; let spatialParam = ''; if (state.areaType === 'bbox') { const g = state.areaGeometry; spatialParam = `&bbox=${g.west},${g.south},${g.east},${g.north}`; } else if (state.areaType === 'radius') { const g = state.areaGeometry; spatialParam = `&radius=${g.lon},${g.lat},${g.radius}`; } const url = `/signalk/v1/history/values?context=${ctx}` + `&from=${from}&to=${to}` + `&paths=${allPaths.join(',')}` + spatialParam; try { const resp = await fetch(url); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const data = await resp.json(); state.results = Array.isArray(data) ? data : [data]; state.mode = STATES.RESULTS; state.selectedIndex = -1; processResults(); const pathNames = getUniquePathNames(); await fetchPathMeta(pathNames); renderMapResults(); renderResultsTable(); renderLegend(); renderSummary(); // Auto-select first data point so detail/sparklines are visible immediately if (state.dataPoints.length > 0) { selectPoint(0); } document.getElementById('me-loading').style.display = 'none'; document.getElementById('me-results-panel').style.display = 'block'; const exportBtns2 = document.getElementById('me-export-btns'); if (exportBtns2) exportBtns2.style.display = 'flex'; } catch (err) { console.error('Map query failed:', err); document.getElementById('me-loading').style.display = 'none'; setToolbarStatus(`Query failed: ${err.message}`); state.mode = STATES.QUERY_CONFIG; } } function processResults() { state.dataPoints = []; if (!state.results || state.results.length === 0) return; // Find position result and value results let posResult = null; const valueResults = []; state.results.forEach((r) => { if (!r || !r.values) return; r.values.forEach((v, vi) => { if (v.path === 'navigation.position') { posResult = { result: r, valueIndex: vi }; } else { valueResults.push({ result: r, valueIndex: vi, path: v.path }); } }); }); if (!posResult) return; const posData = posResult.result.data || []; // Build points array with positions + values at matching timestamps posData.forEach((row, rowIdx) => { const ts = row[0]; const posVal = row[posResult.valueIndex + 1]; if (!posVal) return; // Position can be {latitude, longitude} or [lon, lat] let lat, lon; if (typeof posVal === 'object' && posVal.latitude !== undefined) { lat = posVal.latitude; lon = posVal.longitude; } else if (Array.isArray(posVal)) { lon = posVal[0]; lat = posVal[1]; } else { return; } const point = { ts, lat, lon, values: {} }; // Collect values from each result set at same row index valueResults.forEach((vr) => { const vrData = vr.result.data || []; if (rowIdx < vrData.length) { const val = vrData[rowIdx][vr.valueIndex + 1]; if (val !== null && val !== undefined) { point.values[vr.path] = val; } } }); state.dataPoints.push(point); }); // Initialize legend: first series visible and active state.activeSeries = 0; state.visibleSeriesIndices = new Set([0]); } function getUniquePathNames() { const names = new Set(); state.dataPoints.forEach((pt) => { Object.keys(pt.values).forEach((k) => names.add(k)); }); return Array.from(names); } async function fetchPathMeta(paths) { // Build context prefix for meta API: vessels/self or vessels/urn:mrn:... let ctxPrefix = 'vessels/self'; if (state.context && state.context !== 'self') { // context is like "vessels.urn:mrn:imo:mmsi:123456789" ctxPrefix = state.context.replace(/\./g, '/'); } for (const p of paths) { const basePath = p.split(':')[0]; // strip aggregation/smoothing suffixes if (state.pathMeta[basePath]) continue; try { const skPath = basePath.replace(/\./g, '/'); const resp = await fetch(`/signalk/v1/api/${ctxPrefix}/${skPath}/meta`); if (resp.ok) { const meta = await resp.json(); if (meta.displayUnits) { state.pathMeta[basePath] = meta.displayUnits; } } } catch (err) { // no meta available for this path } } } function convertValue(value, pathName) { if (value === null || value === undefined) return value; const basePath = pathName.split(':')[0]; const meta = state.pathMeta[basePath]; if (!meta || !meta.formula) return value; try { // formula is like "value * 1.94384" return Function('value', `return ${meta.formula}`)(value); } catch { return value; } } function getUnitForPath(pathName) { const basePath = pathName.split(':')[0]; const meta = state.pathMeta[basePath]; if (meta && meta.symbol) return meta.symbol; return ''; } function getDisplayFormat(pathName) { const basePath = pathName.split(':')[0]; const meta = state.pathMeta[basePath]; return meta?.displayFormat || null; } // ============================================================================= // G. MAP RESULTS RENDERING // ============================================================================= function renderMapResults() { clearMapResults(); if (state.dataPoints.length === 0) return; const pathNames = getUniquePathNames(); const activePathName = pathNames[state.activeSeries] || pathNames[0]; const activeColor = PATH_COLORS[state.activeSeries % PATH_COLORS.length]; // Track polyline const trackCoords = state.dataPoints.map((pt) => [pt.lat, pt.lon]); state.trackLayer = L.polyline(trackCoords, { color: activeColor, weight: 2, opacity: 0.7, }).addTo(state.map); // Only render markers if at least one series is visible if (state.visibleSeriesIndices.size === 0) return; // Markers per data point — sized/colored by active series state.dataPoints.forEach((pt, idx) => { const val = pt.values[activePathName]; const isSelected = idx === state.selectedIndex; // Grey dot if no data for active path, colored otherwise let markerColor, markerRadius, markerOpacity; if (val === undefined || val === null) { markerColor = '#999'; markerRadius = 4; markerOpacity = 0.3; } else { markerColor = activeColor; markerRadius = scaleMarkerSize(val, activePathName); markerOpacity = 0.6; } const marker = L.circleMarker([pt.lat, pt.lon], { radius: markerRadius, color: isSelected ? '#FFD700' : markerColor, fillColor: isSelected ? '#FFD700' : markerColor, fillOpacity: isSelected ? 1 : markerOpacity, weight: isSelected ? 3 : 1, }).addTo(state.map); marker.on('click', () => selectPoint(idx)); marker.bindTooltip( () => { let tip = formatTimestamp(pt.ts); Object.entries(pt.values).forEach(([p, v]) => { tip += `<br>${shortenPath(p)}: ${formatValue(v, p)} ${getUnitForPath(p)}`; }); return tip; }, { direction: 'top' } ); state.markerLayers.push(marker); }); // Bring selected marker to front if (state.selectedIndex >= 0 && state.markerLayers[state.selectedIndex]) { state.markerLayers[state.selectedIndex].bringToFront(); } // Fit bounds to track if (state.trackLayer) { state.map.fitBounds(state.trackLayer.getBounds().pad(0.1)); } } function scaleMarkerSize(val, pathName) { if (val === undefined || val === null) return 4; // Get min/max for this path let min = Infinity, max = -Infinity; state.dataPoints.forEach((pt) => { const v = pt.values[pathName]; if (v !== undefined && v !== null) { if (v < min) min = v; if (v > max) max = v; } }); if (min === max) return 6; const normalized = (val - min) / (max - min); return 4 + normalized * 8; } function clearMapResults() { if (state.trackLayer) { state.map.removeLayer(state.trackLayer); state.trackLayer = null; } state.markerLayers.forEach((m) => state.map.removeLayer(m)); state.markerLayers = []; } // ============================================================================= // H. ALL POINTS TABLE // ============================================================================= function renderResultsTable() { const container = document.getElementById('me-table-body'); if (!container) return; const pathNames = getUniquePathNames(); // Header const thead = document.getElementById('me-table-head'); thead.innerHTML = `<tr><th>Time</th><th>Position</th>${pathNames.map((p) => `<th><span class="me-color-dot" style="background:${PATH_COLORS[pathNames.indexOf(p) % PATH_COLORS.length]}"></span>${shortenPath(p)}</th>`).join('')}</tr>`; // Body container.innerHTML = ''; state.dataPoints.forEach((pt, idx) => { const row = document.createElement('tr'); row.className = idx === state.selectedIndex ? 'me-selected-row' : ''; row.onclick = () => selectPoint(idx); let cells = `<td>${formatTimestamp(pt.ts)}</td><td>${toDDM(pt.lat, pt.lon)}</td>`; pathNames.forEach((p) => { const v = pt.values[p]; cells += `<td>${v !== undefined ? formatValue(v, p) + ' ' + getUnitForPath(p) : '-'}</td>`; }); row.innerHTML = cells; container.appendChild(row); }); } // ============================================================================= // I. DETAIL PANEL + PLAYBACK // ============================================================================= export function selectPoint(idx) { if (idx < 0 || idx >= state.dataPoints.length) return; state.selectedIndex = idx; // Update map markers renderMapResults(); // Update table highlight const rows = document.querySelectorAll('#me-table-body tr'); rows.forEach((r, i) => { r.className = i === idx ? 'me-selected-row' : ''; }); // Scroll table row into view if (rows[idx]) { rows[idx].scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } // Update detail panel renderDetailPanel(); // Update playback slider const slider = document.getElementById('me-playback-slider'); if (slider) slider.value = idx; } function renderDetailPanel() { const panel = document.getElementById('me-detail-content'); if (!panel) return; if (state.selectedIndex < 0) { panel.innerHTML = '<p>Click a point to see details.</p>'; return; } const pt = state.dataPoints[state.selectedIndex]; const pathNames = getUniquePathNames(); let html = ` <div class="me-detail-header"> <strong>${formatTimestamp(pt.ts)}</strong><br> <span>${toDDM(pt.lat, pt.lon)}</span> </div> <div class="me-detail-values"> `; pathNames.forEach((p, pi) => { const v = pt.values[p]; const unit = getUnitForPath(p); const color = PATH_COLORS[pi % PATH_COLORS.length]; html += ` <div class="me-detail-value-row"> <span class="me-color-dot" style="background:${color}"></span> <strong>${shortenPath(p)}:</strong> ${v !== undefined ? formatValue(v, p) + ' ' + unit : 'N/A'} </div> <div id="me-sparkline-${pi}" class="me-sparkline"></div> `; }); html += '</div>'; panel.innerHTML = html; // Render sparklines with Plotly pathNames.forEach((p, pi) => { const container = document.getElementById(`me-sparkline-${pi}`); if (!container) return; const xData = []; const yData = []; state.dataPoints.forEach((dp) => { if (dp.values[p] !== undefined) { xData.push(new Date(dp.ts)); yData.push(convertValue(dp.values[p], p)); } }); if (yData.length === 0) return; const color = PATH_COLORS[pi % PATH_COLORS.length]; const selectedTs = new Date(pt.ts); Plotly.newPlot( container, [ { x: xData, y: yData, type: 'scatter', mode: 'lines', line: { color, width: 1 }, hoverinfo: 'skip', }, ], { height: 80, margin: { t: 5, b: 20, l: 40, r: 5 }, xaxis: { showgrid: false, showticklabels: false }, yaxis: { showgrid: true, gridcolor: '#eee', title: { text: getUnitForPath(p), font: { size: 10 } } }, shapes: [ { type: 'line', x0: selectedTs, x1: selectedTs, y0: 0, y1: 1, yref: 'paper', line: { color: '#FF5722', width: 2 }, }, ], paper_bgcolor: 'transparent', plot_bgcolor: 'transparent', }, { staticPlot: true, responsive: true } ); }); // Update slider range const slider = document.getElementById('me-playback-slider'); if (slider) { slider.max = state.dataPoints.length - 1; slider.value = state.selectedIndex; } } export function onPlaybackSliderChange(value) { selectPoint(parseInt(value)); } export function togglePlayback() { if (state.playing) { stopPlayback(); } else { startPlayback(); } } function startPlayback() { if (state.dataPoints.length === 0) return; state.playing = true; document.getElementById('me-play-btn').textContent = '\u23F8'; if (state.selectedIndex < 0) state.selectedIndex = 0; const intervalMs = 200 / state.playSpeed; state.playInterval = setInterval(() => { const next = state.playReverse ? state.selectedIndex - 1 : state.selectedIndex + 1; if (next < 0 || next >= state.dataPoints.length) { stopPlayback(); return; } selectPoint(next); }, intervalMs); } function stopPlayback() { state.playing = false; if (state.playInterval) clearInterval(state.playInterval); state.playInterval = null; const btn = document.getElementById('me-play-btn'); if (btn) btn.textContent = '\u25B6'; } export function togglePlayReverse() { state.playReverse = !state.playReverse; const btn = document.getElementById('me-reverse-btn'); if (btn) btn.classList.toggle('me-active', state.playReverse); if (state.playing) { stopPlayback(); startPlayback(); } } export function skipPlayback(delta) { if (state.dataPoints.length === 0) return; let next; if (delta === -Infinity) { next = 0; } else if (delta === Infinity) { next = state.dataPoints.length - 1; } else { next = Math.max(0, Math.min(state.dataPoints.length - 1, state.selectedIndex + delta)); } selectPoint(next); } export function setPlaySpeed(speed) { state.playSpeed = parseFloat(speed); if (state.playing) { stopPlayback(); startPlayback(); } } // ============================================================================= // J. LEGEND // ============================================================================= function renderLegend() { const container = document.getElementById('me-legend'); if (!container) return; const pathNames = getUniquePathNames(); container.innerHTML = ''; pathNames.forEach((p, i) => { const color = PATH_COLORS[i % PATH_COLORS.length]; const isVisible = state.visibleSeriesIndices.has(i); const isActive = i === state.activeSeries; const chip = document.createElement('div'); chip.className = `me-legend-chip ${!isVisible ? 'me-hidden' : ''} ${isActive ? 'me-active-series' : ''}`; chip.innerHTML = `<span class="me-color-dot" style="background:${isVisible ? color : '#999'}"></span>${shortenPath(p)}`; chip.onclick = () => legendToggle(i); container.appendChild(chip); }); } // ZedDisplay 3-state toggle: // Hidden → Visible + Active // Visible + Inactive → Active (no visibility change) // Visible + Active → Hidden function legendToggle(index) { const isVisible = state.visibleSeriesIndices.has(index); const isActive = index === state.activeSeries; if (!isVisible) { // Hidden → show and make active state.visibleSeriesIndices.add(index); state.activeSeries = index; } else if (isVisible && !isActive) { // Visible but not active → make active state.activeSeries = index; } else { // Visible + active → hide state.visibleSeriesIndices.delete(index); if (state.visibleSeriesIndices.size > 0) { state.activeSeries = state.visibleSeriesIndices.values().next().value; } } renderLegend(); renderMapResults(); } // ============================================================================= // K. SUMMARY BAR // ============================================================================= function renderSummary() { const container = document.getElementById('me-summary'); if (!container || !state.results) return; const pathNames = getUniquePathNames(); const ctx = state.context === 'self' ? 'Self' : state.context; let areaStr = ''; if (state.areaType === 'bbox') { const g = state.areaGeometry; areaStr = `Bbox [${g.south.toFixed(3)},${g.west.toFixed(3)} to ${g.north.toFixed(3)},${g.east.toFixed(3)}]`; } else if (state.areaType === 'radius') { const g = state.areaGeometry; areaStr = `Radius ${g.radius}m from ${g.lat.toFixed(4)},${g.lon.toFixed(4)}`; } const { from, to } = getQueryTimeRange(); const fromStr = new Date(from).toLocaleDateString(); const toStr = new Date(to).toLocaleDateString(); let stats = ''; pathNames.forEach((p, i) => { const vals = state.dataPoints .map((dp) => dp.values[p]) .filter((v) => v !== undefined && v !== null); if (vals.length === 0) return; const min = Math.min(...vals); const max = Math.max(...vals); const avg = vals.reduce((a, b) => a + b, 0) / vals.length; const unit = getUnitForPath(p); const color = PATH_COLORS[i % PATH_COLORS.length]; stats += ` | <span style="color:${color}">${shortenPath(p)}</span>: ${formatValue(min, p)}/${formatValue(avg, p)}/${formatValue(max, p)} ${unit}`; }); container.innerHTML = `${ctx} | ${areaStr} | ${fromStr}–${toStr} | ${state.dataPoints.length} pts | ${pathNames.length} paths${stats}`; } // ============================================================================= // L. EXPORT // ============================================================================= export function exportCSV() { if (!state.dataPoints.length) return; const pathNames = getUniquePathNames(); let csv = `# SignalK Parquet Map Explorer Export\n`; csv += `# Context: ${state.context}\n`; csv += `# Area: ${state.areaType} ${JSON.stringify(state.areaGeometry)}\n`; csv += `# Exported: ${new Date().toISOString()}\n`; csv += `timestamp,latitude,longitude,${pathNames.join(',')}\n`; state.dataPoints.forEach((pt) => { const vals = pathNames.map((p) => pt.values[p] !== undefined ? pt.values[p] : '' ); csv += `${pt.ts},${pt.lat},${pt.lon},${vals.join(',')}\n`; }); downloadBlob(csv, 'map-explorer-export.csv', 'text/csv'); } export function exportGeoJSON() { if (!state.dataPoints.length) return; const pathNames = getUniquePathNames(); const features = []; // Search area feature if (state.areaType === 'bbox') { const g = state.areaGeometry; features.push({ type: 'Feature', properties: { type: 'search_area', areaType: 'bbox' }, geometry: { type: 'Polygon', coordinates: [ [ [g.west, g.south], [g.east, g.south], [g.east, g.north], [g.west, g.north], [g.west, g.south], ], ], }, }); } else if (state.areaType === 'radius') { const g = state.areaGeometry; features.push({ type: 'Feature', properties: { type: 'search_area', areaType: 'radius', radiusMeters: g.radius, }, geometry: { type: 'Point', coordinates: [g.lon, g.lat] }, }); } // Data point features state.dataPoints.forEach((pt) => { const props = { timestamp: pt.ts }; pathNames.forEach((p) => { if (pt.values[p] !== undefined) props[p] = pt.values[p]; }); features.push({ type: 'Feature', properties: props, geometry: { type: 'Point', coordinates: [pt.lon, pt.lat] }, }); }); const geojson = { type: 'FeatureCollection', features }; downloadBlob( JSON.stringify(geojson, null, 2), 'map-explorer-export.geojson', 'application/geo+json' ); } export function exportKML() { if (!state.dataPoints.length) return; const pathNames = getUniquePathNames(); let kml = `<?xml version="1.0" encoding="UTF-8"?> <kml xmlns="http://www.opengis.net/kml/2.2"> <Document> <name>SignalK Map Explorer Export</name> <description>Exported ${new Date().toISOString()}</description> <Style id="trackStyle"> <LineStyle><color>ffF39621</color><width>2</width></LineStyle> </Style> <Style id="pointStyle"> <IconStyle><scale>0.6</scale><Icon><href>http://maps.google.com/mapfiles/kml/paddle/blu-circle.png</href></Icon></IconStyle> </Style> <Placemark> <name>Track</name> <styleUrl>#trackStyle</styleUrl> <LineString> <coordinates>${state.dataPoints.map((pt) => `${pt.lon},${pt.lat},0`).join(' ')}</coordinates> </LineString> </Placemark> `; state.dataPoints.forEach((pt) => { const extData = pathNames .filter((p) => pt.values[p] !== undefined) .map( (p) => ` <Data name="${p}"><value>${pt.values[p]}</value></Data>` ) .join('\n'); kml += ` <Placemark> <name>${formatTimestamp(pt.ts)}</name> <TimeStamp><when>${pt.ts}</when></TimeStamp> <styleUrl>#pointStyle</styleUrl> <Point><coordinates>${pt.lon},${pt.lat},0</coordinates></Point> <ExtendedData> ${extData} </ExtendedData> </Placemark> `; }); kml += '</Document>\n</kml>'; downloadBlob(kml, 'map-explorer-export.kml', 'application/vnd.google-earth.kml+xml'); } // ============================================================================= // M. ROUTES, TRACKS & WAYPOINTS (SignalK Resources API) // ============================================================================= export function saveAsWaypoint() { if (state.selectedIndex < 0) { setToolbarStatus('Select a point first.'); return; } const pt = state.dataPoints[state.selectedIndex]; const name = prompt('Waypoint name:', `Wpt ${formatTimestamp(pt.ts)}`); if (!name) return; const id = crypto.randomUUID(); const data = { name, description: '', feature: { type: 'Feature', geometry: { type: 'Point', coordinates: [pt.lon, pt.lat] }, properties: {}, }, }; fetch(`/signalk/v2/api/resources/waypoints/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), }) .then((r) => { if (r.ok) setToolbarStatus(`Waypoint "${name}" saved.`); else setToolbarStatus('Failed to save waypoint.'); }) .catch(() => setToolbarStatus('Failed to save waypoint.')); } export function saveAsTrack() { if (state.dataPoints.length < 2) { setToolbarStatus('Need at least 2 points for a track.'); return; } const { from } = getQueryTimeRange(); const defaultName = `Track ${new Date(from).toLocaleDateString()}`; const name = prompt('Track name:', defaultName); if (!name) return; const coords = state.dataPoints.map((pt) => [pt.lon, pt.lat]); const id = crypto.randomUUID(); const data = { feature: { type: 'Feature', geometry: { type: 'MultiLineString', coordinates: [coords] }, properties: { name, description: `${coords.length} points` }, }, }; fetch(`/signalk/v2/api/resources/tracks/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), }) .then((r) => { if (r.ok) setToolbarStatus(`Track "${name}" saved (${coords.length} points).`); else setToolbarStatus('Failed to save track.'); }) .catch(() => setToolbarStatus('Failed to save track.')); } export function saveAsRoute() { if (state.dataPoints.length < 2) { setToolbarStatus('Need at least 2 points for a route.'); return; } state._routeCoords = null; state._routeHiResCoords = null; const cb = document.getElementById('me-route-hires'); if (cb) cb.checked = false; const statusEl = document.getElementById('me-route-hires-status'); if (statusEl) statusEl.textContent = ''; // Pre-fill route name with date-based default const nameInput = document.getElementById('me-route-name'); if (nameInput && !nameInput.value) { const { from } = getQueryTimeRange(); nameInput.value = `Route ${new Date(from).toLocaleDateString()}`; } if (nameInput) nameInput.style.border = ''; document.getElementById('me-route-modal').style.display = 'flex'; updateRoutePreview(); } export async function toggleRouteHiRes() { const cb = document.getElementById('me-route-hires'); const statusEl = document.getElementById('me-route-hires-status'); if (!cb.checked) { state._routeCoords = null; updateRoutePreview(); if (statusEl) statusEl.textContent = ''; return; } // Already fetched? if (state._routeHiResCoords) { state._routeCoords = state._routeHiResCoords; updateRoutePreview(); return; } if (statusEl) statusEl.textContent = 'Fetching high-resolution track...'; const { from, to } = getQueryTimeRang