From 534cf37adb9dd4e6c1e6c212debf2691dee21269 Mon Sep 17 00:00:00 2001 From: Adam Shapiro Date: Fri, 24 Jul 2026 18:14:00 -0400 Subject: [PATCH 01/13] Added time slider to map. --- .../fusion_engine_client/analysis/analyzer.py | 361 +++++++++++++++++- 1 file changed, 359 insertions(+), 2 deletions(-) diff --git a/python/fusion_engine_client/analysis/analyzer.py b/python/fusion_engine_client/analysis/analyzer.py index bbf46c15..27a88253 100755 --- a/python/fusion_engine_client/analysis/analyzer.py +++ b/python/fusion_engine_client/analysis/analyzer.py @@ -1243,6 +1243,12 @@ def _plot_data(name, selected_idx, flags, source_id, lla_deg, customdata_all, ma # Read the pose data. have_pose_data = False + primary_source_id = min(pose_source_ids) + overall_t_min = None + overall_t_max = None + profile_time_sec = None + profile_speed_mps = None + profile_gps_time_sec = None for source_id in pose_source_ids: result = self.reader.read(message_types=[PoseMessage], source_ids=[source_id], **self.params) pose_data = result[PoseMessage.MESSAGE_TYPE] @@ -1263,8 +1269,21 @@ def _plot_data(name, selected_idx, flags, source_id, lla_deg, customdata_all, ma flags = pose_data.flags[valid_idx] lla_deg = pose_data.lla_deg[:, valid_idx] std_enu_m = pose_data.position_std_enu_m[:, valid_idx] + p1_time = pose_data.p1_time[valid_idx] - customdata_all = _build_position_customdata(p1_time=pose_data.p1_time[valid_idx], + t_min = float(np.min(p1_time)) + t_max = float(np.max(p1_time)) + overall_t_min = t_min if overall_t_min is None else min(overall_t_min, t_min) + overall_t_max = t_max if overall_t_max is None else max(overall_t_max, t_max) + + # Speed profile (for the time slider below the map) is only computed for the default source -- it's a + # visual aid for picking a time range, not a plotted data source, so it doesn't need every source's data. + if source_id == primary_source_id: + profile_time_sec = p1_time + profile_speed_mps = np.linalg.norm(pose_data.velocity_body_mps[:, valid_idx], axis=0) + profile_gps_time_sec = pose_data.gps_time[valid_idx] + + customdata_all = _build_position_customdata(p1_time=p1_time, gps_time=pose_data.gps_time[valid_idx], lla_deg=lla_deg, std_enu_m=std_enu_m) @@ -1324,8 +1343,34 @@ def _plot_data(name, selected_idx, flags, source_id, lla_deg, customdata_all, ma 'yanchor': 'top' }] + # Decimate the speed profile for the time slider so a long log doesn't inflate the HTML with a huge embedded + # array -- it's just a visual aid for picking a time range, precision doesn't matter. + _MAX_PROFILE_POINTS = 3000 + if profile_time_sec is not None and len(profile_time_sec) > 0: + order = np.argsort(profile_time_sec) + sorted_time = profile_time_sec[order] + sorted_speed = profile_speed_mps[order] + sorted_gps_time = profile_gps_time_sec[order] + if len(sorted_time) > _MAX_PROFILE_POINTS: + stride = int(np.ceil(len(sorted_time) / _MAX_PROFILE_POINTS)) + sorted_time = sorted_time[::stride] + sorted_speed = sorted_speed[::stride] + sorted_gps_time = sorted_gps_time[::stride] + profile_time_json = json.dumps(np.round(sorted_time, 3).tolist()) + profile_speed_json = json.dumps(np.round(sorted_speed, 3).tolist()) + profile_gps_time_json = json.dumps(np.round(sorted_gps_time, 3).tolist()) + else: + profile_time_json = '[]' + profile_speed_json = '[]' + profile_gps_time_json = '[]' + + slider_js = self._map_time_slider_js(t_min=overall_t_min, t_max=overall_t_max, + profile_time_json=profile_time_json, + profile_speed_json=profile_speed_json, + profile_gps_time_json=profile_gps_time_json) + self._add_figure(name="map", figure=figure, title="Vehicle Trajectory (Map)", config={'scrollZoom': True}, - custom_hover=False) + custom_hover=False, inject_js=slider_js) def plot_gnss_skyplot(self, decimate=True): for source_id in self._get_gnss_antenna_source_ids(): @@ -3754,6 +3799,318 @@ def _custom_tooltip_js(self, time_source: str = 'p1', precision: Optional[int] = }); """ + tick_reformat_js) + def _map_time_slider_js(self, t_min: float, t_max: float, profile_time_json: str, profile_speed_json: str, + profile_gps_time_json: str) -> str: + """! + @brief Build JS for a time-range control injected below @ref plot_map()'s figure. + + Draws a speed-vs-time background chart (from pose velocity data) with a draggable/resizable window on top: + dragging an edge narrows the P1 time range shown on the map, dragging the body pans it, and double-clicking + resets it to the full range. Filtering re-slices each trace's original (already-rendered) + lat/lon/customdata via `Plotly.restyle()`, so no extra per-window traces are added to the page. + + @param t_min/t_max The full P1 time range (sec) spanned by the map's traces (matches `customdata[1]`). + @param profile_time_json/profile_speed_json JSON arrays of (decimated) P1 time (sec) and 3D speed (m/s) for + the background chart, from the default pose source. + @param profile_gps_time_json JSON array of GPS time (sec), parallel to `profile_time_json`, used to label + the X axis in `gps`/`utc` mode (see `self.time_type`) -- P1 and GPS time aren't a fixed offset apart, + so converting an arbitrary tick's P1 time requires interpolating within the actual per-point data. + + @return The JS to pass as `inject_js` to @ref _add_figure(). + """ + js = """\ +(function() { + var P1_TIME_MIN = __T_MIN__; + var P1_TIME_MAX = __T_MAX__; + var PROFILE_TIME = __PROFILE_TIME__; + var PROFILE_SPEED = __PROFILE_SPEED__; + var PROFILE_GPS_TIME = __PROFILE_GPS_TIME__; + var SECONDS_PER_WEEK = 7 * 24 * 3600.0; + var SLIDER_HEIGHT_PX = 130; + var TRACK_INSET_PX = 16; + var X_AXIS_LABEL_PX = 14; + var MIN_WINDOW_SEC = Math.max(1e-3, (P1_TIME_MAX - P1_TIME_MIN) * 0.001); + + // Snapshot each trace's original lat/lon/customdata before any restyle() call mutates figure.data in place -- + // narrowing/widening the window always re-filters from this pristine copy, never from what's currently displayed. + var ORIGINAL_TRACES = figure.data.map(function(trace) { + return { + lat: trace.lat ? trace.lat.slice() : null, + lon: trace.lon ? trace.lon.slice() : null, + customdata: trace.customdata ? trace.customdata.slice() : null, + }; + }); + + // Reflow so the map shrinks to make room for the slider below it, instead of the slider being pushed below the + // fold by the map's normal 100vh height. + document.documentElement.style.height = '100%'; + document.body.style.height = '100%'; + document.body.style.margin = '0'; + var mapContainer = figure.parentNode; + mapContainer.style.height = '100%'; + mapContainer.style.display = 'flex'; + mapContainer.style.flexDirection = 'column'; + figure.style.flex = '1 1 auto'; + figure.style.minHeight = '0'; + figure.style.width = '100%'; + + var sliderContainer = document.createElement('div'); + sliderContainer.style.cssText = 'flex:0 0 ' + SLIDER_HEIGHT_PX + 'px; width:100%; box-sizing:border-box; ' + + 'padding:8px ' + TRACK_INSET_PX + 'px; background:#f5f5f5; border-top:1px solid #ccc;'; + + var trackDiv = document.createElement('div'); + trackDiv.style.cssText = 'position:relative; width:100%; height:100%;'; + sliderContainer.appendChild(trackDiv); + + var canvas = document.createElement('canvas'); + canvas.style.cssText = 'position:absolute; left:0; top:0; width:100%; height:100%;'; + trackDiv.appendChild(canvas); + + // windowDiv (the draggable selection) only covers the plotted chart area, not the X axis label strip below it + // (see drawProfile()), so the highlighted band lines up with the speed curve it's overlaid on. + var windowDiv = document.createElement('div'); + windowDiv.style.cssText = 'position:absolute; top:0; bottom:' + X_AXIS_LABEL_PX + 'px; ' + + 'background:rgba(31,119,180,0.25); border:1px solid rgba(31,119,180,0.9); box-sizing:border-box; cursor:grab;'; + trackDiv.appendChild(windowDiv); + + // Solid, protruding grab bars -- a plain hit-region (no visible affordance) didn't make it obvious the window's + // edges are independently draggable to resize the range, as opposed to just dragging the body to pan it. + var HANDLE_CSS = 'position:absolute; top:-4px; bottom:-4px; width:9px; background:rgb(31,119,180); ' + + 'border-radius:3px; box-shadow:0 0 0 1px rgba(255,255,255,0.8); cursor:ew-resize;'; + var leftHandle = document.createElement('div'); + leftHandle.style.cssText = HANDLE_CSS + 'left:-5px;'; + windowDiv.appendChild(leftHandle); + + var rightHandle = document.createElement('div'); + rightHandle.style.cssText = HANDLE_CSS + 'right:-5px;'; + windowDiv.appendChild(rightHandle); + + mapContainer.appendChild(sliderContainer); + + function timeToFrac(t) { return (t - P1_TIME_MIN) / (P1_TIME_MAX - P1_TIME_MIN); } + function fracToTime(f) { return P1_TIME_MIN + f * (P1_TIME_MAX - P1_TIME_MIN); } + + function pixelToTime(clientX) { + var rect = trackDiv.getBoundingClientRect(); + var frac = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width)); + return fracToTime(frac); + } + + // Interpolate GPS time for an arbitrary P1 time using the actual per-point profile samples -- P1 and GPS time + // aren't related by a fixed offset (see Analyzer._map_time_slider_js() docstring), but both progress at ~1 + // sec/sec, so linear interpolation between the nearest two samples is effectively exact. + function p1ToGpsTime(p1) { + var n = PROFILE_GPS_TIME.length; + if (n < 2) return NaN; + var lo = 0, hi = n - 1; + if (p1 <= PROFILE_TIME[0]) { lo = 0; hi = 1; } + else if (p1 >= PROFILE_TIME[hi]) { lo = hi - 1; } + else { + while (hi - lo > 1) { + var mid = (lo + hi) >> 1; + if (PROFILE_TIME[mid] <= p1) lo = mid; else hi = mid; + } + } + var t0 = PROFILE_TIME[lo], t1 = PROFILE_TIME[hi]; + var frac = (t1 > t0) ? (p1 - t0) / (t1 - t0) : 0; + return PROFILE_GPS_TIME[lo] + frac * (PROFILE_GPS_TIME[hi] - PROFILE_GPS_TIME[lo]); + } + + // Match the X axis format used by the log's other time-series plots (see Analyzer.time_type / _resolve_x_axis()). + function formatTickLabel(p1) { + if (time_axis_type === 'relative') { + return (p1 - (p1_t0_sec || 0)).toFixed(1) + ' s'; + } + if (time_axis_type === 'p1') { + return p1.toFixed(1) + ' s'; + } + var gps = p1ToGpsTime(p1); + if (isNaN(gps)) { + return p1.toFixed(1) + ' s'; + } + if (time_axis_type === 'gps') { + var week = Math.floor(gps / SECONDS_PER_WEEK); + var tow_sec = gps - week * SECONDS_PER_WEEK; + return week + ':' + tow_sec.toFixed(1); + } + // 'utc' + if (typeof gps_posix_offset_sec !== 'number') { + return p1.toFixed(1) + ' s'; + } + var d = new Date((gps + gps_posix_offset_sec) * 1000.0); + return d.toISOString().substr(11, 8); + } + + function resizeCanvas() { + var rect = trackDiv.getBoundingClientRect(); + var dpr = window.devicePixelRatio || 1; + canvas.width = Math.round(rect.width * dpr); + canvas.height = Math.round(rect.height * dpr); + drawProfile(); + } + + function drawProfile() { + var ctx = canvas.getContext('2d'); + var dpr = window.devicePixelRatio || 1; + var w = canvas.width, h = canvas.height; + ctx.clearRect(0, 0, w, h); + + var axisPx = Math.round(X_AXIS_LABEL_PX * dpr); + var chartH = Math.max(0, h - axisPx); + + var maxSpeed = 0; + for (var i = 0; i < PROFILE_SPEED.length; i++) { + if (PROFILE_SPEED[i] > maxSpeed) maxSpeed = PROFILE_SPEED[i]; + } + maxSpeed = Math.max(1, Math.ceil(maxSpeed)); + + if (PROFILE_TIME.length >= 2) { + function y(speed) { return chartH - (speed / maxSpeed) * chartH; } + ctx.beginPath(); + for (var i = 0; i < PROFILE_TIME.length; i++) { + var x = timeToFrac(PROFILE_TIME[i]) * w; + if (i === 0) ctx.moveTo(x, y(PROFILE_SPEED[i])); else ctx.lineTo(x, y(PROFILE_SPEED[i])); + } + ctx.strokeStyle = '#ff7f0e'; + ctx.lineWidth = Math.max(1, 1.5 * dpr); + ctx.stroke(); + } + + // Y axis context (0 at the baseline, ceil(max) at the top) -- without this there's no indication the + // background trace is even speed, let alone its scale. + ctx.fillStyle = 'rgba(90,90,90,0.95)'; + ctx.font = Math.round(10 * dpr) + 'px sans-serif'; + ctx.textAlign = 'left'; + ctx.textBaseline = 'top'; + ctx.fillText('Speed: ' + maxSpeed + ' m/s', 4 * dpr, 3 * dpr); + ctx.textBaseline = 'alphabetic'; + ctx.fillText('0 m/s', 4 * dpr, chartH - 3 * dpr); + + // X axis time, in whatever format the rest of the log's plots use (self.time_type). + var tickFracs = [0, 0.25, 0.5, 0.75, 1.0]; + ctx.font = Math.round(9 * dpr) + 'px sans-serif'; + ctx.textBaseline = 'top'; + tickFracs.forEach(function(f, idx) { + ctx.textAlign = (idx === 0) ? 'left' : (idx === tickFracs.length - 1) ? 'right' : 'center'; + ctx.fillText(formatTickLabel(fracToTime(f)), f * w, chartH + 2 * dpr); + }); + } + + var winStart = P1_TIME_MIN; + var winEnd = P1_TIME_MAX; + + function updateWindowDivStyle() { + var f0 = timeToFrac(winStart), f1 = timeToFrac(winEnd); + windowDiv.style.left = (f0 * 100) + '%'; + windowDiv.style.width = Math.max(0, (f1 - f0) * 100) + '%'; + } + + // Re-slice from ORIGINAL_TRACES (not figure.data) so widening the window can bring back points a previous + // restyle() dropped. + function applyMapFilter() { + var traceIndices = [], latUpdate = [], lonUpdate = [], cdUpdate = []; + for (var i = 0; i < ORIGINAL_TRACES.length; i++) { + var orig = ORIGINAL_TRACES[i]; + if (!orig.customdata) continue; + var lat = [], lon = [], cd = []; + for (var j = 0; j < orig.customdata.length; j++) { + var t = orig.customdata[j][1]; + if (t >= winStart && t <= winEnd) { + lat.push(orig.lat[j]); + lon.push(orig.lon[j]); + cd.push(orig.customdata[j]); + } + } + traceIndices.push(i); + latUpdate.push(lat); + lonUpdate.push(lon); + cdUpdate.push(cd); + } + if (traceIndices.length > 0) { + Plotly.restyle(figure, {lat: latUpdate, lon: lonUpdate, customdata: cdUpdate}, traceIndices); + } + } + + var pendingFilter = null; + function scheduleFilter() { + updateWindowDivStyle(); + if (pendingFilter) return; + pendingFilter = setTimeout(function() { + pendingFilter = null; + applyMapFilter(); + }, 16); + } + + function resetWindow() { + winStart = P1_TIME_MIN; + winEnd = P1_TIME_MAX; + scheduleFilter(); + } + + var dragMode = null, dragStartX = 0, dragWinStart = 0, dragWinEnd = 0; + + function onPointerMove(evt) { + if (!dragMode) return; + if (dragMode === 'left') { + winStart = Math.max(P1_TIME_MIN, Math.min(pixelToTime(evt.clientX), winEnd - MIN_WINDOW_SEC)); + } else if (dragMode === 'right') { + winEnd = Math.min(P1_TIME_MAX, Math.max(pixelToTime(evt.clientX), winStart + MIN_WINDOW_SEC)); + } else if (dragMode === 'pan') { + var rect = trackDiv.getBoundingClientRect(); + var deltaTime = ((evt.clientX - dragStartX) / rect.width) * (P1_TIME_MAX - P1_TIME_MIN); + var width = dragWinEnd - dragWinStart; + var newStart = dragWinStart + deltaTime, newEnd = dragWinEnd + deltaTime; + if (newStart < P1_TIME_MIN) { newStart = P1_TIME_MIN; newEnd = newStart + width; } + if (newEnd > P1_TIME_MAX) { newEnd = P1_TIME_MAX; newStart = newEnd - width; } + winStart = newStart; + winEnd = newEnd; + } + scheduleFilter(); + } + + function onPointerUp() { + dragMode = null; + windowDiv.style.cursor = 'grab'; + document.removeEventListener('mousemove', onPointerMove); + document.removeEventListener('mouseup', onPointerUp); + } + + function beginDrag(mode) { + return function(evt) { + evt.preventDefault(); + evt.stopPropagation(); + dragMode = mode; + dragStartX = evt.clientX; + dragWinStart = winStart; + dragWinEnd = winEnd; + if (mode === 'pan') windowDiv.style.cursor = 'grabbing'; + document.addEventListener('mousemove', onPointerMove); + document.addEventListener('mouseup', onPointerUp); + }; + } + + leftHandle.addEventListener('mousedown', beginDrag('left')); + rightHandle.addEventListener('mousedown', beginDrag('right')); + windowDiv.addEventListener('mousedown', function(evt) { + if (evt.target === leftHandle || evt.target === rightHandle) return; + beginDrag('pan')(evt); + }); + trackDiv.addEventListener('dblclick', resetWindow); + + window.addEventListener('resize', function() { + setTimeout(function() { Plotly.Plots.resize(figure); resizeCanvas(); }, 0); + }); + + updateWindowDivStyle(); + setTimeout(function() { Plotly.Plots.resize(figure); resizeCanvas(); }, 0); +})(); +""" + return (js.replace('__T_MIN__', json.dumps(t_min)) + .replace('__T_MAX__', json.dumps(t_max)) + .replace('__PROFILE_TIME__', profile_time_json) + .replace('__PROFILE_SPEED__', profile_speed_json) + .replace('__PROFILE_GPS_TIME__', profile_gps_time_json)) + def _auto_detect_message_type(self, types: List[MessageType]): types = [t.MESSAGE_TYPE if inspect.isclass(t) else t for t in types] From 547db2e7bb14ed4608bae5ceead564f5b5ea08bd Mon Sep 17 00:00:00 2001 From: Adam Shapiro Date: Fri, 24 Jul 2026 18:40:29 -0400 Subject: [PATCH 02/13] Added time and speed axes. --- .../fusion_engine_client/analysis/analyzer.py | 73 ++++++++++++++----- 1 file changed, 54 insertions(+), 19 deletions(-) diff --git a/python/fusion_engine_client/analysis/analyzer.py b/python/fusion_engine_client/analysis/analyzer.py index 27a88253..fc3e1731 100755 --- a/python/fusion_engine_client/analysis/analyzer.py +++ b/python/fusion_engine_client/analysis/analyzer.py @@ -3826,8 +3826,9 @@ def _map_time_slider_js(self, t_min: float, t_max: float, profile_time_json: str var PROFILE_SPEED = __PROFILE_SPEED__; var PROFILE_GPS_TIME = __PROFILE_GPS_TIME__; var SECONDS_PER_WEEK = 7 * 24 * 3600.0; - var SLIDER_HEIGHT_PX = 130; + var SLIDER_HEIGHT_PX = 80; var TRACK_INSET_PX = 16; + var TRACK_PADDING_V_PX = 4; var X_AXIS_LABEL_PX = 14; var MIN_WINDOW_SEC = Math.max(1e-3, (P1_TIME_MAX - P1_TIME_MIN) * 0.001); @@ -3856,7 +3857,7 @@ def _map_time_slider_js(self, t_min: float, t_max: float, profile_time_json: str var sliderContainer = document.createElement('div'); sliderContainer.style.cssText = 'flex:0 0 ' + SLIDER_HEIGHT_PX + 'px; width:100%; box-sizing:border-box; ' + - 'padding:8px ' + TRACK_INSET_PX + 'px; background:#f5f5f5; border-top:1px solid #ccc;'; + 'padding:' + TRACK_PADDING_V_PX + 'px ' + TRACK_INSET_PX + 'px; background:#f5f5f5; border-top:1px solid #ccc;'; var trackDiv = document.createElement('div'); trackDiv.style.cssText = 'position:relative; width:100%; height:100%;'; @@ -3916,29 +3917,43 @@ def _map_time_slider_js(self, t_min: float, t_max: float, profile_time_json: str return PROFILE_GPS_TIME[lo] + frac * (PROFILE_GPS_TIME[hi] - PROFILE_GPS_TIME[lo]); } + // Split a P1 time into UTC calendar date + time-of-day, for the tick loop below to decide when a date needs to + // be shown (first tick, or a tick that landed on a different day than the previous one). Returns null if UTC + // can't be resolved (no GPS/POSIX offset, or no profile data to interpolate GPS time from). + function utcPartsForP1(p1) { + var gps = p1ToGpsTime(p1); + if (isNaN(gps) || typeof gps_posix_offset_sec !== 'number') { + return null; + } + var iso = new Date((gps + gps_posix_offset_sec) * 1000.0).toISOString(); + return { date: iso.slice(0, 10).split('-').join('/'), time: iso.substr(11, 8) }; + } + // Match the X axis format used by the log's other time-series plots (see Analyzer.time_type / _resolve_x_axis()). - function formatTickLabel(p1) { + // The domain label ("Rel:", "P1:", "GPS:") only needs to appear once, on the first tick -- it's the same for + // every tick after that. + function formatTickLabel(p1, is_first) { if (time_axis_type === 'relative') { - return (p1 - (p1_t0_sec || 0)).toFixed(1) + ' s'; + var s = (p1 - (p1_t0_sec || 0)).toFixed(1) + ' s'; + return is_first ? 'Rel: ' + s : s; } if (time_axis_type === 'p1') { - return p1.toFixed(1) + ' s'; - } - var gps = p1ToGpsTime(p1); - if (isNaN(gps)) { - return p1.toFixed(1) + ' s'; + var s = p1.toFixed(1) + ' s'; + return is_first ? 'P1: ' + s : s; } if (time_axis_type === 'gps') { + var gps = p1ToGpsTime(p1); + if (isNaN(gps)) { + return p1.toFixed(1) + ' s'; + } var week = Math.floor(gps / SECONDS_PER_WEEK); var tow_sec = gps - week * SECONDS_PER_WEEK; - return week + ':' + tow_sec.toFixed(1); - } - // 'utc' - if (typeof gps_posix_offset_sec !== 'number') { - return p1.toFixed(1) + ' s'; + var s = week + ':' + tow_sec.toFixed(1); + return is_first ? 'GPS: ' + s : s; } - var d = new Date((gps + gps_posix_offset_sec) * 1000.0); - return d.toISOString().substr(11, 8); + // 'utc' -- no date-change context here (see the tick loop's own UTC handling below), just the time of day. + var parts = utcPartsForP1(p1); + return parts ? parts.time : p1.toFixed(1) + ' s'; } function resizeCanvas() { @@ -3982,17 +3997,37 @@ def _map_time_slider_js(self, t_min: float, t_max: float, profile_time_json: str ctx.font = Math.round(10 * dpr) + 'px sans-serif'; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; - ctx.fillText('Speed: ' + maxSpeed + ' m/s', 4 * dpr, 3 * dpr); + ctx.fillText(maxSpeed + ' m/s', 4 * dpr, 3 * dpr); ctx.textBaseline = 'alphabetic'; ctx.fillText('0 m/s', 4 * dpr, chartH - 3 * dpr); - // X axis time, in whatever format the rest of the log's plots use (self.time_type). + // X axis time, in whatever format the rest of the log's plots use (self.time_type). The domain marker + // ("Rel:"/"P1:"/"GPS:"/"UTC:") only appears once, on the first tick. In 'utc' mode, the bare time of day is + // also ambiguous about which day it's from, so the first tick -- and any later tick that lands on a different + // UTC calendar day than the one before it, however many days apart -- gets the date too (but not the "UTC:" + // marker again, since that was already established by the first tick). var tickFracs = [0, 0.25, 0.5, 0.75, 1.0]; ctx.font = Math.round(9 * dpr) + 'px sans-serif'; ctx.textBaseline = 'top'; + var lastUtcDate = null; tickFracs.forEach(function(f, idx) { ctx.textAlign = (idx === 0) ? 'left' : (idx === tickFracs.length - 1) ? 'right' : 'center'; - ctx.fillText(formatTickLabel(fracToTime(f)), f * w, chartH + 2 * dpr); + var p1 = fracToTime(f); + var label; + if (time_axis_type === 'utc') { + var parts = utcPartsForP1(p1); + if (parts === null) { + label = p1.toFixed(1) + ' s'; + } else { + var showDate = (idx === 0) || (parts.date !== lastUtcDate); + lastUtcDate = parts.date; + var dateTime = showDate ? (parts.date + ' ' + parts.time) : parts.time; + label = (idx === 0) ? ('UTC: ' + dateTime) : dateTime; + } + } else { + label = formatTickLabel(p1, idx === 0); + } + ctx.fillText(label, f * w, chartH + 2 * dpr); }); } From 0a9fc71cc5aff9d424ef970bc624f80a60813deb Mon Sep 17 00:00:00 2001 From: Adam Shapiro Date: Fri, 24 Jul 2026 18:45:11 -0400 Subject: [PATCH 03/13] Moved time slider to .js file. --- .../fusion_engine_client/analysis/analyzer.py | 342 +----------------- .../analysis/plotly_map_time_slider.js | 329 +++++++++++++++++ 2 files changed, 341 insertions(+), 330 deletions(-) create mode 100644 python/fusion_engine_client/analysis/plotly_map_time_slider.js diff --git a/python/fusion_engine_client/analysis/analyzer.py b/python/fusion_engine_client/analysis/analyzer.py index fc3e1731..a316ae0f 100755 --- a/python/fusion_engine_client/analysis/analyzer.py +++ b/python/fusion_engine_client/analysis/analyzer.py @@ -3804,10 +3804,9 @@ def _map_time_slider_js(self, t_min: float, t_max: float, profile_time_json: str """! @brief Build JS for a time-range control injected below @ref plot_map()'s figure. - Draws a speed-vs-time background chart (from pose velocity data) with a draggable/resizable window on top: - dragging an edge narrows the P1 time range shown on the map, dragging the body pans it, and double-clicking - resets it to the full range. Filtering re-slices each trace's original (already-rendered) - lat/lon/customdata via `Plotly.restyle()`, so no extra per-window traces are added to the page. + The control itself (DOM/canvas setup, drag handling, axis formatting) lives in `plotly_map_time_slider.js`, + injected the same way as `plotly_data_support.js` (see @ref __write_html_and_inject_js()); this just + supplies the per-log data that static file reads from a handful of `MAP_SLIDER_*` globals. @param t_min/t_max The full P1 time range (sec) spanned by the map's traces (matches `customdata[1]`). @param profile_time_json/profile_speed_json JSON arrays of (decimated) P1 time (sec) and 3D speed (m/s) for @@ -3818,333 +3817,16 @@ def _map_time_slider_js(self, t_min: float, t_max: float, profile_time_json: str @return The JS to pass as `inject_js` to @ref _add_figure(). """ - js = """\ -(function() { - var P1_TIME_MIN = __T_MIN__; - var P1_TIME_MAX = __T_MAX__; - var PROFILE_TIME = __PROFILE_TIME__; - var PROFILE_SPEED = __PROFILE_SPEED__; - var PROFILE_GPS_TIME = __PROFILE_GPS_TIME__; - var SECONDS_PER_WEEK = 7 * 24 * 3600.0; - var SLIDER_HEIGHT_PX = 80; - var TRACK_INSET_PX = 16; - var TRACK_PADDING_V_PX = 4; - var X_AXIS_LABEL_PX = 14; - var MIN_WINDOW_SEC = Math.max(1e-3, (P1_TIME_MAX - P1_TIME_MIN) * 0.001); - - // Snapshot each trace's original lat/lon/customdata before any restyle() call mutates figure.data in place -- - // narrowing/widening the window always re-filters from this pristine copy, never from what's currently displayed. - var ORIGINAL_TRACES = figure.data.map(function(trace) { - return { - lat: trace.lat ? trace.lat.slice() : null, - lon: trace.lon ? trace.lon.slice() : null, - customdata: trace.customdata ? trace.customdata.slice() : null, - }; - }); - - // Reflow so the map shrinks to make room for the slider below it, instead of the slider being pushed below the - // fold by the map's normal 100vh height. - document.documentElement.style.height = '100%'; - document.body.style.height = '100%'; - document.body.style.margin = '0'; - var mapContainer = figure.parentNode; - mapContainer.style.height = '100%'; - mapContainer.style.display = 'flex'; - mapContainer.style.flexDirection = 'column'; - figure.style.flex = '1 1 auto'; - figure.style.minHeight = '0'; - figure.style.width = '100%'; - - var sliderContainer = document.createElement('div'); - sliderContainer.style.cssText = 'flex:0 0 ' + SLIDER_HEIGHT_PX + 'px; width:100%; box-sizing:border-box; ' + - 'padding:' + TRACK_PADDING_V_PX + 'px ' + TRACK_INSET_PX + 'px; background:#f5f5f5; border-top:1px solid #ccc;'; - - var trackDiv = document.createElement('div'); - trackDiv.style.cssText = 'position:relative; width:100%; height:100%;'; - sliderContainer.appendChild(trackDiv); - - var canvas = document.createElement('canvas'); - canvas.style.cssText = 'position:absolute; left:0; top:0; width:100%; height:100%;'; - trackDiv.appendChild(canvas); - - // windowDiv (the draggable selection) only covers the plotted chart area, not the X axis label strip below it - // (see drawProfile()), so the highlighted band lines up with the speed curve it's overlaid on. - var windowDiv = document.createElement('div'); - windowDiv.style.cssText = 'position:absolute; top:0; bottom:' + X_AXIS_LABEL_PX + 'px; ' + - 'background:rgba(31,119,180,0.25); border:1px solid rgba(31,119,180,0.9); box-sizing:border-box; cursor:grab;'; - trackDiv.appendChild(windowDiv); - - // Solid, protruding grab bars -- a plain hit-region (no visible affordance) didn't make it obvious the window's - // edges are independently draggable to resize the range, as opposed to just dragging the body to pan it. - var HANDLE_CSS = 'position:absolute; top:-4px; bottom:-4px; width:9px; background:rgb(31,119,180); ' + - 'border-radius:3px; box-shadow:0 0 0 1px rgba(255,255,255,0.8); cursor:ew-resize;'; - var leftHandle = document.createElement('div'); - leftHandle.style.cssText = HANDLE_CSS + 'left:-5px;'; - windowDiv.appendChild(leftHandle); - - var rightHandle = document.createElement('div'); - rightHandle.style.cssText = HANDLE_CSS + 'right:-5px;'; - windowDiv.appendChild(rightHandle); - - mapContainer.appendChild(sliderContainer); - - function timeToFrac(t) { return (t - P1_TIME_MIN) / (P1_TIME_MAX - P1_TIME_MIN); } - function fracToTime(f) { return P1_TIME_MIN + f * (P1_TIME_MAX - P1_TIME_MIN); } - - function pixelToTime(clientX) { - var rect = trackDiv.getBoundingClientRect(); - var frac = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width)); - return fracToTime(frac); - } - - // Interpolate GPS time for an arbitrary P1 time using the actual per-point profile samples -- P1 and GPS time - // aren't related by a fixed offset (see Analyzer._map_time_slider_js() docstring), but both progress at ~1 - // sec/sec, so linear interpolation between the nearest two samples is effectively exact. - function p1ToGpsTime(p1) { - var n = PROFILE_GPS_TIME.length; - if (n < 2) return NaN; - var lo = 0, hi = n - 1; - if (p1 <= PROFILE_TIME[0]) { lo = 0; hi = 1; } - else if (p1 >= PROFILE_TIME[hi]) { lo = hi - 1; } - else { - while (hi - lo > 1) { - var mid = (lo + hi) >> 1; - if (PROFILE_TIME[mid] <= p1) lo = mid; else hi = mid; - } - } - var t0 = PROFILE_TIME[lo], t1 = PROFILE_TIME[hi]; - var frac = (t1 > t0) ? (p1 - t0) / (t1 - t0) : 0; - return PROFILE_GPS_TIME[lo] + frac * (PROFILE_GPS_TIME[hi] - PROFILE_GPS_TIME[lo]); - } - - // Split a P1 time into UTC calendar date + time-of-day, for the tick loop below to decide when a date needs to - // be shown (first tick, or a tick that landed on a different day than the previous one). Returns null if UTC - // can't be resolved (no GPS/POSIX offset, or no profile data to interpolate GPS time from). - function utcPartsForP1(p1) { - var gps = p1ToGpsTime(p1); - if (isNaN(gps) || typeof gps_posix_offset_sec !== 'number') { - return null; - } - var iso = new Date((gps + gps_posix_offset_sec) * 1000.0).toISOString(); - return { date: iso.slice(0, 10).split('-').join('/'), time: iso.substr(11, 8) }; - } - - // Match the X axis format used by the log's other time-series plots (see Analyzer.time_type / _resolve_x_axis()). - // The domain label ("Rel:", "P1:", "GPS:") only needs to appear once, on the first tick -- it's the same for - // every tick after that. - function formatTickLabel(p1, is_first) { - if (time_axis_type === 'relative') { - var s = (p1 - (p1_t0_sec || 0)).toFixed(1) + ' s'; - return is_first ? 'Rel: ' + s : s; - } - if (time_axis_type === 'p1') { - var s = p1.toFixed(1) + ' s'; - return is_first ? 'P1: ' + s : s; - } - if (time_axis_type === 'gps') { - var gps = p1ToGpsTime(p1); - if (isNaN(gps)) { - return p1.toFixed(1) + ' s'; - } - var week = Math.floor(gps / SECONDS_PER_WEEK); - var tow_sec = gps - week * SECONDS_PER_WEEK; - var s = week + ':' + tow_sec.toFixed(1); - return is_first ? 'GPS: ' + s : s; - } - // 'utc' -- no date-change context here (see the tick loop's own UTC handling below), just the time of day. - var parts = utcPartsForP1(p1); - return parts ? parts.time : p1.toFixed(1) + ' s'; - } - - function resizeCanvas() { - var rect = trackDiv.getBoundingClientRect(); - var dpr = window.devicePixelRatio || 1; - canvas.width = Math.round(rect.width * dpr); - canvas.height = Math.round(rect.height * dpr); - drawProfile(); - } - - function drawProfile() { - var ctx = canvas.getContext('2d'); - var dpr = window.devicePixelRatio || 1; - var w = canvas.width, h = canvas.height; - ctx.clearRect(0, 0, w, h); - - var axisPx = Math.round(X_AXIS_LABEL_PX * dpr); - var chartH = Math.max(0, h - axisPx); - - var maxSpeed = 0; - for (var i = 0; i < PROFILE_SPEED.length; i++) { - if (PROFILE_SPEED[i] > maxSpeed) maxSpeed = PROFILE_SPEED[i]; - } - maxSpeed = Math.max(1, Math.ceil(maxSpeed)); - - if (PROFILE_TIME.length >= 2) { - function y(speed) { return chartH - (speed / maxSpeed) * chartH; } - ctx.beginPath(); - for (var i = 0; i < PROFILE_TIME.length; i++) { - var x = timeToFrac(PROFILE_TIME[i]) * w; - if (i === 0) ctx.moveTo(x, y(PROFILE_SPEED[i])); else ctx.lineTo(x, y(PROFILE_SPEED[i])); - } - ctx.strokeStyle = '#ff7f0e'; - ctx.lineWidth = Math.max(1, 1.5 * dpr); - ctx.stroke(); - } - - // Y axis context (0 at the baseline, ceil(max) at the top) -- without this there's no indication the - // background trace is even speed, let alone its scale. - ctx.fillStyle = 'rgba(90,90,90,0.95)'; - ctx.font = Math.round(10 * dpr) + 'px sans-serif'; - ctx.textAlign = 'left'; - ctx.textBaseline = 'top'; - ctx.fillText(maxSpeed + ' m/s', 4 * dpr, 3 * dpr); - ctx.textBaseline = 'alphabetic'; - ctx.fillText('0 m/s', 4 * dpr, chartH - 3 * dpr); - - // X axis time, in whatever format the rest of the log's plots use (self.time_type). The domain marker - // ("Rel:"/"P1:"/"GPS:"/"UTC:") only appears once, on the first tick. In 'utc' mode, the bare time of day is - // also ambiguous about which day it's from, so the first tick -- and any later tick that lands on a different - // UTC calendar day than the one before it, however many days apart -- gets the date too (but not the "UTC:" - // marker again, since that was already established by the first tick). - var tickFracs = [0, 0.25, 0.5, 0.75, 1.0]; - ctx.font = Math.round(9 * dpr) + 'px sans-serif'; - ctx.textBaseline = 'top'; - var lastUtcDate = null; - tickFracs.forEach(function(f, idx) { - ctx.textAlign = (idx === 0) ? 'left' : (idx === tickFracs.length - 1) ? 'right' : 'center'; - var p1 = fracToTime(f); - var label; - if (time_axis_type === 'utc') { - var parts = utcPartsForP1(p1); - if (parts === null) { - label = p1.toFixed(1) + ' s'; - } else { - var showDate = (idx === 0) || (parts.date !== lastUtcDate); - lastUtcDate = parts.date; - var dateTime = showDate ? (parts.date + ' ' + parts.time) : parts.time; - label = (idx === 0) ? ('UTC: ' + dateTime) : dateTime; - } - } else { - label = formatTickLabel(p1, idx === 0); - } - ctx.fillText(label, f * w, chartH + 2 * dpr); - }); - } - - var winStart = P1_TIME_MIN; - var winEnd = P1_TIME_MAX; - - function updateWindowDivStyle() { - var f0 = timeToFrac(winStart), f1 = timeToFrac(winEnd); - windowDiv.style.left = (f0 * 100) + '%'; - windowDiv.style.width = Math.max(0, (f1 - f0) * 100) + '%'; - } - - // Re-slice from ORIGINAL_TRACES (not figure.data) so widening the window can bring back points a previous - // restyle() dropped. - function applyMapFilter() { - var traceIndices = [], latUpdate = [], lonUpdate = [], cdUpdate = []; - for (var i = 0; i < ORIGINAL_TRACES.length; i++) { - var orig = ORIGINAL_TRACES[i]; - if (!orig.customdata) continue; - var lat = [], lon = [], cd = []; - for (var j = 0; j < orig.customdata.length; j++) { - var t = orig.customdata[j][1]; - if (t >= winStart && t <= winEnd) { - lat.push(orig.lat[j]); - lon.push(orig.lon[j]); - cd.push(orig.customdata[j]); - } - } - traceIndices.push(i); - latUpdate.push(lat); - lonUpdate.push(lon); - cdUpdate.push(cd); - } - if (traceIndices.length > 0) { - Plotly.restyle(figure, {lat: latUpdate, lon: lonUpdate, customdata: cdUpdate}, traceIndices); - } - } - - var pendingFilter = null; - function scheduleFilter() { - updateWindowDivStyle(); - if (pendingFilter) return; - pendingFilter = setTimeout(function() { - pendingFilter = null; - applyMapFilter(); - }, 16); - } - - function resetWindow() { - winStart = P1_TIME_MIN; - winEnd = P1_TIME_MAX; - scheduleFilter(); - } - - var dragMode = null, dragStartX = 0, dragWinStart = 0, dragWinEnd = 0; - - function onPointerMove(evt) { - if (!dragMode) return; - if (dragMode === 'left') { - winStart = Math.max(P1_TIME_MIN, Math.min(pixelToTime(evt.clientX), winEnd - MIN_WINDOW_SEC)); - } else if (dragMode === 'right') { - winEnd = Math.min(P1_TIME_MAX, Math.max(pixelToTime(evt.clientX), winStart + MIN_WINDOW_SEC)); - } else if (dragMode === 'pan') { - var rect = trackDiv.getBoundingClientRect(); - var deltaTime = ((evt.clientX - dragStartX) / rect.width) * (P1_TIME_MAX - P1_TIME_MIN); - var width = dragWinEnd - dragWinStart; - var newStart = dragWinStart + deltaTime, newEnd = dragWinEnd + deltaTime; - if (newStart < P1_TIME_MIN) { newStart = P1_TIME_MIN; newEnd = newStart + width; } - if (newEnd > P1_TIME_MAX) { newEnd = P1_TIME_MAX; newStart = newEnd - width; } - winStart = newStart; - winEnd = newEnd; - } - scheduleFilter(); - } - - function onPointerUp() { - dragMode = null; - windowDiv.style.cursor = 'grab'; - document.removeEventListener('mousemove', onPointerMove); - document.removeEventListener('mouseup', onPointerUp); - } - - function beginDrag(mode) { - return function(evt) { - evt.preventDefault(); - evt.stopPropagation(); - dragMode = mode; - dragStartX = evt.clientX; - dragWinStart = winStart; - dragWinEnd = winEnd; - if (mode === 'pan') windowDiv.style.cursor = 'grabbing'; - document.addEventListener('mousemove', onPointerMove); - document.addEventListener('mouseup', onPointerUp); - }; - } - - leftHandle.addEventListener('mousedown', beginDrag('left')); - rightHandle.addEventListener('mousedown', beginDrag('right')); - windowDiv.addEventListener('mousedown', function(evt) { - if (evt.target === leftHandle || evt.target === rightHandle) return; - beginDrag('pan')(evt); - }); - trackDiv.addEventListener('dblclick', resetWindow); - - window.addEventListener('resize', function() { - setTimeout(function() { Plotly.Plots.resize(figure); resizeCanvas(); }, 0); - }); - - updateWindowDivStyle(); - setTimeout(function() { Plotly.Plots.resize(figure); resizeCanvas(); }, 0); -})(); + preamble = f"""\ +var MAP_SLIDER_T_MIN = {json.dumps(t_min)}; +var MAP_SLIDER_T_MAX = {json.dumps(t_max)}; +var MAP_SLIDER_PROFILE_TIME = {profile_time_json}; +var MAP_SLIDER_PROFILE_SPEED = {profile_speed_json}; +var MAP_SLIDER_PROFILE_GPS_TIME = {profile_gps_time_json}; """ - return (js.replace('__T_MIN__', json.dumps(t_min)) - .replace('__T_MAX__', json.dumps(t_max)) - .replace('__PROFILE_TIME__', profile_time_json) - .replace('__PROFILE_SPEED__', profile_speed_json) - .replace('__PROFILE_GPS_TIME__', profile_gps_time_json)) + script_dir = os.path.join(os.path.dirname(__file__)) + with open(os.path.join(script_dir, 'plotly_map_time_slider.js'), 'rt') as f: + return preamble + f.read() def _auto_detect_message_type(self, types: List[MessageType]): types = [t.MESSAGE_TYPE if inspect.isclass(t) else t for t in types] diff --git a/python/fusion_engine_client/analysis/plotly_map_time_slider.js b/python/fusion_engine_client/analysis/plotly_map_time_slider.js new file mode 100644 index 00000000..c80fa432 --- /dev/null +++ b/python/fusion_engine_client/analysis/plotly_map_time_slider.js @@ -0,0 +1,329 @@ +// Time-range control injected below plot_map()'s figure (see Analyzer._map_time_slider_js()). Draws a +// speed-vs-time background chart (from pose velocity data) with a draggable/resizable window on top: dragging an +// edge narrows the P1 time range shown on the map, dragging the body pans it, and double-clicking resets it to the +// full range. Filtering re-slices each trace's original (already-rendered) lat/lon/customdata via +// `Plotly.restyle()`, so no extra per-window traces are added to the page. +// +// Requires the MAP_SLIDER_* globals (set by Analyzer._map_time_slider_js() immediately before this file is +// injected) plus the common per-figure globals set up by Analyzer.__write_html_and_inject_js() (`figure`, +// `time_axis_type`, `p1_t0_sec`, `gps_posix_offset_sec`). +(function() { + var P1_TIME_MIN = MAP_SLIDER_T_MIN; + var P1_TIME_MAX = MAP_SLIDER_T_MAX; + var PROFILE_TIME = MAP_SLIDER_PROFILE_TIME; + var PROFILE_SPEED = MAP_SLIDER_PROFILE_SPEED; + var PROFILE_GPS_TIME = MAP_SLIDER_PROFILE_GPS_TIME; + var SECONDS_PER_WEEK = 7 * 24 * 3600.0; + var SLIDER_HEIGHT_PX = 80; + var TRACK_INSET_PX = 16; + var TRACK_PADDING_V_PX = 4; + var X_AXIS_LABEL_PX = 14; + var MIN_WINDOW_SEC = Math.max(1e-3, (P1_TIME_MAX - P1_TIME_MIN) * 0.001); + + // Snapshot each trace's original lat/lon/customdata before any restyle() call mutates figure.data in place -- + // narrowing/widening the window always re-filters from this pristine copy, never from what's currently displayed. + var ORIGINAL_TRACES = figure.data.map(function(trace) { + return { + lat: trace.lat ? trace.lat.slice() : null, + lon: trace.lon ? trace.lon.slice() : null, + customdata: trace.customdata ? trace.customdata.slice() : null, + }; + }); + + // Reflow so the map shrinks to make room for the slider below it, instead of the slider being pushed below the + // fold by the map's normal 100vh height. + document.documentElement.style.height = '100%'; + document.body.style.height = '100%'; + document.body.style.margin = '0'; + var mapContainer = figure.parentNode; + mapContainer.style.height = '100%'; + mapContainer.style.display = 'flex'; + mapContainer.style.flexDirection = 'column'; + figure.style.flex = '1 1 auto'; + figure.style.minHeight = '0'; + figure.style.width = '100%'; + + var sliderContainer = document.createElement('div'); + sliderContainer.style.cssText = 'flex:0 0 ' + SLIDER_HEIGHT_PX + 'px; width:100%; box-sizing:border-box; ' + + 'padding:' + TRACK_PADDING_V_PX + 'px ' + TRACK_INSET_PX + 'px; background:#f5f5f5; border-top:1px solid #ccc;'; + + var trackDiv = document.createElement('div'); + trackDiv.style.cssText = 'position:relative; width:100%; height:100%;'; + sliderContainer.appendChild(trackDiv); + + var canvas = document.createElement('canvas'); + canvas.style.cssText = 'position:absolute; left:0; top:0; width:100%; height:100%;'; + trackDiv.appendChild(canvas); + + // windowDiv (the draggable selection) only covers the plotted chart area, not the X axis label strip below it + // (see drawProfile()), so the highlighted band lines up with the speed curve it's overlaid on. + var windowDiv = document.createElement('div'); + windowDiv.style.cssText = 'position:absolute; top:0; bottom:' + X_AXIS_LABEL_PX + 'px; ' + + 'background:rgba(31,119,180,0.25); border:1px solid rgba(31,119,180,0.9); box-sizing:border-box; cursor:grab;'; + trackDiv.appendChild(windowDiv); + + // Solid, protruding grab bars -- a plain hit-region (no visible affordance) didn't make it obvious the window's + // edges are independently draggable to resize the range, as opposed to just dragging the body to pan it. + var HANDLE_CSS = 'position:absolute; top:-4px; bottom:-4px; width:9px; background:rgb(31,119,180); ' + + 'border-radius:3px; box-shadow:0 0 0 1px rgba(255,255,255,0.8); cursor:ew-resize;'; + var leftHandle = document.createElement('div'); + leftHandle.style.cssText = HANDLE_CSS + 'left:-5px;'; + windowDiv.appendChild(leftHandle); + + var rightHandle = document.createElement('div'); + rightHandle.style.cssText = HANDLE_CSS + 'right:-5px;'; + windowDiv.appendChild(rightHandle); + + mapContainer.appendChild(sliderContainer); + + function timeToFrac(t) { return (t - P1_TIME_MIN) / (P1_TIME_MAX - P1_TIME_MIN); } + function fracToTime(f) { return P1_TIME_MIN + f * (P1_TIME_MAX - P1_TIME_MIN); } + + function pixelToTime(clientX) { + var rect = trackDiv.getBoundingClientRect(); + var frac = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width)); + return fracToTime(frac); + } + + // Interpolate GPS time for an arbitrary P1 time using the actual per-point profile samples -- P1 and GPS time + // aren't related by a fixed offset (see Analyzer._map_time_slider_js() docstring), but both progress at ~1 + // sec/sec, so linear interpolation between the nearest two samples is effectively exact. + function p1ToGpsTime(p1) { + var n = PROFILE_GPS_TIME.length; + if (n < 2) return NaN; + var lo = 0, hi = n - 1; + if (p1 <= PROFILE_TIME[0]) { lo = 0; hi = 1; } + else if (p1 >= PROFILE_TIME[hi]) { lo = hi - 1; } + else { + while (hi - lo > 1) { + var mid = (lo + hi) >> 1; + if (PROFILE_TIME[mid] <= p1) lo = mid; else hi = mid; + } + } + var t0 = PROFILE_TIME[lo], t1 = PROFILE_TIME[hi]; + var frac = (t1 > t0) ? (p1 - t0) / (t1 - t0) : 0; + return PROFILE_GPS_TIME[lo] + frac * (PROFILE_GPS_TIME[hi] - PROFILE_GPS_TIME[lo]); + } + + // Split a P1 time into UTC calendar date + time-of-day, for the tick loop below to decide when a date needs to + // be shown (first tick, or a tick that landed on a different day than the previous one). Returns null if UTC + // can't be resolved (no GPS/POSIX offset, or no profile data to interpolate GPS time from). + function utcPartsForP1(p1) { + var gps = p1ToGpsTime(p1); + if (isNaN(gps) || typeof gps_posix_offset_sec !== 'number') { + return null; + } + var iso = new Date((gps + gps_posix_offset_sec) * 1000.0).toISOString(); + return { date: iso.slice(0, 10).split('-').join('/'), time: iso.substr(11, 8) }; + } + + // Match the X axis format used by the log's other time-series plots (see Analyzer.time_type / _resolve_x_axis()). + // The domain label ("Rel:", "P1:", "GPS:") only needs to appear once, on the first tick -- it's the same for + // every tick after that. + function formatTickLabel(p1, is_first) { + if (time_axis_type === 'relative') { + var s = (p1 - (p1_t0_sec || 0)).toFixed(1) + ' s'; + return is_first ? 'Rel: ' + s : s; + } + if (time_axis_type === 'p1') { + var s = p1.toFixed(1) + ' s'; + return is_first ? 'P1: ' + s : s; + } + if (time_axis_type === 'gps') { + var gps = p1ToGpsTime(p1); + if (isNaN(gps)) { + return p1.toFixed(1) + ' s'; + } + var week = Math.floor(gps / SECONDS_PER_WEEK); + var tow_sec = gps - week * SECONDS_PER_WEEK; + var s = week + ':' + tow_sec.toFixed(1); + return is_first ? 'GPS: ' + s : s; + } + // 'utc' -- no date-change context here (see the tick loop's own UTC handling below), just the time of day. + var parts = utcPartsForP1(p1); + return parts ? parts.time : p1.toFixed(1) + ' s'; + } + + function resizeCanvas() { + var rect = trackDiv.getBoundingClientRect(); + var dpr = window.devicePixelRatio || 1; + canvas.width = Math.round(rect.width * dpr); + canvas.height = Math.round(rect.height * dpr); + drawProfile(); + } + + function drawProfile() { + var ctx = canvas.getContext('2d'); + var dpr = window.devicePixelRatio || 1; + var w = canvas.width, h = canvas.height; + ctx.clearRect(0, 0, w, h); + + var axisPx = Math.round(X_AXIS_LABEL_PX * dpr); + var chartH = Math.max(0, h - axisPx); + + var maxSpeed = 0; + for (var i = 0; i < PROFILE_SPEED.length; i++) { + if (PROFILE_SPEED[i] > maxSpeed) maxSpeed = PROFILE_SPEED[i]; + } + maxSpeed = Math.max(1, Math.ceil(maxSpeed)); + + if (PROFILE_TIME.length >= 2) { + function y(speed) { return chartH - (speed / maxSpeed) * chartH; } + ctx.beginPath(); + for (var i = 0; i < PROFILE_TIME.length; i++) { + var x = timeToFrac(PROFILE_TIME[i]) * w; + if (i === 0) ctx.moveTo(x, y(PROFILE_SPEED[i])); else ctx.lineTo(x, y(PROFILE_SPEED[i])); + } + ctx.strokeStyle = '#ff7f0e'; + ctx.lineWidth = Math.max(1, 1.5 * dpr); + ctx.stroke(); + } + + // Y axis context (0 at the baseline, ceil(max) at the top) -- without this there's no indication the + // background trace is even speed, let alone its scale. + ctx.fillStyle = 'rgba(90,90,90,0.95)'; + ctx.font = Math.round(10 * dpr) + 'px sans-serif'; + ctx.textAlign = 'left'; + ctx.textBaseline = 'top'; + ctx.fillText(maxSpeed + ' m/s', 4 * dpr, 3 * dpr); + ctx.textBaseline = 'alphabetic'; + ctx.fillText('0 m/s', 4 * dpr, chartH - 3 * dpr); + + // X axis time, in whatever format the rest of the log's plots use (self.time_type). The domain marker + // ("Rel:"/"P1:"/"GPS:"/"UTC:") only appears once, on the first tick. In 'utc' mode, the bare time of day is + // also ambiguous about which day it's from, so the first tick -- and any later tick that lands on a different + // UTC calendar day than the one before it, however many days apart -- gets the date too (but not the "UTC:" + // marker again, since that was already established by the first tick). + var tickFracs = [0, 0.25, 0.5, 0.75, 1.0]; + ctx.font = Math.round(9 * dpr) + 'px sans-serif'; + ctx.textBaseline = 'top'; + var lastUtcDate = null; + tickFracs.forEach(function(f, idx) { + ctx.textAlign = (idx === 0) ? 'left' : (idx === tickFracs.length - 1) ? 'right' : 'center'; + var p1 = fracToTime(f); + var label; + if (time_axis_type === 'utc') { + var parts = utcPartsForP1(p1); + if (parts === null) { + label = p1.toFixed(1) + ' s'; + } else { + var showDate = (idx === 0) || (parts.date !== lastUtcDate); + lastUtcDate = parts.date; + var dateTime = showDate ? (parts.date + ' ' + parts.time) : parts.time; + label = (idx === 0) ? ('UTC: ' + dateTime) : dateTime; + } + } else { + label = formatTickLabel(p1, idx === 0); + } + ctx.fillText(label, f * w, chartH + 2 * dpr); + }); + } + + var winStart = P1_TIME_MIN; + var winEnd = P1_TIME_MAX; + + function updateWindowDivStyle() { + var f0 = timeToFrac(winStart), f1 = timeToFrac(winEnd); + windowDiv.style.left = (f0 * 100) + '%'; + windowDiv.style.width = Math.max(0, (f1 - f0) * 100) + '%'; + } + + // Re-slice from ORIGINAL_TRACES (not figure.data) so widening the window can bring back points a previous + // restyle() dropped. + function applyMapFilter() { + var traceIndices = [], latUpdate = [], lonUpdate = [], cdUpdate = []; + for (var i = 0; i < ORIGINAL_TRACES.length; i++) { + var orig = ORIGINAL_TRACES[i]; + if (!orig.customdata) continue; + var lat = [], lon = [], cd = []; + for (var j = 0; j < orig.customdata.length; j++) { + var t = orig.customdata[j][1]; + if (t >= winStart && t <= winEnd) { + lat.push(orig.lat[j]); + lon.push(orig.lon[j]); + cd.push(orig.customdata[j]); + } + } + traceIndices.push(i); + latUpdate.push(lat); + lonUpdate.push(lon); + cdUpdate.push(cd); + } + if (traceIndices.length > 0) { + Plotly.restyle(figure, {lat: latUpdate, lon: lonUpdate, customdata: cdUpdate}, traceIndices); + } + } + + var pendingFilter = null; + function scheduleFilter() { + updateWindowDivStyle(); + if (pendingFilter) return; + pendingFilter = setTimeout(function() { + pendingFilter = null; + applyMapFilter(); + }, 16); + } + + function resetWindow() { + winStart = P1_TIME_MIN; + winEnd = P1_TIME_MAX; + scheduleFilter(); + } + + var dragMode = null, dragStartX = 0, dragWinStart = 0, dragWinEnd = 0; + + function onPointerMove(evt) { + if (!dragMode) return; + if (dragMode === 'left') { + winStart = Math.max(P1_TIME_MIN, Math.min(pixelToTime(evt.clientX), winEnd - MIN_WINDOW_SEC)); + } else if (dragMode === 'right') { + winEnd = Math.min(P1_TIME_MAX, Math.max(pixelToTime(evt.clientX), winStart + MIN_WINDOW_SEC)); + } else if (dragMode === 'pan') { + var rect = trackDiv.getBoundingClientRect(); + var deltaTime = ((evt.clientX - dragStartX) / rect.width) * (P1_TIME_MAX - P1_TIME_MIN); + var width = dragWinEnd - dragWinStart; + var newStart = dragWinStart + deltaTime, newEnd = dragWinEnd + deltaTime; + if (newStart < P1_TIME_MIN) { newStart = P1_TIME_MIN; newEnd = newStart + width; } + if (newEnd > P1_TIME_MAX) { newEnd = P1_TIME_MAX; newStart = newEnd - width; } + winStart = newStart; + winEnd = newEnd; + } + scheduleFilter(); + } + + function onPointerUp() { + dragMode = null; + windowDiv.style.cursor = 'grab'; + document.removeEventListener('mousemove', onPointerMove); + document.removeEventListener('mouseup', onPointerUp); + } + + function beginDrag(mode) { + return function(evt) { + evt.preventDefault(); + evt.stopPropagation(); + dragMode = mode; + dragStartX = evt.clientX; + dragWinStart = winStart; + dragWinEnd = winEnd; + if (mode === 'pan') windowDiv.style.cursor = 'grabbing'; + document.addEventListener('mousemove', onPointerMove); + document.addEventListener('mouseup', onPointerUp); + }; + } + + leftHandle.addEventListener('mousedown', beginDrag('left')); + rightHandle.addEventListener('mousedown', beginDrag('right')); + windowDiv.addEventListener('mousedown', function(evt) { + if (evt.target === leftHandle || evt.target === rightHandle) return; + beginDrag('pan')(evt); + }); + trackDiv.addEventListener('dblclick', resetWindow); + + window.addEventListener('resize', function() { + setTimeout(function() { Plotly.Plots.resize(figure); resizeCanvas(); }, 0); + }); + + updateWindowDivStyle(); + setTimeout(function() { Plotly.Plots.resize(figure); resizeCanvas(); }, 0); +})(); From 371d99b2c943245e7f9ecfa4c54d90aa6d63f102 Mon Sep 17 00:00:00 2001 From: Adam Shapiro Date: Fri, 24 Jul 2026 19:05:00 -0400 Subject: [PATCH 04/13] Restyle the slider. --- .../analysis/plotly_map_time_slider.js | 50 +++++++++++++++---- 1 file changed, 41 insertions(+), 9 deletions(-) diff --git a/python/fusion_engine_client/analysis/plotly_map_time_slider.js b/python/fusion_engine_client/analysis/plotly_map_time_slider.js index c80fa432..71f27579 100644 --- a/python/fusion_engine_client/analysis/plotly_map_time_slider.js +++ b/python/fusion_engine_client/analysis/plotly_map_time_slider.js @@ -1,8 +1,13 @@ // Time-range control injected below plot_map()'s figure (see Analyzer._map_time_slider_js()). Draws a // speed-vs-time background chart (from pose velocity data) with a draggable/resizable window on top: dragging an // edge narrows the P1 time range shown on the map, dragging the body pans it, and double-clicking resets it to the -// full range. Filtering re-slices each trace's original (already-rendered) lat/lon/customdata via -// `Plotly.restyle()`, so no extra per-window traces are added to the page. +// full range. A one-line "Showing X -> Y" readout below the slider echoes the current window as text. Filtering +// re-slices each trace's original (already-rendered) lat/lon/customdata via `Plotly.restyle()`, so no extra +// per-window traces are added to the page. +// +// Styled to sit quietly under the plain-white Plotly figures the rest of the report generates -- amber is +// reserved for the two things that are actually interactive (the selection band and its handles), while the +// speed curve itself stays a neutral grey so it doesn't compete with them. // // Requires the MAP_SLIDER_* globals (set by Analyzer._map_time_slider_js() immediately before this file is // injected) plus the common per-figure globals set up by Analyzer.__write_html_and_inject_js() (`figure`, @@ -15,9 +20,11 @@ var PROFILE_GPS_TIME = MAP_SLIDER_PROFILE_GPS_TIME; var SECONDS_PER_WEEK = 7 * 24 * 3600.0; var SLIDER_HEIGHT_PX = 80; + var READOUT_HEIGHT_PX = 22; var TRACK_INSET_PX = 16; var TRACK_PADDING_V_PX = 4; var X_AXIS_LABEL_PX = 14; + var ACCENT_COLOR = '#FF9C00'; var MIN_WINDOW_SEC = Math.max(1e-3, (P1_TIME_MAX - P1_TIME_MIN) * 0.001); // Snapshot each trace's original lat/lon/customdata before any restyle() call mutates figure.data in place -- @@ -45,7 +52,7 @@ var sliderContainer = document.createElement('div'); sliderContainer.style.cssText = 'flex:0 0 ' + SLIDER_HEIGHT_PX + 'px; width:100%; box-sizing:border-box; ' + - 'padding:' + TRACK_PADDING_V_PX + 'px ' + TRACK_INSET_PX + 'px; background:#f5f5f5; border-top:1px solid #ccc;'; + 'padding:' + TRACK_PADDING_V_PX + 'px ' + TRACK_INSET_PX + 'px; background:#ffffff; border-top:1px solid #e4e4e1;'; var trackDiv = document.createElement('div'); trackDiv.style.cssText = 'position:relative; width:100%; height:100%;'; @@ -59,13 +66,13 @@ // (see drawProfile()), so the highlighted band lines up with the speed curve it's overlaid on. var windowDiv = document.createElement('div'); windowDiv.style.cssText = 'position:absolute; top:0; bottom:' + X_AXIS_LABEL_PX + 'px; ' + - 'background:rgba(31,119,180,0.25); border:1px solid rgba(31,119,180,0.9); box-sizing:border-box; cursor:grab;'; + 'background:rgba(201,127,10,0.10); border:1px solid ' + ACCENT_COLOR + '; box-sizing:border-box; cursor:grab;'; trackDiv.appendChild(windowDiv); // Solid, protruding grab bars -- a plain hit-region (no visible affordance) didn't make it obvious the window's // edges are independently draggable to resize the range, as opposed to just dragging the body to pan it. - var HANDLE_CSS = 'position:absolute; top:-4px; bottom:-4px; width:9px; background:rgb(31,119,180); ' + - 'border-radius:3px; box-shadow:0 0 0 1px rgba(255,255,255,0.8); cursor:ew-resize;'; + var HANDLE_CSS = 'position:absolute; top:-4px; bottom:-4px; width:8px; background:' + ACCENT_COLOR + '; ' + + 'cursor:ew-resize;'; var leftHandle = document.createElement('div'); leftHandle.style.cssText = HANDLE_CSS + 'left:-5px;'; windowDiv.appendChild(leftHandle); @@ -76,6 +83,14 @@ mapContainer.appendChild(sliderContainer); + // Text echo of the current window, in the same time_type-aware format as the axis ticks -- lets the current + // range be read precisely (and copy-pasted) without having to eyeball tick positions. + var readoutDiv = document.createElement('div'); + readoutDiv.style.cssText = 'flex:0 0 ' + READOUT_HEIGHT_PX + 'px; width:100%; box-sizing:border-box; ' + + 'padding:2px ' + TRACK_INSET_PX + 'px; background:#ffffff; color:' + ACCENT_COLOR + '; ' + + 'font:12px -apple-system, "Segoe UI", Roboto, sans-serif;'; + mapContainer.appendChild(readoutDiv); + function timeToFrac(t) { return (t - P1_TIME_MIN) / (P1_TIME_MAX - P1_TIME_MIN); } function fracToTime(f) { return P1_TIME_MIN + f * (P1_TIME_MAX - P1_TIME_MIN); } @@ -174,14 +189,14 @@ var x = timeToFrac(PROFILE_TIME[i]) * w; if (i === 0) ctx.moveTo(x, y(PROFILE_SPEED[i])); else ctx.lineTo(x, y(PROFILE_SPEED[i])); } - ctx.strokeStyle = '#ff7f0e'; - ctx.lineWidth = Math.max(1, 1.5 * dpr); + ctx.strokeStyle = '#aab2bc'; + ctx.lineWidth = Math.max(1, 1.4 * dpr); ctx.stroke(); } // Y axis context (0 at the baseline, ceil(max) at the top) -- without this there's no indication the // background trace is even speed, let alone its scale. - ctx.fillStyle = 'rgba(90,90,90,0.95)'; + ctx.fillStyle = '#6b6b66'; ctx.font = Math.round(10 * dpr) + 'px sans-serif'; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; @@ -222,10 +237,27 @@ var winStart = P1_TIME_MIN; var winEnd = P1_TIME_MAX; + // Neither side gets a "Rel:"/"P1:"/"GPS:"/"UTC:" prefix here -- "Showing" already establishes these are times, + // and the axis ticks above spell out which domain. In 'utc' mode, the end date is only repeated if it actually + // differs from the start's (mirrors the axis ticks' own midnight-crossing rule, just for these two values). + function formatRangeReadout() { + if (time_axis_type === 'utc') { + var p0 = utcPartsForP1(winStart); + var p1 = utcPartsForP1(winEnd); + if (p0 === null || p1 === null) { + return 'Showing ' + winStart.toFixed(1) + ' s → ' + winEnd.toFixed(1) + ' s'; + } + var endText = (p1.date !== p0.date) ? (p1.date + ' ' + p1.time) : p1.time; + return 'Showing ' + p0.date + ' ' + p0.time + ' → ' + endText; + } + return 'Showing ' + formatTickLabel(winStart, false) + ' → ' + formatTickLabel(winEnd, false); + } + function updateWindowDivStyle() { var f0 = timeToFrac(winStart), f1 = timeToFrac(winEnd); windowDiv.style.left = (f0 * 100) + '%'; windowDiv.style.width = Math.max(0, (f1 - f0) * 100) + '%'; + readoutDiv.textContent = formatRangeReadout(); } // Re-slice from ORIGINAL_TRACES (not figure.data) so widening the window can bring back points a previous From 263903abbda2a7470577981011f1e6612604133b Mon Sep 17 00:00:00 2001 From: Adam Shapiro Date: Fri, 24 Jul 2026 19:38:30 -0400 Subject: [PATCH 05/13] Restyle the map to reduce padding. --- .../fusion_engine_client/analysis/analyzer.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/python/fusion_engine_client/analysis/analyzer.py b/python/fusion_engine_client/analysis/analyzer.py index a316ae0f..0a032e5e 100755 --- a/python/fusion_engine_client/analysis/analyzer.py +++ b/python/fusion_engine_client/analysis/analyzer.py @@ -1307,7 +1307,12 @@ def _plot_data(name, selected_idx, flags, source_id, lla_deg, customdata_all, ma layout = go.Layout( autosize=True, hovermode='closest', - title=title, + # Anchored to the plot area's own left edge (paper x=0) rather than left at the default (centered on + # the whole container) -- the container also includes the legend, which Plotly auto-widens to fit, + # so a container-centered title drifts right of where the map itself actually ends up. + title=dict(text=title, x=0, xanchor='left', xref='paper'), + # Reduce padding around the map, leaving enough space for the title. + margin=dict(l=16, r=16, t=70, b=8), mapbox=dict( accesstoken=mapbox_token, bearing=0, @@ -1337,10 +1342,12 @@ def _plot_data(name, selected_idx, flags, source_id, lla_deg, customdata_all, ma 'type': 'buttons', 'direction': 'left', 'buttons': buttons, - 'x': 0.0, - 'xanchor': 'left', - 'y': 1.1, - 'yanchor': 'top' + # Move the buttons inside the map to avoid overlap and reduce unused whitespace. + 'x': 1.0, + 'xanchor': 'right', + 'y': 0.99, + 'yanchor': 'top', + 'bgcolor': 'rgba(255,255,255,0.85)', }] # Decimate the speed profile for the time slider so a long log doesn't inflate the HTML with a huge embedded From 07eb63ee261e90df031efbfc8e292fe3367a5f9e Mon Sep 17 00:00:00 2001 From: Adam Shapiro Date: Fri, 24 Jul 2026 19:57:25 -0400 Subject: [PATCH 06/13] Moved vehicle speed estimate/extraction to common function. --- .../fusion_engine_client/analysis/analyzer.py | 127 ++++++++++++------ 1 file changed, 87 insertions(+), 40 deletions(-) diff --git a/python/fusion_engine_client/analysis/analyzer.py b/python/fusion_engine_client/analysis/analyzer.py index 0a032e5e..46d3f6a3 100755 --- a/python/fusion_engine_client/analysis/analyzer.py +++ b/python/fusion_engine_client/analysis/analyzer.py @@ -1246,9 +1246,6 @@ def _plot_data(name, selected_idx, flags, source_id, lla_deg, customdata_all, ma primary_source_id = min(pose_source_ids) overall_t_min = None overall_t_max = None - profile_time_sec = None - profile_speed_mps = None - profile_gps_time_sec = None for source_id in pose_source_ids: result = self.reader.read(message_types=[PoseMessage], source_ids=[source_id], **self.params) pose_data = result[PoseMessage.MESSAGE_TYPE] @@ -1276,13 +1273,6 @@ def _plot_data(name, selected_idx, flags, source_id, lla_deg, customdata_all, ma overall_t_min = t_min if overall_t_min is None else min(overall_t_min, t_min) overall_t_max = t_max if overall_t_max is None else max(overall_t_max, t_max) - # Speed profile (for the time slider below the map) is only computed for the default source -- it's a - # visual aid for picking a time range, not a plotted data source, so it doesn't need every source's data. - if source_id == primary_source_id: - profile_time_sec = p1_time - profile_speed_mps = np.linalg.norm(pose_data.velocity_body_mps[:, valid_idx], axis=0) - profile_gps_time_sec = pose_data.gps_time[valid_idx] - customdata_all = _build_position_customdata(p1_time=p1_time, gps_time=pose_data.gps_time[valid_idx], lla_deg=lla_deg, std_enu_m=std_enu_m) @@ -1350,6 +1340,11 @@ def _plot_data(name, selected_idx, flags, source_id, lla_deg, customdata_all, ma 'bgcolor': 'rgba(255,255,255,0.85)', }] + # Speed profile (for the time slider below the map) is only computed for the default source -- it's a + # visual aid for picking a time range, not a plotted data source, so it doesn't need every source's data. + profile_time_sec, profile_speed_mps, profile_gps_time_sec, _ = \ + self._estimate_speed_mps(source_id=primary_source_id, forward_only=False, signed=False) + # Decimate the speed profile for the time slider so a long log doesn't inflate the HTML with a huge embedded # array -- it's just a visual aid for picking a time range, precision doesn't matter. _MAX_PROFILE_POINTS = 3000 @@ -2236,6 +2231,77 @@ def plot_wheel_data(self): self._plot_wheel_ticks_or_speeds(source='vehicle', type='speed') self._plot_wheel_ticks_or_speeds(source='vehicle', type='tick') + def _estimate_speed_mps(self, source_id, forward_only: bool = False, signed: bool = False): + """! + @brief Estimate speed (m/s) from the best available data, falling back through progressively coarser + sources. + + Sources in order of priority: + 1. `PoseMessage.velocity_body_mps` -- forward (X) component only if `forward_only`, otherwise the + full 3D norm. + 2. `PoseAuxMessage.velocity_enu_mps` -- always an unsigned 3D norm; ENU velocity can't convey forward speed. + 3. Differential position -- unsigned 3D norm of consecutive ECEF position deltas (from + `PoseMessage.lla_deg`) divided by elapsed time. Coarser (no velocity filtering/smoothing) and one + sample shorter than the other sources (undefined at the first time). + + @param source_id The pose source ID to read. + @param forward_only If `True`, return body forward velocity, if known, or 3D speed otherwise. + @param signed If `True`, return signed forward velocity (negative when reversing) instead of speed (unsigned) + when known. When using ENU velocity or differential position, speed is always unsigned. + + @return `(p1_time, speed_mps, gps_time, source)`, all `None` if no usable data exists at all. `source` is + one of `'body'`, `'enu'`, or `'diff_position'`, identifying which tier was used. `gps_time` is + NaN-filled if the winning source has no way to recover real GPS time (only possible for `'enu'`, + and only when there's no `PoseMessage` in the log at all to borrow it from -- see below). + """ + result = self.reader.read(message_types=[PoseMessage], source_ids=source_id, **self.params) + pose_data = result[PoseMessage.MESSAGE_TYPE] + have_pose = len(pose_data.p1_time) != 0 + + if have_pose and np.any(~np.isnan(pose_data.velocity_body_mps)): + if forward_only: + speed_mps = pose_data.velocity_body_mps[0, :] + if not signed: + speed_mps = np.abs(speed_mps) + else: + speed_mps = np.linalg.norm(pose_data.velocity_body_mps, axis=0) + return pose_data.p1_time, speed_mps, pose_data.gps_time, 'body' + + result = self.reader.read(message_types=[PoseAuxMessage], source_ids=source_id, **self.params) + pose_aux_data = result[PoseAuxMessage.MESSAGE_TYPE] + if len(pose_aux_data.p1_time) != 0 and np.any(~np.isnan(pose_aux_data.velocity_enu_mps)): + self.logger.warning('Body velocity not available. Estimating |speed| from ENU velocity. May not ' + 'match other speed sources when reversing.') + speed_mps = np.linalg.norm(pose_aux_data.velocity_enu_mps, axis=0) + # PoseAuxMessage doesn't carry GPS time itself, but it's emitted in lockstep with PoseMessage at the + # same P1 times -- if PoseMessage is present too (just without usable velocity), borrow its GPS time + # via interpolation rather than leaving this all NaN. + valid_gps_idx = np.logical_and(~np.isnan(pose_data.p1_time), ~np.isnan(pose_data.gps_time)) + if have_pose and np.any(valid_gps_idx): + gps_time = np.interp(pose_aux_data.p1_time, pose_data.p1_time[valid_gps_idx], + pose_data.gps_time[valid_gps_idx]) + else: + gps_time = np.full_like(pose_aux_data.p1_time, np.nan) + return pose_aux_data.p1_time, speed_mps, gps_time, 'enu' + + if not have_pose: + return None, None, None, None + + valid_idx = np.logical_and(~np.isnan(pose_data.p1_time), ~np.any(np.isnan(pose_data.lla_deg), axis=0)) + if np.sum(valid_idx) < 2: + return None, None, None, None + + self.logger.warning('Body and ENU velocity not available. Approximating |speed| from differential ' + 'position.') + p1_time = pose_data.p1_time[valid_idx] + gps_time = pose_data.gps_time[valid_idx] + position_ecef_m = np.array(geodetic2ecef(lat=pose_data.lla_deg[0, valid_idx], + lon=pose_data.lla_deg[1, valid_idx], + alt=pose_data.lla_deg[2, valid_idx], deg=True)) + dt_sec = np.diff(p1_time) + speed_mps = np.linalg.norm(np.diff(position_ecef_m, axis=1), axis=0) / dt_sec + return p1_time[1:], speed_mps, gps_time[1:], 'diff_position' + def _plot_wheel_ticks_or_speeds(self, source, type): """! @brief Plot wheel speed or tick data. @@ -2425,38 +2491,19 @@ def _get_time_source(meas_type, data): # Note: Pose data is not read when plotting ticks (ticks do not plot in meters/second). If the wheel data is not # in P1 time, we cannot compare against the pose data, which is. if type == 'speed' and p1_time_present: - nav_engine_p1_time = None - nav_engine_speed_mps = None - - # If we have pose messages _and_ they contain body velocity, we can use that. - # - # Note that we are using this to compare vs wheel speeds, so we're only interested in forward speed here. - result = self.reader.read(message_types=[PoseMessage], source_ids=self.default_source_id, **self.params) - pose_data = result[PoseMessage.MESSAGE_TYPE] - if len(pose_data.p1_time) != 0 and np.any(~np.isnan(pose_data.velocity_body_mps[0, :])): - nav_engine_p1_time = pose_data.p1_time - nav_engine_speed_mps = pose_data.velocity_body_mps[0, :] - if data_signed: - nav_engine_speed_name = 'Speed Estimate (Nav Engine)' - else: - nav_engine_speed_mps = np.abs(nav_engine_speed_mps) - nav_engine_speed_name = '|Speed Estimate| (Nav Engine)' - # Otherwise, if we have pose aux messages, read those and use the ENU velocity to estimate speed. Since we - # don't know attitude, the best we can do is estimate 3D speed and assume it's primarily in the along-track - # direction. This will also be an absolute value, so may not match the wheel data if it is signed and the - # vehicle is going backward. - else: - result = self.reader.read(message_types=[PoseAuxMessage], source_ids=self.default_source_id, - **self.params) - pose_aux_data = result[PoseAuxMessage.MESSAGE_TYPE] - if len(pose_aux_data.p1_time) != 0: - self.logger.warning('Body forward velocity not available. Estimating |speed| from ENU velocity. ' - 'May not match wheel speeds when going backward.') - nav_engine_p1_time = pose_aux_data.p1_time - nav_engine_speed_mps = np.linalg.norm(pose_aux_data.velocity_enu_mps, axis=0) - nav_engine_speed_name = '|3D Speed Estimate| (Nav Engine)' + # We're comparing this to wheel speed, so prefer a signed forward (body-frame X) estimate when real + # body velocity is available. The ENU-velocity and differential-position fallbacks (see + # _estimate_speed_mps()) can only ever produce an unsigned 3D speed -- attitude/heading isn't known + # from either -- so may not match the wheel data if it's signed and the vehicle is going backward. + nav_engine_p1_time, nav_engine_speed_mps, _, speed_source = \ + self._estimate_speed_mps(source_id=self.default_source_id, forward_only=True, signed=data_signed) if nav_engine_speed_mps is not None: + nav_engine_speed_name = { + 'body': 'Speed Estimate' if data_signed else '|Speed Estimate|', + 'enu': '|3D Speed Estimate|', + 'diff_position': '|Differential Position Speed Estimate|', + }[speed_source] + ' (Nav Engine)' if use_time_type: nav_time, _ = self._resolve_x_axis(p1_time=nav_engine_p1_time) nav_kwargs = {'customdata': self._time_hover_customdata(p1_time=nav_engine_p1_time)} From 8e01a99509d76a1c175bf371aba815d729777fd7 Mon Sep 17 00:00:00 2001 From: Adam Shapiro Date: Fri, 24 Jul 2026 20:00:03 -0400 Subject: [PATCH 07/13] Moved HTML/JSON details into time slider function. --- .../fusion_engine_client/analysis/analyzer.py | 68 ++++++++++--------- 1 file changed, 35 insertions(+), 33 deletions(-) diff --git a/python/fusion_engine_client/analysis/analyzer.py b/python/fusion_engine_client/analysis/analyzer.py index 46d3f6a3..534149a4 100755 --- a/python/fusion_engine_client/analysis/analyzer.py +++ b/python/fusion_engine_client/analysis/analyzer.py @@ -1345,31 +1345,10 @@ def _plot_data(name, selected_idx, flags, source_id, lla_deg, customdata_all, ma profile_time_sec, profile_speed_mps, profile_gps_time_sec, _ = \ self._estimate_speed_mps(source_id=primary_source_id, forward_only=False, signed=False) - # Decimate the speed profile for the time slider so a long log doesn't inflate the HTML with a huge embedded - # array -- it's just a visual aid for picking a time range, precision doesn't matter. - _MAX_PROFILE_POINTS = 3000 - if profile_time_sec is not None and len(profile_time_sec) > 0: - order = np.argsort(profile_time_sec) - sorted_time = profile_time_sec[order] - sorted_speed = profile_speed_mps[order] - sorted_gps_time = profile_gps_time_sec[order] - if len(sorted_time) > _MAX_PROFILE_POINTS: - stride = int(np.ceil(len(sorted_time) / _MAX_PROFILE_POINTS)) - sorted_time = sorted_time[::stride] - sorted_speed = sorted_speed[::stride] - sorted_gps_time = sorted_gps_time[::stride] - profile_time_json = json.dumps(np.round(sorted_time, 3).tolist()) - profile_speed_json = json.dumps(np.round(sorted_speed, 3).tolist()) - profile_gps_time_json = json.dumps(np.round(sorted_gps_time, 3).tolist()) - else: - profile_time_json = '[]' - profile_speed_json = '[]' - profile_gps_time_json = '[]' - slider_js = self._map_time_slider_js(t_min=overall_t_min, t_max=overall_t_max, - profile_time_json=profile_time_json, - profile_speed_json=profile_speed_json, - profile_gps_time_json=profile_gps_time_json) + profile_time_sec=profile_time_sec, + profile_speed_mps=profile_speed_mps, + profile_gps_time_sec=profile_gps_time_sec) self._add_figure(name="map", figure=figure, title="Vehicle Trajectory (Map)", config={'scrollZoom': True}, custom_hover=False, inject_js=slider_js) @@ -3853,24 +3832,47 @@ def _custom_tooltip_js(self, time_source: str = 'p1', precision: Optional[int] = }); """ + tick_reformat_js) - def _map_time_slider_js(self, t_min: float, t_max: float, profile_time_json: str, profile_speed_json: str, - profile_gps_time_json: str) -> str: + def _map_time_slider_js(self, t_min: float, t_max: float, profile_time_sec: Optional[np.ndarray], + profile_speed_mps: Optional[np.ndarray], + profile_gps_time_sec: Optional[np.ndarray]) -> str: """! @brief Build JS for a time-range control injected below @ref plot_map()'s figure. The control itself (DOM/canvas setup, drag handling, axis formatting) lives in `plotly_map_time_slider.js`, - injected the same way as `plotly_data_support.js` (see @ref __write_html_and_inject_js()); this just - supplies the per-log data that static file reads from a handful of `MAP_SLIDER_*` globals. + injected the same way as `plotly_data_support.js` (see @ref __write_html_and_inject_js()); this decimates + and JSON-encodes the per-log data that static file reads from a handful of `MAP_SLIDER_*` globals. @param t_min/t_max The full P1 time range (sec) spanned by the map's traces (matches `customdata[1]`). - @param profile_time_json/profile_speed_json JSON arrays of (decimated) P1 time (sec) and 3D speed (m/s) for - the background chart, from the default pose source. - @param profile_gps_time_json JSON array of GPS time (sec), parallel to `profile_time_json`, used to label - the X axis in `gps`/`utc` mode (see `self.time_type`) -- P1 and GPS time aren't a fixed offset apart, - so converting an arbitrary tick's P1 time requires interpolating within the actual per-point data. + @param profile_time_sec/profile_speed_mps Parallel arrays of P1 time (sec) and 3D speed (m/s) for the + background chart, from the default pose source (e.g., from @ref _estimate_speed_mps()). `None` (or + empty) if no speed data is available at all, in which case the chart is simply left blank. + @param profile_gps_time_sec GPS time (sec), parallel to `profile_time_sec`, used to label the X axis in + `gps`/`utc` mode (see `self.time_type`) -- P1 and GPS time aren't a fixed offset apart, so + converting an arbitrary tick's P1 time requires interpolating within the actual per-point data. @return The JS to pass as `inject_js` to @ref _add_figure(). """ + # Decimate the speed profile so a long log doesn't inflate the HTML with a huge embedded array -- it's + # just a visual aid for picking a time range, precision doesn't matter. + _MAX_PROFILE_POINTS = 3000 + if profile_time_sec is not None and len(profile_time_sec) > 0: + order = np.argsort(profile_time_sec) + sorted_time = profile_time_sec[order] + sorted_speed = profile_speed_mps[order] + sorted_gps_time = profile_gps_time_sec[order] + if len(sorted_time) > _MAX_PROFILE_POINTS: + stride = int(np.ceil(len(sorted_time) / _MAX_PROFILE_POINTS)) + sorted_time = sorted_time[::stride] + sorted_speed = sorted_speed[::stride] + sorted_gps_time = sorted_gps_time[::stride] + profile_time_json = json.dumps(np.round(sorted_time, 3).tolist()) + profile_speed_json = json.dumps(np.round(sorted_speed, 3).tolist()) + profile_gps_time_json = json.dumps(np.round(sorted_gps_time, 3).tolist()) + else: + profile_time_json = '[]' + profile_speed_json = '[]' + profile_gps_time_json = '[]' + preamble = f"""\ var MAP_SLIDER_T_MIN = {json.dumps(t_min)}; var MAP_SLIDER_T_MAX = {json.dumps(t_max)}; From 5a926fc506374b19d86278a37a4294911b4cc4d6 Mon Sep 17 00:00:00 2001 From: Adam Shapiro Date: Mon, 27 Jul 2026 09:50:03 -0400 Subject: [PATCH 08/13] Extrapolate GPS time backward for beginning of time slider. --- .../analysis/plotly_map_time_slider.js | 130 ++++++++++++------ 1 file changed, 86 insertions(+), 44 deletions(-) diff --git a/python/fusion_engine_client/analysis/plotly_map_time_slider.js b/python/fusion_engine_client/analysis/plotly_map_time_slider.js index 71f27579..e86bf740 100644 --- a/python/fusion_engine_client/analysis/plotly_map_time_slider.js +++ b/python/fusion_engine_client/analysis/plotly_map_time_slider.js @@ -1,17 +1,11 @@ // Time-range control injected below plot_map()'s figure (see Analyzer._map_time_slider_js()). Draws a // speed-vs-time background chart (from pose velocity data) with a draggable/resizable window on top: dragging an // edge narrows the P1 time range shown on the map, dragging the body pans it, and double-clicking resets it to the -// full range. A one-line "Showing X -> Y" readout below the slider echoes the current window as text. Filtering -// re-slices each trace's original (already-rendered) lat/lon/customdata via `Plotly.restyle()`, so no extra -// per-window traces are added to the page. +// full range. A one-line "Showing X -> Y" readout below the slider echoes the current window as text. // -// Styled to sit quietly under the plain-white Plotly figures the rest of the report generates -- amber is -// reserved for the two things that are actually interactive (the selection band and its handles), while the -// speed curve itself stays a neutral grey so it doesn't compete with them. -// -// Requires the MAP_SLIDER_* globals (set by Analyzer._map_time_slider_js() immediately before this file is -// injected) plus the common per-figure globals set up by Analyzer.__write_html_and_inject_js() (`figure`, -// `time_axis_type`, `p1_t0_sec`, `gps_posix_offset_sec`). +// Requires the MAP_SLIDER_* globals (set by Analyzer._map_time_slider_js() immediately before this file is injected) +// plus the common per-figure globals set up by Analyzer.__write_html_and_inject_js() (`figure`, `time_axis_type`, +// `p1_t0_sec`, `gps_posix_offset_sec`). (function() { var P1_TIME_MIN = MAP_SLIDER_T_MIN; var P1_TIME_MAX = MAP_SLIDER_T_MAX; @@ -69,8 +63,7 @@ 'background:rgba(201,127,10,0.10); border:1px solid ' + ACCENT_COLOR + '; box-sizing:border-box; cursor:grab;'; trackDiv.appendChild(windowDiv); - // Solid, protruding grab bars -- a plain hit-region (no visible affordance) didn't make it obvious the window's - // edges are independently draggable to resize the range, as opposed to just dragging the body to pan it. + // Solid, protruding grab bars for dragging the size of the visible time range. var HANDLE_CSS = 'position:absolute; top:-4px; bottom:-4px; width:8px; background:' + ACCENT_COLOR + '; ' + 'cursor:ew-resize;'; var leftHandle = document.createElement('div'); @@ -83,7 +76,7 @@ mapContainer.appendChild(sliderContainer); - // Text echo of the current window, in the same time_type-aware format as the axis ticks -- lets the current + // Text echo of the current window, in the same time-type-aware format as the axis ticks -- lets the current // range be read precisely (and copy-pasted) without having to eyeball tick positions. var readoutDiv = document.createElement('div'); readoutDiv.style.cssText = 'flex:0 0 ' + READOUT_HEIGHT_PX + 'px; width:100%; box-sizing:border-box; ' + @@ -100,24 +93,58 @@ return fracToTime(frac); } - // Interpolate GPS time for an arbitrary P1 time using the actual per-point profile samples -- P1 and GPS time - // aren't related by a fixed offset (see Analyzer._map_time_slider_js() docstring), but both progress at ~1 - // sec/sec, so linear interpolation between the nearest two samples is effectively exact. + // Only P1 times with a real (non-NaN) GPS time can be used as GPS time interpolation/extrapolation anchors below. + var VALID_PROFILE_TIME = []; + var VALID_PROFILE_GPS_TIME = []; + for (var vi = 0; vi < PROFILE_TIME.length; vi++) { + if (!isNaN(PROFILE_GPS_TIME[vi])) { + VALID_PROFILE_TIME.push(PROFILE_TIME[vi]); + VALID_PROFILE_GPS_TIME.push(PROFILE_GPS_TIME[vi]); + } + } + + // Interpolate (or, beyond the known data, extrapolate) GPS time for an arbitrary P1 time -- P1 and GPS time + // aren't related by a fixed offset (see Analyzer._map_time_slider_js() docstring) but P1 time should be rate-locked + // to GPS time when it is available. + // + // For timeline purposes, displayed timestamps don't need to be precise: the scale is large and can't be zoomed in on + // the map's slider. Before GPS time is known, extrapolate assuming P1 time tracks real elapsed time 1:1 -- it does + // not, but this is a good enough approximation for a while. + // + // Before GPS time is available, P1 time is rate-locked to the device's local oscillator. Even a very poor 300 PPM + // oscillator only accumulates ~1 sec of error per hour. A 1-2 hour window should be good enough for display purposes. + // Past that, the error is large enough it's better to just say so (fall back to a P1 reading) than to show a + // wrong-looking UTC/GPS time -- see formatTickLabel()/utcPartsForP1() callers. + var GPS_EXTRAPOLATION_LIMIT_SEC = 2 * 3600; + function p1ToGpsTime(p1) { - var n = PROFILE_GPS_TIME.length; - if (n < 2) return NaN; + var n = VALID_PROFILE_TIME.length; + if (n === 0) { + return NaN; + } + if (n === 1) { + var dtOnly = p1 - VALID_PROFILE_TIME[0]; + return Math.abs(dtOnly) <= GPS_EXTRAPOLATION_LIMIT_SEC ? VALID_PROFILE_GPS_TIME[0] + dtOnly : NaN; + } + if (p1 <= VALID_PROFILE_TIME[0]) { + var dtBefore = VALID_PROFILE_TIME[0] - p1; + return dtBefore <= GPS_EXTRAPOLATION_LIMIT_SEC ? VALID_PROFILE_GPS_TIME[0] - dtBefore : NaN; + } + if (p1 >= VALID_PROFILE_TIME[n - 1]) { + var dtAfter = p1 - VALID_PROFILE_TIME[n - 1]; + return dtAfter <= GPS_EXTRAPOLATION_LIMIT_SEC ? VALID_PROFILE_GPS_TIME[n - 1] + dtAfter : NaN; + } + // Interior: bracket and linearly interpolate between the two nearest valid points -- no error cap needed here + // (unlike the edges above), since the real GPS time is known at both ends, however far apart a mid-log gap in + // GPS availability left them. var lo = 0, hi = n - 1; - if (p1 <= PROFILE_TIME[0]) { lo = 0; hi = 1; } - else if (p1 >= PROFILE_TIME[hi]) { lo = hi - 1; } - else { - while (hi - lo > 1) { - var mid = (lo + hi) >> 1; - if (PROFILE_TIME[mid] <= p1) lo = mid; else hi = mid; - } + while (hi - lo > 1) { + var mid = (lo + hi) >> 1; + if (VALID_PROFILE_TIME[mid] <= p1) lo = mid; else hi = mid; } - var t0 = PROFILE_TIME[lo], t1 = PROFILE_TIME[hi]; + var t0 = VALID_PROFILE_TIME[lo], t1 = VALID_PROFILE_TIME[hi]; var frac = (t1 > t0) ? (p1 - t0) / (t1 - t0) : 0; - return PROFILE_GPS_TIME[lo] + frac * (PROFILE_GPS_TIME[hi] - PROFILE_GPS_TIME[lo]); + return VALID_PROFILE_GPS_TIME[lo] + frac * (VALID_PROFILE_GPS_TIME[hi] - VALID_PROFILE_GPS_TIME[lo]); } // Split a P1 time into UTC calendar date + time-of-day, for the tick loop below to decide when a date needs to @@ -146,8 +173,11 @@ } if (time_axis_type === 'gps') { var gps = p1ToGpsTime(p1); + // GPS time may not be available at the start of the timeline if we have to extrapolate backward for a very long + // time. Rather than silently showing a bare number that looks like a GPS value but isn't, label it as what it + // actually is. if (isNaN(gps)) { - return p1.toFixed(1) + ' s'; + return 'P1: ' + p1.toFixed(1) + ' s'; } var week = Math.floor(gps / SECONDS_PER_WEEK); var tow_sec = gps - week * SECONDS_PER_WEEK; @@ -156,7 +186,7 @@ } // 'utc' -- no date-change context here (see the tick loop's own UTC handling below), just the time of day. var parts = utcPartsForP1(p1); - return parts ? parts.time : p1.toFixed(1) + ' s'; + return parts ? parts.time : 'P1: ' + p1.toFixed(1) + ' s'; } function resizeCanvas() { @@ -194,8 +224,7 @@ ctx.stroke(); } - // Y axis context (0 at the baseline, ceil(max) at the top) -- without this there's no indication the - // background trace is even speed, let alone its scale. + // Y axis context (vehicle speed) - 0 at the bottom, ceil(max) at the top. ctx.fillStyle = '#6b6b66'; ctx.font = Math.round(10 * dpr) + 'px sans-serif'; ctx.textAlign = 'left'; @@ -205,14 +234,17 @@ ctx.fillText('0 m/s', 4 * dpr, chartH - 3 * dpr); // X axis time, in whatever format the rest of the log's plots use (self.time_type). The domain marker - // ("Rel:"/"P1:"/"GPS:"/"UTC:") only appears once, on the first tick. In 'utc' mode, the bare time of day is - // also ambiguous about which day it's from, so the first tick -- and any later tick that lands on a different - // UTC calendar day than the one before it, however many days apart -- gets the date too (but not the "UTC:" - // marker again, since that was already established by the first tick). + // ("Rel:"/"P1:"/"GPS:"/"UTC:") only appears once, on the first tick that actually has it -- usually tick 0, + // but GPS/UTC time may not be available yet that early in the log (e.g. before first fix), in which case that + // tick falls back to a clearly-labeled "P1: ..." reading instead, and the "UTC:" marker moves to the first + // tick that does resolve. In 'utc' mode, the bare time of day is also ambiguous about which day it's from, so + // that same first-resolved tick -- and any later tick that lands on a different UTC calendar day than the one + // before it, however many days apart -- gets the date too. var tickFracs = [0, 0.25, 0.5, 0.75, 1.0]; ctx.font = Math.round(9 * dpr) + 'px sans-serif'; ctx.textBaseline = 'top'; var lastUtcDate = null; + var utcDomainLabelShown = false; tickFracs.forEach(function(f, idx) { ctx.textAlign = (idx === 0) ? 'left' : (idx === tickFracs.length - 1) ? 'right' : 'center'; var p1 = fracToTime(f); @@ -220,12 +252,13 @@ if (time_axis_type === 'utc') { var parts = utcPartsForP1(p1); if (parts === null) { - label = p1.toFixed(1) + ' s'; + label = 'P1: ' + p1.toFixed(1) + ' s'; } else { - var showDate = (idx === 0) || (parts.date !== lastUtcDate); + var showDate = !utcDomainLabelShown || (parts.date !== lastUtcDate); lastUtcDate = parts.date; var dateTime = showDate ? (parts.date + ' ' + parts.time) : parts.time; - label = (idx === 0) ? ('UTC: ' + dateTime) : dateTime; + label = !utcDomainLabelShown ? ('UTC: ' + dateTime) : dateTime; + utcDomainLabelShown = true; } } else { label = formatTickLabel(p1, idx === 0); @@ -238,17 +271,26 @@ var winEnd = P1_TIME_MAX; // Neither side gets a "Rel:"/"P1:"/"GPS:"/"UTC:" prefix here -- "Showing" already establishes these are times, - // and the axis ticks above spell out which domain. In 'utc' mode, the end date is only repeated if it actually - // differs from the start's (mirrors the axis ticks' own midnight-crossing rule, just for these two values). + // and the axis ticks above spell out which domain -- *unless* GPS/UTC time isn't actually available for that + // particular value (e.g. before first fix), in which case it falls back to an explicitly-labeled "P1: ..." + // reading instead of a bare number that looks like it's in the axis's domain but isn't. In 'utc' mode, the end + // date is only repeated if it actually differs from the start's (mirrors the axis ticks' own midnight-crossing + // rule, just for these two values) -- unless the start itself fell back to P1, in which case there's no prior + // date to compare against, so the end always shows its date too. function formatRangeReadout() { if (time_axis_type === 'utc') { var p0 = utcPartsForP1(winStart); var p1 = utcPartsForP1(winEnd); - if (p0 === null || p1 === null) { - return 'Showing ' + winStart.toFixed(1) + ' s → ' + winEnd.toFixed(1) + ' s'; + var startText = p0 ? (p0.date + ' ' + p0.time) : ('P1: ' + winStart.toFixed(1) + ' s'); + var endText; + if (p1 === null) { + endText = 'P1: ' + winEnd.toFixed(1) + ' s'; + } else if (p0 === null || p1.date !== p0.date) { + endText = p1.date + ' ' + p1.time; + } else { + endText = p1.time; } - var endText = (p1.date !== p0.date) ? (p1.date + ' ' + p1.time) : p1.time; - return 'Showing ' + p0.date + ' ' + p0.time + ' → ' + endText; + return 'Showing ' + startText + ' → ' + endText; } return 'Showing ' + formatTickLabel(winStart, false) + ' → ' + formatTickLabel(winEnd, false); } From 24293027f26996cc6904731d478e7f70d184c329 Mon Sep 17 00:00:00 2001 From: Adam Shapiro Date: Mon, 27 Jul 2026 09:57:26 -0400 Subject: [PATCH 09/13] Show selected duration. --- .../analysis/plotly_map_time_slider.js | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/python/fusion_engine_client/analysis/plotly_map_time_slider.js b/python/fusion_engine_client/analysis/plotly_map_time_slider.js index e86bf740..ffd6e2f6 100644 --- a/python/fusion_engine_client/analysis/plotly_map_time_slider.js +++ b/python/fusion_engine_client/analysis/plotly_map_time_slider.js @@ -277,7 +277,19 @@ // date is only repeated if it actually differs from the start's (mirrors the axis ticks' own midnight-crossing // rule, just for these two values) -- unless the start itself fell back to P1, in which case there's no prior // date to compare against, so the end always shows its date too. + // Always HH:MM:SS, even when hours is 0 -- dropping leading zero fields reads ambiguously (is "01:25" one + // minute or one hour?). + function formatDuration(duration_sec) { + var total_sec = Math.max(0, Math.round(duration_sec)); + var hh = Math.floor(total_sec / 3600); + var mm = Math.floor((total_sec % 3600) / 60); + var ss = total_sec % 60; + function pad(n) { return (n < 10 ? '0' : '') + n; } + return pad(hh) + ':' + pad(mm) + ':' + pad(ss); + } + function formatRangeReadout() { + var rangeText; if (time_axis_type === 'utc') { var p0 = utcPartsForP1(winStart); var p1 = utcPartsForP1(winEnd); @@ -290,9 +302,11 @@ } else { endText = p1.time; } - return 'Showing ' + startText + ' → ' + endText; + rangeText = startText + ' → ' + endText; + } else { + rangeText = formatTickLabel(winStart, false) + ' - ' + formatTickLabel(winEnd, false); } - return 'Showing ' + formatTickLabel(winStart, false) + ' → ' + formatTickLabel(winEnd, false); + return 'Displaying: ' + rangeText + ' | Duration: ' + formatDuration(winEnd - winStart); } function updateWindowDivStyle() { From 26f4d76262263afe8e4cf6ac63d61ddc0b694f1a Mon Sep 17 00:00:00 2001 From: Adam Shapiro Date: Mon, 27 Jul 2026 09:58:37 -0400 Subject: [PATCH 10/13] Increased time tick font size. --- python/fusion_engine_client/analysis/plotly_map_time_slider.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/fusion_engine_client/analysis/plotly_map_time_slider.js b/python/fusion_engine_client/analysis/plotly_map_time_slider.js index ffd6e2f6..35d37c31 100644 --- a/python/fusion_engine_client/analysis/plotly_map_time_slider.js +++ b/python/fusion_engine_client/analysis/plotly_map_time_slider.js @@ -241,7 +241,7 @@ // that same first-resolved tick -- and any later tick that lands on a different UTC calendar day than the one // before it, however many days apart -- gets the date too. var tickFracs = [0, 0.25, 0.5, 0.75, 1.0]; - ctx.font = Math.round(9 * dpr) + 'px sans-serif'; + ctx.font = Math.round(10 * dpr) + 'px sans-serif'; ctx.textBaseline = 'top'; var lastUtcDate = null; var utcDomainLabelShown = false; From e3e1d8749452aed457c5e1aaf1c44f17f5ecf66b Mon Sep 17 00:00:00 2001 From: Adam Shapiro Date: Mon, 27 Jul 2026 10:25:30 -0400 Subject: [PATCH 11/13] Delay map visibility to accommodate the time slider. --- .../fusion_engine_client/analysis/analyzer.py | 35 ++++++++++++++-- .../analysis/plotly_map_time_slider.js | 40 +++++++++++++------ 2 files changed, 60 insertions(+), 15 deletions(-) diff --git a/python/fusion_engine_client/analysis/analyzer.py b/python/fusion_engine_client/analysis/analyzer.py index 534149a4..e78fc41e 100755 --- a/python/fusion_engine_client/analysis/analyzer.py +++ b/python/fusion_engine_client/analysis/analyzer.py @@ -1350,8 +1350,25 @@ def _plot_data(name, selected_idx, flags, source_id, lla_deg, customdata_all, ma profile_speed_mps=profile_speed_mps, profile_gps_time_sec=profile_gps_time_sec) + # Make room for the slider *before* Plotly's own first render so the map doesn't appear full-size and then + # shrink after the time scale renders. + # + # The map itself starts hidden (`visibility:hidden`, which still reserves its final layout space, unlike + # `display:none`) -- even with the container correctly sized up front, Plotly's own WebGL/mapbox-gl + # rendering doesn't necessarily catch up to a resize() call within the same paint, so revealing it right + # away can still show one frame at the wrong (window-sized) dimensions overlapping the slider. It's + # revealed by JS (plotly_map_time_slider.js) once Plotly itself reports the post-resize redraw is done. + slider_head_css = """\ + +""" + self._add_figure(name="map", figure=figure, title="Vehicle Trajectory (Map)", config={'scrollZoom': True}, - custom_hover=False, inject_js=slider_js) + custom_hover=False, inject_js=slider_js, inject_head=slider_head_css) def plot_gnss_skyplot(self, decimate=True): for source_id in self._get_gnss_antenna_source_ids(): @@ -3532,7 +3549,7 @@ def _add_page(self, name, html_body, title=None): self.plots[name] = {'title': title, 'path': path} def _add_figure(self, name, figure=None, title=None, config=None, inject_js: str = None, - time_axis_type: Optional[str] = None, custom_hover: bool = True): + inject_head: str = None, time_axis_type: Optional[str] = None, custom_hover: bool = True): """! @brief Generate an HTML file for the specified figure. @@ -3542,7 +3559,13 @@ def _add_figure(self, name, figure=None, title=None, config=None, inject_js: str @param config An optional dictionary containing Plotly.js figure config options to be included in the generated JavaScript. @param inject_js Custom Javascript to be injected into the generated HTML file (see @ref - __write_html_and_inject_js()). + __write_html_and_inject_js()). Runs *after* `Plotly.newPlot()`, so it's too late to affect the + container's size before Plotly's own initial (auto-sized) render -- use `inject_head` for that. + @param inject_head Raw HTML (typically a `