Skip to content

Commit 7b744fe

Browse files
committed
fix: snap TimeSeries seek events to nearest data point to eliminate precision errors
When clicking on a TimeSeries visualization, seek events were emitting inaccurate time values due to floating-point precision errors in the pixel-to-time conversion calculations. For example, clicking on data point 1770.677344 would emit a seek event with value 1770.6719467913167, which doesn't exist in the original dataset. This caused synchronization issues with audio/video players and affected annotation accuracy. Changes: - Added snapToNearestDataPoint() helper function in helpers.js using efficient binary search (O(log n)) to find the closest actual data point - Refactored emitSeekSync() to snap center time before emitting - Refactored plotClickHandler() to snap clicked time before processing - Refactored handleMainAreaClick() to snap clicked time before processing Benefits: - Seek events now emit exact data point values from the dataset - Eliminates ~135 lines of duplicated code across 3 locations - Maintains backward compatibility with no breaking changes - Improves synchronization accuracy with video/audio players - Ensures annotation precision in time-based labeling tasks Bug demonstration: https://www.loom.com/share/5f1f429a21f0438ca5f11e7146570bfe Fix demonstration: https://www.loom.com/share/b1b2b9ea3230461eb6e58848c40edfe2 Files changed: - web/libs/editor/src/tags/object/TimeSeries/helpers.js - web/libs/editor/src/tags/object/TimeSeries.jsx Related To: #8601
1 parent 4140635 commit 7b744fe

File tree

3 files changed

+65
-4
lines changed

3 files changed

+65
-4
lines changed

web/libs/editor/src/tags/Custom.jsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import React from "react";
55
import { destroy, types, getRoot } from "mobx-state-tree";
66
import { observer } from "mobx-react";
77
import { EnterpriseBadge } from "@humansignal/ui";
8-
import Registry from "../core/Registry";
98
import ControlBase from "./control/Base";
109
import ClassificationBase from "./control/ClassificationBase";
1110

web/libs/editor/src/tags/object/TimeSeries.jsx

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
getOptimalWidth,
1818
getRegionColor,
1919
idFromValue,
20+
snapToNearestDataPoint,
2021
sparseValues,
2122
} from "./TimeSeries/helpers";
2223
import { AnnotationMixin } from "../../mixins/AnnotationMixin";
@@ -1035,13 +1036,18 @@ const Model = types
10351036
if (!isFF(FF_TIMESERIES_SYNC)) return;
10361037
if (self.suppressSync) return;
10371038

1038-
const centerTime = self.centerTime; // centerTime is in NATIVE units (ms if isDate, else seconds/indices)
1039+
let centerTime = self.centerTime; // centerTime is in NATIVE units (ms if isDate, else seconds/indices)
10391040
if (centerTime !== null && self.sync && !self.isPlaying) {
10401041
const [minKey] = self.keysRange; // Native unit
10411042
if (minKey === undefined) {
10421043
// console.warn("TimeSeries emitSeekSync: minKey is undefined.");
10431044
return;
10441045
}
1046+
1047+
// Snap to the nearest actual data point to avoid floating-point precision errors
1048+
const timeData = self.dataObj?.[self.keyColumn];
1049+
centerTime = snapToNearestDataPoint(centerTime, timeData);
1050+
10451051
// Convert native centerTime to relative seconds for the sync message
10461052
let relativeTime;
10471053
if (self.isDate) {
@@ -1061,7 +1067,11 @@ const Model = types
10611067
if (self.isNotReady) return;
10621068

10631069
const [minKey, maxKey] = self.keysRange;
1064-
const finalTime = Math.max(minKey, Math.min(timeClicked, maxKey));
1070+
const clampedTime = Math.max(minKey, Math.min(timeClicked, maxKey));
1071+
1072+
// Snap to the nearest actual data point to avoid floating-point precision errors
1073+
const timeData = self.dataObj?.[self.keyColumn];
1074+
const finalTime = snapToNearestDataPoint(clampedTime, timeData);
10651075

10661076
const insideView = self.brushRange && finalTime >= self.brushRange[0] && finalTime <= self.brushRange[1];
10671077

@@ -1472,7 +1482,11 @@ const HtxTimeSeriesViewRTS = ({ item }) => {
14721482
// Calculate the clicked time within the current brush range
14731483
const timeClicked = brushTimeStartNative + (clickX / plottingAreaWidth) * brushDurationNative;
14741484
const [minKey, maxKey] = item.keysRange;
1475-
const finalTime = Math.max(minKey, Math.min(timeClicked, maxKey));
1485+
const clampedTime = Math.max(minKey, Math.min(timeClicked, maxKey));
1486+
1487+
// Snap to the nearest actual data point to avoid floating-point precision errors
1488+
const timeData = item.dataObj?.[item.keyColumn];
1489+
const finalTime = snapToNearestDataPoint(clampedTime, timeData);
14761490

14771491
// Since we're clicking on the visible area, the time is always inside the current view
14781492
// Update cursor position to the clicked location

web/libs/editor/src/tags/object/TimeSeries/helpers.js

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,51 @@ export const formatRegion = (node) => {
6060
};
6161

6262
export const formatTrackerTime = (time) => new Date(time).toUTCString();
63+
64+
/**
65+
* Snap a time value to the nearest actual data point to avoid floating-point precision errors
66+
* @param {number} targetTime - The time to snap
67+
* @param {Array} timeData - Array of time values from the data
68+
* @returns {number} - The snapped time value (or original if no data)
69+
*/
70+
export const snapToNearestDataPoint = (targetTime, timeData) => {
71+
if (!timeData || timeData.length === 0) {
72+
return targetTime;
73+
}
74+
75+
// Binary search to find the closest data point
76+
let left = 0;
77+
let right = timeData.length - 1;
78+
let closestIndex = 0;
79+
let minDiff = Math.abs(timeData[0] - targetTime);
80+
81+
while (left <= right) {
82+
const mid = Math.floor((left + right) / 2);
83+
const diff = Math.abs(timeData[mid] - targetTime);
84+
85+
if (diff < minDiff) {
86+
minDiff = diff;
87+
closestIndex = mid;
88+
}
89+
90+
if (timeData[mid] < targetTime) {
91+
left = mid + 1;
92+
} else if (timeData[mid] > targetTime) {
93+
right = mid - 1;
94+
} else {
95+
// Exact match found
96+
closestIndex = mid;
97+
break;
98+
}
99+
}
100+
101+
// Also check adjacent points to ensure we have the absolute closest
102+
if (closestIndex > 0 && Math.abs(timeData[closestIndex - 1] - targetTime) < minDiff) {
103+
closestIndex = closestIndex - 1;
104+
}
105+
if (closestIndex < timeData.length - 1 && Math.abs(timeData[closestIndex + 1] - targetTime) < minDiff) {
106+
closestIndex = closestIndex + 1;
107+
}
108+
109+
return timeData[closestIndex];
110+
};

0 commit comments

Comments
 (0)