diff --git a/public/js/app.js b/public/js/app.js index bdd9baa..e3fd69b 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -6,6 +6,7 @@ const state = { markers: {}, trails: {}, // Track position trail polylines for each node nodeLastHeard: {}, // Track last heard times for flash detection + nodeLastColor: {}, // Track last assigned color to prevent flipping refreshIntervals: [], timeUpdateInterval: null, lastSuccessfulUpdate: Date.now(), @@ -296,7 +297,7 @@ const ui = { return 0.2; }, - getMarkerColor(lastHeardDate) { + getMarkerColor(lastHeardDate, nodeId) { if (!lastHeardDate) return '#a50026'; // Darkest red for unknown // Handle SQLite datetime format @@ -324,8 +325,32 @@ const ui = { // 8 buckets over 360 minutes = 45 minutes per bucket const bucketSize = 360 / 8; const bucketIndex = Math.min(7, Math.floor(minutesSince / bucketSize)); + const newColor = colors[bucketIndex]; - return colors[bucketIndex]; + // Apply hysteresis: only change color if we're at least 2 minutes into the new bucket + // This prevents flipping at boundaries + if (nodeId && state.nodeLastColor[nodeId]) { + const lastColor = state.nodeLastColor[nodeId]; + const lastIndex = colors.indexOf(lastColor); + + // If we're in a different bucket, check if we're far enough in + if (lastIndex !== -1 && lastIndex !== bucketIndex) { + const minutesIntoBucket = minutesSince % bucketSize; + const minutesFromBoundary = Math.min(minutesIntoBucket, bucketSize - minutesIntoBucket); + + // Need to be at least 2 minutes away from boundary to change color + if (minutesFromBoundary < 2) { + return lastColor; // Keep the old color + } + } + } + + // Store the color for this node + if (nodeId) { + state.nodeLastColor[nodeId] = newColor; + } + + return newColor; }, getChannelName(channelNumber) { @@ -877,14 +902,25 @@ function displayPositions(positions) { positions.forEach(pos => { seenNodes.add(pos.node_id); const opacity = ui.getMarkerOpacity(pos.last_heard || pos.timestamp); - const color = ui.getMarkerColor(pos.last_heard || pos.timestamp); + const color = ui.getMarkerColor(pos.last_heard || pos.timestamp, pos.node_id); - // Check if node is newly heard (last_heard changed recently) - const currentLastHeard = pos.last_heard || pos.timestamp; + // Normalize timestamps for comparison + const normalizeTimestamp = (ts) => { + if (!ts) return null; + let dateStr = ts; + if (ts.includes(' ') && !ts.includes('T')) { + dateStr = ts.replace(' ', 'T') + 'Z'; + } + return new Date(dateStr).getTime(); + }; + + // Check if node is newly heard (last_heard changed in the last 10 seconds) + const currentLastHeard = normalizeTimestamp(pos.last_heard || pos.timestamp); const previousLastHeard = state.nodeLastHeard[pos.node_id]; - const isNewlyHeard = previousLastHeard && currentLastHeard !== previousLastHeard; + const timeSinceUpdate = currentLastHeard - (previousLastHeard || 0); + const isNewlyHeard = previousLastHeard && timeSinceUpdate > 0 && timeSinceUpdate < 10000; - // Update tracked last heard time + // Update tracked last heard time (store normalized timestamp) state.nodeLastHeard[pos.node_id] = currentLastHeard; // Add pulsing animation for newly heard nodes @@ -897,6 +933,9 @@ function displayPositions(positions) { iconAnchor: [8, 8] }); + // Use last_heard for consistency (fallback to timestamp if not available) + const relevantTimestamp = pos.last_heard || pos.timestamp; + const popupContent = `
${escapeHtml(pos.long_name || pos.short_name || pos.node_id)}
@@ -904,7 +943,7 @@ function displayPositions(positions) { Lat: ${pos.latitude.toFixed(6)}
Lon: ${pos.longitude.toFixed(6)}
${pos.altitude ? `Alt: ${pos.altitude}m
` : ''} - ${ui.formatRelativeTime(pos.timestamp)} + ${ui.formatRelativeTime(relevantTimestamp)}
0.05 || currentColor !== color) { + // Only update icon if opacity changed significantly (>0.1) OR color bucket changed + const opacityChanged = !currentOpacity || Math.abs(parseFloat(currentOpacity) - newOpacity) > 0.1; + const colorChanged = currentColor !== newColor; + + // Only update if there's a meaningful change (avoid flashing on minor updates) + if (opacityChanged || colorChanged) { const icon = L.divIcon({ className: 'custom-marker', - html: `
`, + html: `
`, iconSize: [16, 16], iconAnchor: [8, 8] });