import React from "react";
|
import * as d3 from "d3";
|
import { inject, observer } from "mobx-react";
|
import { getEnv, getRoot, getType, types, isAlive } from "mobx-state-tree";
|
import throttle from "lodash/throttle";
|
import { Spin } from "antd";
|
|
import ObjectBase from "./Base";
|
import ObjectTag from "../../components/Tags/Object";
|
import { errorBuilder } from "../../core/DataValidator/ConfigValidator";
|
import Registry from "../../core/Registry";
|
import Tree from "../../core/Tree";
|
import Types from "../../core/Types";
|
import {
|
checkD3EventLoop,
|
formatTrackerTime,
|
getOptimalWidth,
|
getRegionColor,
|
idFromValue,
|
sparseValues,
|
} from "./TimeSeries/helpers";
|
import { AnnotationMixin } from "../../mixins/AnnotationMixin";
|
import PersistentStateMixin from "../../mixins/PersistentState";
|
import { SyncableMixin } from "../../mixins/Syncable";
|
import { parseCSV, parseValue, tryToParseJSON } from "../../utils/data";
|
import { fixMobxObserve } from "../../utils/utilities";
|
|
import "./TimeSeries/MultiChannel";
|
import "./TimeSeries/Channel";
|
import { getChannelColor } from "./TimeSeries/palette";
|
import { FF_TIMESERIES_SYNC, isFF } from "../../utils/feature-flags";
|
import { ff } from "@humansignal/core";
|
/**
|
* The `TimeSeries` tag can be used to label time series data. Read more about Time Series Labeling on [the time series template page](../templates/time_series.html).
|
*
|
* Note: The time axis in your data must be sorted, otherwise the TimeSeries tag does not work.
|
* To use autogenerated indices as time axes, don't use the `timeColumn` parameter.
|
*
|
* Use with the following data types: time series.
|
* @example
|
* <!--Labeling configuration for time series data stored in a CSV loaded from a URL containing 3 columns: time, sensor1, and sensor2. The time column stores time as a number. -->
|
* <View>
|
* <TimeSeries name="device" value="$timeseries" valueType="url" timeColumn="time">
|
* <Channel column="sensor1" />
|
* <Channel column="sensor2" />
|
* </TimeSeries>
|
* <TimeSeriesLabels name="label" toName="device">
|
* <Label value="Run" background="#5b5"/>
|
* <Label value="Walk" background="#55f"/>
|
* </TimeSeriesLabels>
|
* </View>
|
* @example
|
* <!--Labeling configuration for time series data stored in the task field `ts` in Label Studio JSON format. The time field is stored as a date in the `timeformat` field and formatted as a full date on the plot (by default). -->
|
* <View>
|
* <TimeSeries name="device" value="$ts" timeColumn="time" timeFormat="%m/%d/%Y %H:%M:%S">
|
* <Channel column="sensor1" />
|
* <Channel column="sensor2" />
|
* </TimeSeries>
|
* </View>
|
* @name TimeSeries
|
* @meta_title Time Series Tags for Time Series Data
|
* @meta_description Customize Label Studio with the TimeSeries tag to annotate time series data for machine learning and data science projects.
|
* @param {string} name Name of the element
|
* @param {string} value Key used to look up the data, either URLs for your time-series if valueType=url, otherwise expects JSON
|
* @param {url|json} [valueType=url] Format of time series data provided. If set to "url" then Label Studio loads value references inside `value` key, otherwise it expects JSON.
|
* @param {string} [sync] Object name to sync with.
|
* @param {string} [cursorColor] Color of the playback cursors used in sync (hex or any SVG-compatible color string)
|
* @param {string} [timeColumn] Column name or index that provides temporal values. If your time series data has no temporal column then one is automatically generated.
|
* @param {string} [timeFormat] Pattern used to parse values inside timeColumn, parsing is provided by d3, and follows `strftime` implementation
|
* @param {string} [timeDisplayFormat] Format used to display temporal value. Can be a number or a date. If a temporal column is a date, use strftime to format it. If it's a number, use [d3 number](https://github.com/d3/d3-format#locale_format) formatting.
|
* @param {string} [durationDisplayFormat] Format used to display temporal duration value for brush range. If the temporal column is a date, use strftime to format it. If it's a number, use [d3 number](https://github.com/d3/d3-format#locale_format) formatting.
|
* @param {string} [sep=,] Separator for your CSV file.
|
* @param {string} [overviewChannels] Comma-separated list of channel names or indexes displayed in overview.
|
* @param {string} [overviewWidth=25%] Default width of overview window in percents
|
* @param {boolean} [fixedScale=false] Whether to scale y-axis to the maximum to fit all the values. If false, current view scales to fit only the displayed values.
|
*/
|
const TagAttrs = types.model({
|
value: types.string,
|
valuetype: types.optional(types.enumeration(["url", "json"]), "url"),
|
timecolumn: "",
|
|
sep: ",",
|
timeformat: "",
|
timedisplayformat: "",
|
durationdisplayformat: ".0f",
|
overviewchannels: "", // comma-separated list of channels to show
|
overviewwidth: "25%",
|
|
fixedscale: false,
|
|
multiaxis: types.optional(types.boolean, false), // show channels in the same view
|
// visibilitycontrols: types.optional(types.boolean, false), // show channel visibility controls
|
hotkey: types.maybeNull(types.string),
|
sync: types.maybeNull(types.string),
|
|
// Color of the playback cursors (hex or any SVG-compatible color string)
|
cursorcolor: types.optional(types.string, "var(--color-neutral-inverted-surface)"),
|
});
|
|
const Model = types
|
.model("TimeSeriesBaseModel", {
|
type: "timeseries",
|
children: Types.unionArray(["channel", "timeseriesoverview", "view", "hypertext", "multichannel"]),
|
|
width: 840,
|
margin: types.frozen({ top: 20, right: 20, bottom: 30, left: 50, min: 10, max: 10 }),
|
brushRange: types.array(types.number),
|
|
// _value: types.optional(types.string, ""),
|
_needsUpdate: types.optional(types.number, 0),
|
})
|
.volatile(() => ({
|
data: null,
|
valueLoaded: false,
|
zoomedRange: 0,
|
scale: 1,
|
headers: [],
|
canvasWidth: 0,
|
seekTo: null,
|
isPlaying: false,
|
playStartTime: null,
|
playStartPosition: null,
|
animationFrameId: null,
|
playbackSpeed: 1,
|
// Cursor position in native units (same units as keysRange). Used for visual playhead.
|
cursorTime: null,
|
// Suppress sync while user drags overview
|
suppressSync: false,
|
}))
|
.views((self) => ({
|
// This condition shows that essential data missing or component not ready
|
get isNotReady() {
|
return (
|
!self.brushRange ||
|
self.brushRange.length !== 2 ||
|
!self.margin ||
|
!self.canvasWidth ||
|
!self.keysRange ||
|
self.keysRange.length !== 2
|
);
|
},
|
|
get regionsTimeRanges() {
|
if (!isAlive(self)) return [];
|
return self.regs.map((r) => {
|
return [r.start, r.end];
|
});
|
},
|
|
get centerTime() {
|
if (!self.brushRange || self.brushRange.length !== 2) return null;
|
return (self.brushRange[0] + self.brushRange[1]) / 2;
|
},
|
|
get defaultOverviewWidth() {
|
const defaultWidth = 25;
|
const defaultStart = 0;
|
// overviewwidth in percents, default 25, 100% max
|
const width = Math.min(self.overviewwidth.match(/(\d+)%$/)?.[1] ?? defaultWidth, 100) / 100;
|
|
return [defaultStart, width];
|
},
|
|
get store() {
|
return getRoot(self);
|
},
|
|
get isDate() {
|
return Boolean(self.timeformat) || (self.timedisplayformat && /[a-zA-Z]/.test(self.timedisplayformat[0]));
|
},
|
|
get keyColumn() {
|
// for virtual column use just an uniq random name to not overlap with other column names
|
return (self.timecolumn || "#@$").toLowerCase();
|
},
|
|
get parseTimeFn() {
|
return self.timeformat && self.timecolumn ? d3.utcParse(self.timeformat) : Number;
|
},
|
|
get channelsMap() {
|
const res = {};
|
const itemsToVisit = [...self.children];
|
let item;
|
while ((item = itemsToVisit.shift())) {
|
if (item.type === "channel") {
|
res[item.columnName] = item;
|
} else {
|
itemsToVisit.push(...item.children);
|
}
|
}
|
return res;
|
},
|
|
get channels() {
|
return Object.values(self.channelsMap);
|
},
|
|
parseTime(time) {
|
const parse = self.parseTimeFn;
|
|
const dt = parse(time);
|
|
if (dt instanceof Date) {
|
return dt.getTime();
|
}
|
|
return dt;
|
},
|
|
/**
|
* Calculate initial brush boundaries ensuring minimum points are visible
|
*/
|
calculateInitialBrushRange(times) {
|
const MIN_POINTS_ON_SCREEN = 10;
|
|
// If dataset is smaller than minimum, show everything
|
if (times.length < MIN_POINTS_ON_SCREEN) {
|
return [times[0], times[times.length - 1]];
|
}
|
|
const startIndex = Math.round((times.length - 1) * self.defaultOverviewWidth[0]);
|
const endIndex = Math.round((times.length - 1) * self.defaultOverviewWidth[1]);
|
const pointsInRange = endIndex - startIndex + 1;
|
|
// If we have enough points, use original range
|
if (pointsInRange >= MIN_POINTS_ON_SCREEN) {
|
return [times[startIndex], times[endIndex]];
|
}
|
|
// Otherwise, expand the range to show at least MIN_POINTS_ON_SCREEN points
|
return self.expandRangeToMinimumPoints(times, [times[startIndex], times[endIndex]], MIN_POINTS_ON_SCREEN);
|
},
|
|
/**
|
* Expand range to ensure minimum number of points are visible
|
*/
|
expandRangeToMinimumPoints(times, originalBoundaries, minPoints) {
|
const startIndex = Math.round((times.length - 1) * self.defaultOverviewWidth[0]);
|
const endIndex = Math.round((times.length - 1) * self.defaultOverviewWidth[1]);
|
|
const currentPointCount = endIndex - startIndex + 1;
|
const pointsNeeded = minPoints - currentPointCount;
|
|
if (pointsNeeded <= 0) {
|
return originalBoundaries; // Should not happen, but safety check
|
}
|
|
// Expand to the right first (show more recent data)
|
const expandRight = Math.min(pointsNeeded, times.length - 1 - endIndex);
|
const expandLeft = pointsNeeded - expandRight;
|
|
const newStartIndex = Math.max(0, startIndex - expandLeft);
|
const newEndIndex = Math.min(times.length - 1, endIndex + expandRight);
|
|
return [times[newStartIndex], times[newEndIndex]];
|
},
|
|
get dataObj() {
|
if (!self.valueLoaded || !self.data) return null;
|
let data = self.data;
|
|
// Autogenerated indices
|
if (!self.timecolumn) {
|
const justAnyColumn = Object.values(data)[0];
|
const indices = Array.from({ length: justAnyColumn.length }, (_, i) => i);
|
|
data = { ...data, [self.keyColumn]: indices };
|
|
// Require a timeformat for non numeric values
|
} else if (!self.timeformat && isNaN(data[self.keyColumn][0])) {
|
const message = [
|
`Looks like your <b>timeColumn</b> (${self.timecolumn}) contains non-numbers.`,
|
"You have to use <b>timeFormat</b> parameter if your values are datetimes.",
|
`First wrong values: ${data[self.keyColumn].slice(0, 3).join(", ")}`,
|
`<a href="${
|
getEnv(self).messages.URL_TAGS_DOCS
|
}/timeseries.html#Parameters" target="_blank">Read Documentation</a> for details.`,
|
];
|
|
throw new Error(message.join("<br/>"));
|
|
// Ensure that the timestamps are incremental and formatted to proper numeric values
|
} else {
|
let current = 0;
|
let previous = Number.NEGATIVE_INFINITY;
|
const dataLength = data[self.keyColumn].length;
|
const timestamps = Array.from({ length: dataLength });
|
|
// Check if user is using %f (microseconds) which D3 doesn't support
|
let actualTimeFormat = self.timeformat;
|
let shouldConvertMicroseconds = false;
|
|
if (self.timeformat && self.timeformat.includes("%f")) {
|
console.warn(
|
"TimeSeries: timeFormat contains %f (microseconds) which is not supported by D3. " +
|
"Converting microseconds to milliseconds and using %L instead. " +
|
"Consider updating your timeFormat to use %L and your data to use 3-digit milliseconds.",
|
);
|
actualTimeFormat = self.timeformat.replace("%f", "%L");
|
shouldConvertMicroseconds = true;
|
}
|
|
for (let i = 0; i < dataLength; i++) {
|
let value = data[self.keyColumn][i];
|
|
if (self.timeformat) {
|
// Convert microseconds to milliseconds if needed
|
if (shouldConvertMicroseconds && typeof value === "string") {
|
// Convert "00:00:01.123456" to "00:00:01.123"
|
value = value.replace(/\.(\d{3})\d{3}$/, ".$1");
|
}
|
|
// Pad fractional seconds to exactly 3 digits for consistent parsing
|
if (typeof value === "string" && value.includes(".")) {
|
// Match timestamp with decimal point and capture fractional part
|
value = value.replace(/\.(\d{0,3})\b/, (_match, fractional) => {
|
// Pad fractional seconds to exactly 3 digits
|
return `.${fractional.padEnd(3, "0")}`;
|
});
|
}
|
|
// Use the corrected format for parsing
|
const parse = actualTimeFormat ? d3.utcParse(actualTimeFormat) : d3.utcParse(self.timeformat);
|
const dt = parse(value);
|
|
if (dt instanceof Date) {
|
current = dt.getTime();
|
} else if (dt === null) {
|
// Parsing failed - this will trigger the error handling below
|
current = 0;
|
} else {
|
current = dt;
|
}
|
} else {
|
current = value;
|
}
|
|
timestamps[i] = current;
|
|
if (current < previous) {
|
const nonSeqValues = [`seq: ${i - 1}, value: ${data[self.keyColumn][i - 1]}`, `seq: ${i}, value: ${value}`];
|
|
throw new Error(
|
[
|
`<b>timeColumn</b> (${self.timecolumn}) must be incremental and sequentially ordered.`,
|
`First wrong values: ${nonSeqValues.join(", ")}`,
|
`<br/><a href="${
|
getEnv(self).messages.URL_TAGS_DOCS
|
}/timeseries.html" target="_blank">Read Documentation</a> for details.`,
|
].join("<br/>"),
|
);
|
}
|
|
previous = current;
|
}
|
|
// Check if parsing failed by looking for multiple null, 0, or NaN values
|
const failedValues = timestamps.slice(0, 3).filter((t) => t === null || t === 0 || isNaN(t));
|
if (failedValues.length >= 2) {
|
const message = [
|
`<b>timeColumn</b> (${self.timecolumn}) cannot be parsed.`,
|
`First wrong values: ${data[self.keyColumn].slice(0, 3).join(", ")}`,
|
];
|
|
if (self.timeformat) {
|
message.push(`Your <b>timeFormat</b>: ${self.timeformat}. It should be compatible with these values.`);
|
|
if (self.timeformat.includes("%f")) {
|
message.push(
|
"<b>Note:</b> %f (microseconds) is not supported by D3. Use %L (milliseconds) instead and convert your data to 3-digit milliseconds.",
|
);
|
}
|
} else {
|
message.push("You have to use <b>timeFormat</b> parameter if your values are datetimes.");
|
}
|
message.push(
|
`<br/><a href="${
|
getEnv(self).messages.URL_TAGS_DOCS
|
}/timeseries.html#Parameters" target="_blank">Read Documentation</a> for details.`,
|
);
|
throw new Error(message.join("<br/>"));
|
}
|
|
data = { ...data, [self.keyColumn]: timestamps };
|
}
|
|
return data;
|
},
|
|
get dataHash() {
|
const raw = self.dataObj;
|
const { keyColumn } = self;
|
|
if (!raw) return null;
|
const keys = Object.keys(raw);
|
const data = [];
|
|
for (const key of keys) {
|
for (let i = 0; i < raw[key].length; i++) {
|
if (!data[i]) {
|
data[i] = { [key]: raw[key][i] };
|
} else {
|
data[i][key] = raw[key][i];
|
}
|
if (!self.timecolumn) data[i][keyColumn] = i;
|
}
|
}
|
return data;
|
},
|
|
get slicesCount() {
|
return 10;
|
},
|
|
get dataSlices() {
|
// @todo it should make it `computed` automatically
|
if (self.slices) return self.slices;
|
const count = self.slicesCount;
|
const data = self.dataHash;
|
const slice = Math.floor(data.length / count);
|
const slices = [];
|
|
for (let i = 0; i < count - 1; i++) {
|
slices[i] = data.slice(slice * i, slice * i + slice + 1);
|
}
|
slices.push(data.slice(slice * (count - 1)));
|
self.slices = slices;
|
return slices;
|
},
|
|
// range of times or numerical indices
|
get keysRange() {
|
const keys = self.dataObj?.[self.keyColumn];
|
|
if (!keys?.length) return [];
|
return [keys[0], keys[keys.length - 1]];
|
},
|
|
get persistentValues() {
|
return {
|
brushRange: self.brushRange,
|
initialRange: self.initialRange,
|
// @todo as usual for rerender
|
scale: self.scale + 0.0001,
|
};
|
},
|
|
get persistentFingerprint() {
|
return { task: getRoot(self).task?.id };
|
},
|
|
states() {
|
return self.annotation.toNames.get(self.name);
|
},
|
|
activeStates() {
|
const states = self.states();
|
|
return states ? states.filter((s) => s.isSelected && getType(s).name === "TimeSeriesLabelsModel") : null;
|
},
|
|
formatTime(time) {
|
if (!self._format) {
|
const { timedisplayformat: format, isDate } = self;
|
|
if (format === "date") self._format = formatTrackerTime;
|
else if (format) self._format = isDate ? d3.utcFormat(format) : d3.format(format);
|
else self._format = String;
|
}
|
return self._format(time);
|
},
|
|
formatDuration(duration) {
|
if (!self._formatDuration) {
|
const { durationdisplayformat: format, isDate } = self;
|
|
if (format) self._formatDuration = isDate ? d3.utcFormat(format) : d3.format(format);
|
else self._formatDuration = String;
|
}
|
return self._formatDuration(duration);
|
},
|
}))
|
|
.actions((self) => ({
|
afterCreate() {
|
self.channels.forEach((channel, idx) => {
|
if (channel.strokecolor === "") {
|
channel.strokecolor = getChannelColor(idx);
|
}
|
if (channel.markercolor === "") {
|
channel.markercolor = getChannelColor(idx);
|
}
|
});
|
},
|
|
setData(data) {
|
self.data = data;
|
self.valueLoaded = true;
|
},
|
|
setColumnNames(headers) {
|
self.headers = headers;
|
},
|
|
setZoomedRange(range) {
|
self.zoomedRange = range;
|
},
|
|
setScale(scale) {
|
self.scale = scale;
|
},
|
|
updateCanvasWidth(width) {
|
self.canvasWidth = width;
|
},
|
|
updateView() {
|
self._needsUpdate = self._needsUpdate + 1;
|
},
|
|
resetSeekTo() {
|
self.seekTo = null;
|
},
|
|
/**
|
* Suppress sync while user drags overview
|
*/
|
setSuppressSync(flag) {
|
self.suppressSync = flag;
|
},
|
|
/**
|
* Update cursorTime & seekTo without changing view.
|
*/
|
setCursorAndSeek(time) {
|
self.cursorTime = time;
|
self.seekTo = time;
|
},
|
|
/**
|
* Update cursorTime only (no brush move).
|
*/
|
setCursor(time) {
|
self.cursorTime = time;
|
},
|
|
/**
|
* Restart playback from a specific time position
|
* @param {number} time - The time in native units to restart playback from
|
*/
|
restartPlaybackFromTime(time) {
|
if (!self.isPlaying) return;
|
|
// Cancel the current animation frame
|
if (self.animationFrameId) {
|
cancelAnimationFrame(self.animationFrameId);
|
self.animationFrameId = null;
|
}
|
|
// Convert native time to relative seconds for playback state
|
const [minKey] = self.keysRange;
|
let relativeTimeForPlayback;
|
if (self.isDate) {
|
relativeTimeForPlayback = (time - minKey) / 1000;
|
} else {
|
relativeTimeForPlayback = time - minKey;
|
}
|
|
// Update the play start position to the new time
|
self.playStartPosition = relativeTimeForPlayback;
|
self.playStartTime = performance.now();
|
|
// Restart the playback loop
|
self.playbackLoop();
|
},
|
|
scrollToRegion(r) {
|
const range = [...self.brushRange];
|
|
if (r.start >= range[0] && r.end <= range[1]) return;
|
const currentSize = range[1] - range[0];
|
const regionSize = r.end - r.start;
|
const desiredSize = regionSize * 1.5;
|
const gap = (desiredSize - regionSize) / 2;
|
|
if (currentSize < desiredSize) {
|
const extend = (desiredSize - currentSize) / 2;
|
|
range[0] -= extend;
|
range[1] += extend;
|
}
|
// just move without resize
|
if (r.start < range[0]) {
|
range[1] -= range[0] - (r.start - gap);
|
range[0] = r.start - gap;
|
}
|
if (r.end > range[1]) {
|
range[0] += r.end + gap - range[1];
|
range[1] = r.end + gap;
|
}
|
// constrain to domain
|
range[0] = Math.max(self.keysRange[0], range[0]);
|
range[1] = Math.min(self.keysRange[1], range[1]);
|
// @todo dirty hack to trigger rerender, rewrite
|
self.updateTR(range, self.scale + 0.0001);
|
},
|
|
updateTR(tr, scale = 1) {
|
if (tr === null) return;
|
|
self.initialRange = tr;
|
self.brushRange = tr;
|
self.setZoomedRange(tr[1] - tr[0]);
|
self.setScale(scale);
|
self.updateView();
|
self.emitSeekSync(); // Emit sync when view range changes
|
},
|
|
throttledRangeUpdate() {
|
return throttle(self.updateTR, 100);
|
},
|
|
addRegion(start, end) {
|
const states = self.getAvailableStates();
|
|
if (states.length === 0) return;
|
const [control, ...rest] = states;
|
const labels = { [control.valueType]: control.selectedValues() };
|
|
const r = ff.isActive(ff.FF_MULTIPLE_LABELS_REGIONS)
|
? self.annotation.createResult({ start, end, instant: start === end }, labels, control, self, false, rest)
|
: self.annotation.createResult({ start, end, instant: start === end }, labels, control, self, false);
|
|
return r;
|
},
|
|
regionChanged(timerange, i) {
|
const r = self.regs[i];
|
let needUpdate = false;
|
|
if (!r) {
|
const newRegion = self.addRegion(timerange.start, timerange.end);
|
|
needUpdate = true;
|
newRegion.notifyDrawingFinished();
|
} else {
|
needUpdate = r.start !== timerange.start || r.end !== timerange.end;
|
r.updateRegion(timerange.start, timerange.end);
|
}
|
needUpdate && self.updateView();
|
},
|
|
async preloadValue(store) {
|
const dataObj = store.task.dataObj;
|
|
if (self.valuetype !== "url") {
|
if (self.value) {
|
self.setData(parseValue(self.value, dataObj));
|
} else {
|
self.setData(dataObj);
|
}
|
return;
|
}
|
|
if (!self.value) {
|
const message = `Attribute <b>value</b> for <b>${self.name}</b> should be provided when <b>valuetype="url"</b>`;
|
|
store.annotationStore.addErrors([errorBuilder.generalError(message)]);
|
return;
|
}
|
const url = parseValue(self.value, dataObj);
|
|
if (!url || typeof url !== "string") {
|
const message = `Cannot find url in <b>${idFromValue(self.value)}</b> field of your task`;
|
|
store.annotationStore.addErrors([errorBuilder.generalError(message)]);
|
return;
|
}
|
let text = "";
|
let cors = false;
|
let res;
|
|
try {
|
res = await fetch(url);
|
if (!res.ok) {
|
if (res.status === 400) {
|
store.annotationStore.addErrors([
|
errorBuilder.loadingError(
|
`${res.status} ${res.statusText}`,
|
url,
|
self.value,
|
getEnv(store).messages.ERR_LOADING_S3,
|
),
|
]);
|
return;
|
}
|
throw new Error(`${res.status} ${res.statusText}`);
|
}
|
text = await res.text();
|
} catch (e) {
|
let error = e;
|
|
if (!res) {
|
try {
|
res = await fetch(url, { mode: "no-cors" });
|
if (!res.ok && res.status === 0) cors = true;
|
} catch (e) {
|
error = e;
|
}
|
}
|
store.annotationStore.addErrors([
|
errorBuilder.loadingError(error, url, self.value, cors ? getEnv(store).messages.ERR_LOADING_CORS : undefined),
|
]);
|
return;
|
}
|
|
try {
|
let data = tryToParseJSON(text);
|
let headers = [];
|
|
if (!data) {
|
let separator = self.sep;
|
|
if (separator?.length > 1) {
|
const aliases = { tab: "\t", "\\t": "\t", space: " ", auto: "auto", comma: ",", dot: "." };
|
|
separator = aliases[separator] || separator[0];
|
}
|
[data, headers] = parseCSV(text, separator);
|
}
|
if (!isAlive(self)) return;
|
self.setData(data);
|
self.setColumnNames(headers);
|
self.updateValue(store);
|
} catch (e) {
|
const message = `Problems with parsing CSV: ${e?.message || e}<br>URL: ${url}`;
|
|
store.annotationStore.addErrors([errorBuilder.generalError(message)]);
|
}
|
},
|
|
async updateValue(store) {
|
let data;
|
|
try {
|
if (!self.dataObj) {
|
await self.preloadValue(store);
|
}
|
data = self.dataObj;
|
} catch (e) {
|
store.annotationStore.addErrors([errorBuilder.generalError(e.message)]);
|
return;
|
}
|
if (!data) return;
|
const times = data[self.keyColumn];
|
|
if (!times) {
|
const message = [
|
`<b>${self.keyColumn}</b> not found in data.`,
|
'Use <b>valueType="url"</b> for data loading or column index for headless csv',
|
].join(" ");
|
|
store.annotationStore.addErrors([errorBuilder.generalError(message)]);
|
return;
|
}
|
// if current view already restored by PersistentState
|
if (self.brushRange?.length) return;
|
|
// Calculate initial brush range ensuring minimum points are visible
|
const boundaries = self.calculateInitialBrushRange(times);
|
self.updateTR(boundaries);
|
},
|
|
onHotKey() {},
|
|
_handleSeek(data) {
|
if (!isAlive(self)) {
|
console.warn("TimeSeries (seek): model instance is not alive. Skipping operation.");
|
return;
|
}
|
if (typeof data.time !== "number" || isNaN(data.time)) {
|
// console.error("TimeSeries _handleSeek: Invalid data.time received.", data.time);
|
return;
|
}
|
|
const [minKey] = self.keysRange; // Native unit
|
if (minKey === undefined) {
|
// console.warn("TimeSeries _handleSeek: minKey is undefined.");
|
return;
|
}
|
|
// Convert received relative seconds to native units for view update
|
let targetNativeForSeek;
|
if (self.isDate) {
|
targetNativeForSeek = minKey + data.time * 1000;
|
} else {
|
targetNativeForSeek = minKey + data.time;
|
}
|
|
// If we're currently playing, we need to restart the playback loop with the new time
|
if (self.isPlaying) {
|
// Cancel the current animation frame
|
if (self.animationFrameId) {
|
cancelAnimationFrame(self.animationFrameId);
|
self.animationFrameId = null;
|
}
|
// Update the play start position to the new time
|
self.playStartPosition = data.time;
|
self.playStartTime = performance.now();
|
// Restart the playback loop
|
self.playbackLoop();
|
}
|
|
self._updateViewForTime(targetNativeForSeek); // _updateViewForTime expects native units
|
},
|
|
_handlePlay(data) {
|
// data.time is received in relative seconds
|
if (!isAlive(self)) {
|
console.warn("TimeSeries (play): model instance is not alive. Skipping operation.");
|
return;
|
}
|
if (typeof data.time !== "number" || isNaN(data.time)) {
|
// console.error("TimeSeries _handlePlay: Invalid data.time received.", data.time);
|
return;
|
}
|
self.isPlaying = true;
|
self.playStartTime = performance.now();
|
self.playStartPosition = data.time; // Store relative seconds as the starting point for playback progression
|
self.playbackSpeed = data.speed || 1;
|
|
if (self.animationFrameId) {
|
cancelAnimationFrame(self.animationFrameId);
|
}
|
self.playbackLoop();
|
},
|
|
_handlePause(data) {
|
// data.time is received in relative seconds
|
if (!isAlive(self)) {
|
console.warn("TimeSeries (pause): model instance is not alive. Skipping operation.");
|
return;
|
}
|
self.isPlaying = false;
|
self.playStartTime = null;
|
self.playStartPosition = null;
|
|
if (self.animationFrameId) {
|
cancelAnimationFrame(self.animationFrameId);
|
self.animationFrameId = null;
|
}
|
|
const [minKey] = self.keysRange; // Native unit
|
if (minKey === undefined || data.time === undefined) {
|
// console.warn("TimeSeries _handlePause: Essential data missing.");
|
return;
|
}
|
|
// Convert received relative seconds to native units for view update
|
let targetNativeForPause;
|
if (self.isDate) {
|
targetNativeForPause = minKey + data.time * 1000;
|
} else {
|
targetNativeForPause = minKey + data.time;
|
}
|
self._updateViewForTime(targetNativeForPause); // _updateViewForTime expects native units
|
},
|
|
playbackLoop() {
|
if (!isAlive(self)) return;
|
if (!self.isPlaying || self.playStartTime === null || self.playStartPosition === null) {
|
self.animationFrameId = null;
|
return;
|
}
|
|
const currentTime = performance.now();
|
const elapsedSeconds = (currentTime - self.playStartTime) / 1000;
|
// playStartPosition is in relative seconds, so newTimeRelativeSeconds is also relative to data start
|
const newTimeRelativeSeconds = self.playStartPosition + elapsedSeconds * self.playbackSpeed;
|
|
const [minKey, maxKey] = self.keysRange; // Native units
|
if (minKey === undefined || maxKey === undefined) {
|
// console.warn("TimeSeries playbackLoop: keysRange not available. Halting playback.");
|
self.isPlaying = false;
|
return;
|
}
|
|
// Convert the calculated relative time to native units for boundary checks and view update
|
let targetNativeForUpdate;
|
if (self.isDate) {
|
targetNativeForUpdate = minKey + newTimeRelativeSeconds * 1000;
|
} else {
|
targetNativeForUpdate = minKey + newTimeRelativeSeconds;
|
}
|
|
// Boundary checks using native units
|
if (targetNativeForUpdate >= maxKey) {
|
self.isPlaying = false;
|
self.playStartTime = null;
|
self.playStartPosition = null;
|
if (self.animationFrameId) {
|
cancelAnimationFrame(self.animationFrameId);
|
self.animationFrameId = null;
|
}
|
self._updateViewForTime(maxKey); // Update to boundary in native units
|
return;
|
}
|
|
if (targetNativeForUpdate <= minKey) {
|
self.isPlaying = false;
|
self.playStartTime = null;
|
self.playStartPosition = null;
|
if (self.animationFrameId) {
|
cancelAnimationFrame(self.animationFrameId);
|
self.animationFrameId = null;
|
}
|
self._updateViewForTime(minKey); // Update to boundary in native units
|
return;
|
}
|
|
self._updateViewForTime(targetNativeForUpdate); // Update view with native units
|
self.animationFrameId = requestAnimationFrame(self.playbackLoop);
|
},
|
|
// _updateViewForTime expects its `time` argument to be in the NATIVE units of keysRange
|
_updateViewForTime(time) {
|
if (!isAlive(self)) return;
|
if (time === null || !Number.isFinite(time)) {
|
// console.warn("TimeSeries _updateViewForTime: Received null or non-finite time.");
|
return;
|
}
|
|
const [minKey, maxKey] = self.keysRange; // Native units
|
if (minKey === undefined || maxKey === undefined || self.canvasWidth <= 0) return;
|
|
const boundedTime = Math.max(minKey, Math.min(maxKey, time)); // time is native
|
const timeRangeDurationNative = maxKey - minKey;
|
|
if (timeRangeDurationNative <= 0) return;
|
|
const centerPx = ((boundedTime - minKey) / timeRangeDurationNative) * self.canvasWidth;
|
let currentBrushWidthNative = self.brushRange[1] - self.brushRange[0]; // native units
|
|
if (currentBrushWidthNative <= 0) {
|
currentBrushWidthNative = timeRangeDurationNative * 0.1;
|
if (currentBrushWidthNative <= 0) return;
|
}
|
|
const currentPixelWidth = (currentBrushWidthNative / timeRangeDurationNative) * self.canvasWidth;
|
if (currentPixelWidth <= 0) return;
|
|
let newPixelStart = centerPx - currentPixelWidth / 2;
|
let newPixelEnd = centerPx + currentPixelWidth / 2;
|
|
if (newPixelStart < 0) {
|
const widthPx = newPixelEnd - newPixelStart;
|
newPixelStart = 0;
|
newPixelEnd = Math.min(widthPx, self.canvasWidth);
|
}
|
if (newPixelEnd > self.canvasWidth) {
|
const widthPx = newPixelEnd - newPixelStart;
|
newPixelEnd = self.canvasWidth;
|
newPixelStart = Math.max(0, self.canvasWidth - widthPx);
|
}
|
newPixelStart = Math.max(0, newPixelStart);
|
newPixelEnd = Math.min(self.canvasWidth, newPixelEnd);
|
|
if (newPixelStart >= newPixelEnd) {
|
const defaultPixelWidth = Math.max(10, self.canvasWidth * 0.1);
|
newPixelStart = centerPx - defaultPixelWidth / 2;
|
newPixelEnd = centerPx + defaultPixelWidth / 2;
|
if (newPixelStart < 0) {
|
newPixelEnd -= newPixelStart;
|
newPixelStart = 0;
|
}
|
if (newPixelEnd > self.canvasWidth) {
|
newPixelStart -= newPixelEnd - self.canvasWidth;
|
newPixelEnd = self.canvasWidth;
|
}
|
newPixelStart = Math.max(0, newPixelStart);
|
newPixelEnd = Math.min(self.canvasWidth, newPixelEnd);
|
if (newPixelStart >= newPixelEnd) return;
|
}
|
|
let newTimeStartNative = minKey + (newPixelStart / self.canvasWidth) * timeRangeDurationNative;
|
let newTimeEndNative = minKey + (newPixelEnd / self.canvasWidth) * timeRangeDurationNative;
|
|
if (
|
!Number.isFinite(newTimeStartNative) ||
|
!Number.isFinite(newTimeEndNative) ||
|
newTimeStartNative >= newTimeEndNative
|
) {
|
newTimeStartNative = boundedTime - currentBrushWidthNative / 2;
|
newTimeEndNative = boundedTime + currentBrushWidthNative / 2;
|
if (newTimeStartNative < minKey) {
|
newTimeEndNative -= newTimeStartNative - minKey;
|
newTimeStartNative = minKey;
|
}
|
if (newTimeEndNative > maxKey) {
|
newTimeStartNative -= newTimeEndNative - maxKey;
|
newTimeEndNative = maxKey;
|
}
|
newTimeStartNative = Math.max(minKey, newTimeStartNative);
|
newTimeEndNative = Math.min(maxKey, newTimeEndNative);
|
if (
|
!Number.isFinite(newTimeStartNative) ||
|
!Number.isFinite(newTimeEndNative) ||
|
newTimeStartNative >= newTimeEndNative
|
)
|
return;
|
}
|
|
if (Number.isFinite(newTimeStartNative) && Number.isFinite(newTimeEndNative)) {
|
self.updateTR([newTimeStartNative, newTimeEndNative], self.scale); // updateTR expects native units
|
self.seekTo = boundedTime; // seekTo stores native units
|
|
// Update persistent cursor position for rendering playhead
|
self.cursorTime = boundedTime;
|
}
|
},
|
|
registerSyncHandlers() {
|
if (!isAlive(self)) return;
|
if (isFF(FF_TIMESERIES_SYNC)) {
|
self.syncHandlers.set("seek", self._handleSeek);
|
self.syncHandlers.set("play", self._handlePlay);
|
self.syncHandlers.set("pause", self._handlePause);
|
}
|
},
|
|
emitSeekSync() {
|
if (!isAlive(self)) return;
|
if (!isFF(FF_TIMESERIES_SYNC)) return;
|
if (self.suppressSync) return;
|
|
const centerTime = self.centerTime; // centerTime is in NATIVE units (ms if isDate, else seconds/indices)
|
if (centerTime !== null && self.sync && !self.isPlaying) {
|
const [minKey] = self.keysRange; // Native unit
|
if (minKey === undefined) {
|
// console.warn("TimeSeries emitSeekSync: minKey is undefined.");
|
return;
|
}
|
// Convert native centerTime to relative seconds for the sync message
|
let relativeTime;
|
if (self.isDate) {
|
// If native is ms, convert to relative seconds by subtracting minKey and dividing by 1000.
|
relativeTime = (centerTime - minKey) / 1000;
|
} else {
|
// If native is already seconds/indices, relative time is just offset from minKey.
|
relativeTime = centerTime - minKey;
|
}
|
// Include current playing state to prevent other media from pausing during seek
|
self.syncSend({ time: relativeTime, playing: self.isPlaying }, "seek");
|
}
|
},
|
|
plotClickHandler(timeClicked) {
|
if (!isAlive(self) || !isFF(FF_TIMESERIES_SYNC) || !self.sync) return;
|
if (self.isNotReady) return;
|
|
const [minKey, maxKey] = self.keysRange;
|
const finalTime = Math.max(minKey, Math.min(timeClicked, maxKey));
|
|
const insideView = self.brushRange && finalTime >= self.brushRange[0] && finalTime <= self.brushRange[1];
|
|
if (insideView) {
|
// Just move cursor without changing brush range
|
self.setCursor(finalTime);
|
} else if (typeof self._updateViewForTime === "function") {
|
// Re-center only when outside current view
|
self._updateViewForTime(finalTime);
|
}
|
|
// If we're currently playing, update the playback state to restart from the clicked position
|
if (self.isPlaying) {
|
self.restartPlaybackFromTime(finalTime);
|
}
|
|
let relativeTime;
|
if (self.isDate) {
|
relativeTime = (finalTime - minKey) / 1000;
|
} else {
|
relativeTime = finalTime - minKey;
|
}
|
// Include current playing state to prevent other media from pausing during seek
|
self.syncSend({ time: relativeTime, playing: self.isPlaying }, "seek");
|
},
|
}));
|
|
function useWidth() {
|
const [width, setWidth] = React.useState(840);
|
const [node, setNode] = React.useState(null);
|
|
const ref = React.useCallback((node) => {
|
setNode(node);
|
}, []);
|
|
React.useLayoutEffect(() => {
|
if (node) {
|
const measure = () =>
|
// window.requestAnimationFrame(() =>
|
setWidth(node.offsetWidth);
|
// );
|
|
measure();
|
|
window.addEventListener("resize", measure);
|
|
return () => {
|
window.removeEventListener("resize", measure);
|
};
|
}
|
}, [node]);
|
|
return [ref, width, node];
|
}
|
|
// class TimeSeriesOverviewD3 extends React.Component {
|
const Overview = observer(({ item, data, series }) => {
|
const regions = item.regs;
|
const [ref, fullWidth, node] = useWidth();
|
|
const focusHeight = 60;
|
const { margin, keyColumn: idX } = item;
|
const width = Math.max(fullWidth - margin.left - margin.right, 0);
|
// const data = store.task.dataObj;
|
let keys = Object.keys(item.channelsMap);
|
|
if (item.overviewchannels) {
|
const channels = item.overviewchannels
|
.toLowerCase()
|
.split(",")
|
.map((name) => (/^\d+$/.test(name) ? item.headers[name] : name))
|
.filter((ch) => keys.includes(ch));
|
|
if (channels.length) keys = channels;
|
}
|
// const series = data[idX];
|
const minRegionWidth = 2;
|
|
const focus = React.useRef();
|
const gRegions = React.useRef();
|
const gChannels = React.useRef();
|
const gAxis = React.useRef();
|
const gb = React.useRef();
|
const cursorLine = React.useRef();
|
|
const scale = item.isDate ? d3.scaleTime() : d3.scaleLinear();
|
const x = scale.domain(d3.extent(data[idX])).range([0, width]);
|
|
const upd = React.useCallback(item.throttledRangeUpdate(), []);
|
|
const defaultSelection = [0, width >> 2];
|
const prevBrush = React.useRef(defaultSelection);
|
const MIN_OVERVIEW = 10;
|
let startX;
|
|
function brushstarted() {
|
const [x1, x2] = d3.event.selection;
|
|
if (x1 === x2) {
|
startX = x1;
|
} else {
|
startX = null;
|
}
|
|
// Suppress sync while user drags overview
|
item.setSuppressSync(true);
|
}
|
|
function brushed() {
|
if (d3.event.selection && !checkD3EventLoop("brush") && !checkD3EventLoop("wheel")) {
|
let [x1, x2] = d3.event.selection;
|
const prev = prevBrush.current;
|
const overviewWidth = x2 - x1;
|
let start = +x.invert(x1);
|
let end = +x.invert(x2);
|
|
// if overview is left intact do nothing
|
if (prev[0] === x1 && prev[1] === x2) {
|
// TODO: please, rewrite this step to avoid empty blocks
|
}
|
|
// if overview was moved; precision comparison for floats
|
else if (prev[0] !== x1 && prev[1] !== x2 && Math.abs(overviewWidth - MIN_OVERVIEW) < 0.001) {
|
const mid = (start + end) / 2;
|
|
start = mid - item.zoomedRange / 2;
|
end = mid + item.zoomedRange / 2;
|
// if overview was resized
|
} else if (overviewWidth < MIN_OVERVIEW) {
|
if (prev[0] !== x1 && prev[1] !== x2) {
|
if (prev[0] === x2 || prev[1] === x1) {
|
// This may happen after sides swap
|
// so we swap prev as well
|
[prev[0], prev[1]] = [prev[1], prev[0]];
|
} else {
|
// This may happen at begining when range was not enough wide yet
|
if (x1 === startX) {
|
x2 = Math.min(width, x1 + MIN_OVERVIEW);
|
x1 = Math.max(0, x2 - MIN_OVERVIEW);
|
} else {
|
x1 = Math.max(0, x2 - MIN_OVERVIEW);
|
x2 = Math.min(width, x1 + MIN_OVERVIEW);
|
}
|
}
|
}
|
if (prev[0] === x1) {
|
x2 = Math.min(width, x1 + MIN_OVERVIEW);
|
x1 = Math.max(0, x2 - MIN_OVERVIEW);
|
} else if (prev[1] === x2) {
|
x1 = Math.max(0, x2 - MIN_OVERVIEW);
|
x2 = Math.min(width, x1 + MIN_OVERVIEW);
|
}
|
start = +x.invert(x1);
|
end = +x.invert(x2);
|
// change the data range, but keep min-width for overview
|
gb.current.call(brush.move, [x1, x2]);
|
}
|
prevBrush.current = [x1, x2];
|
upd([start, end]);
|
}
|
}
|
|
function brushended() {
|
if (!d3.event.selection) {
|
// move selection on click; try to preserve it's width
|
const center = d3.mouse(this)[0];
|
const range = item.brushRange.map(x);
|
const half = (range[1] - range[0]) >> 1;
|
let moved = [center - half, center + half];
|
|
if (moved[0] < 0) moved = [0, half * 2];
|
if (moved[1] > width) moved = [width - half * 2, width];
|
gb.current.call(brush.move, moved);
|
}
|
|
// Re-enable sync after drag ends (next tick to let range settle)
|
setTimeout(() => item.setSuppressSync(false), 0);
|
}
|
|
const brush = d3
|
.brushX()
|
.extent([
|
[0, 0],
|
[width, focusHeight],
|
])
|
.on("start", brushstarted)
|
.on("brush", brushed)
|
.on("end", brushended);
|
|
const drawPath = (key) => {
|
const channel = item.channelsMap[key];
|
const color = channel ? channel.strokecolor : "steelblue";
|
const y = d3
|
.scaleLinear()
|
.domain([d3.min(data[key]), d3.max(data[key])])
|
.range([focusHeight - margin.max, margin.min]);
|
|
gChannels.current
|
.append("path")
|
.datum(sparseValues(series, getOptimalWidth()))
|
.attr("class", "channel")
|
.attr("fill", "none")
|
.attr("stroke", color)
|
.attr(
|
"d",
|
d3
|
.line()
|
.y((d) => y(d[key]))
|
.defined((d) => d[idX])
|
.x((d) => x(d[idX])),
|
);
|
};
|
|
const drawRegions = (ranges) => {
|
const rSelection = gRegions.current.selectAll(".region").data(ranges);
|
|
rSelection
|
.enter()
|
.append("rect")
|
.attr("class", "region")
|
.merge(rSelection)
|
.attr("y", 0)
|
.attr("height", focusHeight)
|
.attr("x", (r) => x(r.start))
|
.attr("width", (r) => Math.max(minRegionWidth, x(r.end) - x(r.start)))
|
.attr("fill", (r) => getRegionColor(r, r.selected ? 0.8 : 0.3))
|
.style("display", (r) => (r.hidden ? "none" : "block"));
|
rSelection.exit().remove();
|
};
|
|
const drawAxis = () => {
|
gAxis.current.call(
|
d3
|
.axisBottom(x)
|
.ticks(width / 80)
|
.tickSizeOuter(0),
|
);
|
};
|
|
React.useEffect(() => {
|
if (!node) return;
|
|
focus.current = d3
|
.select(node)
|
.append("svg")
|
.attr("viewBox", [0, 0, width + margin.left + margin.right, focusHeight + margin.bottom])
|
.style("display", "block")
|
.append("g")
|
.attr("transform", `translate(${margin.left},0)`);
|
|
gAxis.current = focus.current.append("g").attr("transform", `translate(0,${focusHeight})`);
|
|
gChannels.current = focus.current.append("g").attr("class", "channels");
|
|
gRegions.current = focus.current.append("g").attr("class", "regions");
|
|
gb.current = focus.current.append("g").call(brush).call(brush.move, defaultSelection);
|
// give a bit more space for brush moving
|
gb.current.select(".handle--w").style("transform", "translate(-1px, 0)");
|
gb.current.select(".handle--e").style("transform", "translate(1px, 0)");
|
|
// Playhead cursor with triangle handle
|
cursorLine.current = focus.current
|
.append("g")
|
.attr("class", "overview-playhead")
|
.attr("pointer-events", "none")
|
.style("display", "none");
|
|
const cursorColor = item.cursorcolor || "var(--color-neutral-inverted-surface)";
|
|
// Vertical line
|
cursorLine.current
|
.append("line")
|
.attr("y1", 5) // Start below small handle
|
.attr("y2", focusHeight)
|
.attr("stroke", cursorColor)
|
.attr("stroke-width", 2);
|
|
// Upside-down house handle at top (pentagon like audio player)
|
cursorLine.current
|
.append("polygon")
|
.attr("points", "-4,0 4,0 4,7 1,10 -1,10 -4,7") // Upside-down house shape (1.5x wider)
|
.attr("fill", cursorColor);
|
}, [node]);
|
|
React.useEffect(() => {
|
if (node) {
|
d3.select(node)
|
.selectAll("svg")
|
.attr("viewBox", [0, 0, width + margin.left + margin.right, focusHeight + margin.bottom]);
|
|
gChannels.current.selectAll("path").remove();
|
for (const key of Object.keys(item.channelsMap)) drawPath(key);
|
|
drawAxis();
|
// gb.current.selectAll("*").remove();
|
gb.current.call(brush).call(brush.move, item.brushRange.map(x));
|
}
|
}, [width, node]);
|
|
// redraw overview on zoom
|
React.useEffect(() => {
|
if (!gb.current) return;
|
const range = item.brushRange.map(x);
|
|
if (range[1] - range[0] < MIN_OVERVIEW) {
|
const mid = (range[1] + range[0]) / 2;
|
|
range[0] = Math.max(0, mid - MIN_OVERVIEW / 2);
|
range[1] = Math.min(width, mid + MIN_OVERVIEW / 2);
|
}
|
prevBrush.current = range;
|
gb.current.call(brush.move, range);
|
}, [item.scale]); // the only parameter changes on zoom only
|
|
React.useEffect(() => {
|
node && drawRegions(regions);
|
});
|
|
// Add useEffect to listen for seekTo changes
|
React.useEffect(() => {
|
if (item.seekTo !== null && gb.current && isAlive(item)) {
|
const seekTimeNative = item.seekTo;
|
const [minKey, maxKey] = item.keysRange;
|
const timeRangeDurationNative = maxKey - minKey;
|
const centerPx = ((seekTimeNative - minKey) / timeRangeDurationNative) * width;
|
const range = item.brushRange.map(x);
|
const half = (range[1] - range[0]) / 2;
|
let moved = [centerPx - half, centerPx + half];
|
|
if (moved[0] < 0) moved = [0, half * 2];
|
if (moved[1] > width) moved = [width - half * 2, width];
|
gb.current.call(brush.move, moved);
|
item.resetSeekTo(); // Use the action instead of direct modification
|
}
|
}, [item.seekTo, width]);
|
|
// Update playhead position on cursorTime or width changes
|
React.useEffect(() => {
|
if (!cursorLine.current) return;
|
const time = item.cursorTime;
|
if (time === null || !Number.isFinite(time)) {
|
cursorLine.current.style("display", "none");
|
return;
|
}
|
const pos = x(time);
|
if (!Number.isFinite(pos)) {
|
cursorLine.current.style("display", "none");
|
return;
|
}
|
cursorLine.current.attr("transform", `translate(${pos},0)`).style("display", "block");
|
}, [item.cursorTime, width]);
|
|
item.regs.map((r) => fixMobxObserve(r.start, r.end, r.selected, r.hidden, r.style?.fillcolor));
|
|
return <div className="htx-timeseries-overview" ref={ref} />;
|
});
|
|
const HtxTimeSeriesViewRTS = ({ item }) => {
|
const ref = React.createRef();
|
|
React.useEffect(() => {
|
if (item?.brushRange?.length) {
|
item._nodeReference = ref.current;
|
|
const updateWidth = () => {
|
if (ref.current) {
|
item.updateCanvasWidth(ref.current.offsetWidth);
|
}
|
};
|
|
updateWidth();
|
window.addEventListener("resize", updateWidth);
|
|
return () => {
|
window.removeEventListener("resize", updateWidth);
|
};
|
}
|
}, [item, ref]);
|
|
// the last thing updated during initialisation
|
if (!item?.brushRange?.length || !item.data)
|
return (
|
<div style={{ textAlign: "center", height: 100 }}>
|
<Spin size="large" delay={300} />
|
</div>
|
);
|
|
return (
|
<div ref={ref} className="htx-timeseries">
|
<ObjectTag item={item}>
|
{Tree.renderChildren(item, item.annotation)}
|
<Overview data={item.dataObj} series={item.dataHash} item={item} range={item.brushRange} />
|
</ObjectTag>
|
</div>
|
);
|
};
|
|
const TimeSeriesModel = types.compose(
|
"TimeSeriesModel",
|
SyncableMixin,
|
ObjectBase,
|
PersistentStateMixin,
|
AnnotationMixin,
|
TagAttrs,
|
Model,
|
);
|
const HtxTimeSeries = inject("store")(observer(HtxTimeSeriesViewRTS));
|
|
Registry.addTag("timeseries", TimeSeriesModel, HtxTimeSeries);
|
Registry.addObjectType(TimeSeriesModel);
|
|
export { TimeSeriesModel, HtxTimeSeries };
|