+
@@ -183,13 +188,13 @@ const emit = defineEmits<{
}>();
// Some constants (in CSS pixels, where relevant).
-// Note: we want to look and behave like Plotly, so most of these constants are either Plotly's defaults (see
-// https://plotly.com/javascript/reference/) or what we used to give Plotly.
+// Note: we want to behave like Plotly, so many of these constants are either Plotly's defaults (see
+// https://plotly.com/javascript/reference/) or what we used to give Plotly. However, we want to look more modern,
+// so we use OpenCOR's font (rather than Plotly's) with font sizes of our own.
-const FONT_FAMILY = '"Open Sans", verdana, arial, sans-serif'; // Like Plotly, the font family of our tick labels and titles.
-const TICK_FONT_SIZE = 10; // Like Plotly, the font size of our tick labels.
-const TITLE_FONT_SIZE = 14; // Like Plotly, the font size of our axis titles.
-const LEGEND_FONT_SIZE = 12; // Like Plotly, the font size of our legend items.
+const TICK_FONT_SIZE = 11; // Font size of our tick labels.
+const TITLE_FONT_SIZE = 13; // Font size of our axis titles.
+const LEGEND_FONT_SIZE = 12; // Font size of our legend items.
const LEGEND_LEFT_MARGIN = 8; // Margin between our plotting area and our legend.
const LEGEND_RIGHT_MARGIN = 8; // Margin between our legend (or last X tick label) and the right edge of our container.
const DEFAULT_LEGEND_RANK = 1000; // Like Plotly's default legend rank.
@@ -203,31 +208,31 @@ const X_AXIS_TITLE_SIZE = 17; // Size of our X axis title.
const Y_AXIS_TITLE_SIZE = 17; // Size of our Y axis title.
const Y_AXIS_TITLE_GAP = 1.5; // Gap between our Y axis title and (the left edge of) our Y axis.
const Y_AXIS_EXTRA_SIZE = 5; // Extra size of our Y axis, on top of the width of its tick labels.
-const MAJOR_TICK_MIN_X_SPACING = 80; // Target spacing between two consecutive major ticks along the X axis.
-const MAJOR_TICK_MIN_Y_SPACING = 40; // Target spacing between two consecutive major ticks along the Y axis.
-const NB_OF_MINOR_TICKS = 5; // Number of minor tick spacings per major tick spacing (like Plotly).
+const TICK_MIN_X_SPACING = 80; // Target spacing between two consecutive ticks along the X axis.
+const TICK_MIN_Y_SPACING = 40; // Target spacing between two consecutive ticks along the Y axis.
const TICK_LABEL_MIN_SPACING = 10; // Minimum spacing between two consecutive tick labels.
const MAX_NB_OF_TICKS = 1000; // A safeguard against an axis needing an unreasonable number of ticks.
const LINE_WIDTH = 2; // Like Plotly, the width of our traces' lines (in CSS pixels).
const MARKER_SIZE = 6; // Like Plotly, the size of our markers (in CSS pixels).
-const MARKER_PADDING = Math.max(MARKER_SIZE / 1.6, 3); // Autorange padding for markers.
-const MIN_NB_OF_POINTS_WITHOUT_MARKERS = 20; // Like in Plotly, traces with fewer than 20 points also have markers.
-const AUTORANGE_PADDING = 0.05; // As a fraction of the length of an axis.
+const MARKER_PADDING = Math.max(MARKER_SIZE / 1.6, 3); // Autorange padding for markers (in CSS pixels).
+const MIN_NB_OF_POINTS_WITHOUT_MARKERS = 20; // Like Plotly, traces with fewer than 20 points also have markers.
+const AUTORANGE_PADDING = 0.05; // Autorange padding (as a fraction of the length of an axis).
const DEFAULT_X_RANGE: [number, number] = [-1, 6]; // Like Plotly, the default X range.
const DEFAULT_Y_RANGE: [number, number] = [-1, 4]; // Like Plotly, the default Y range.
const MIN_DRAG = 8; // Like Plotly, the minimum distance that we must drag for it not to be a click (e.g., to zoom).
const MIN_ZOOM = 20; // Like Plotly, the distance below which a drag is considered to be along one axis only.
const ZOOM_FACTOR = 0.5; // Factor by which our ranges get scaled when zooming in (or its inverse when zooming out).
+const ZOOM_ANIMATION_DURATION = 250; // Duration of our zoom animations (in milliseconds).
+const WHEEL_ZOOM_ANIMATION_DURATION = 100; // Duration of our wheel zoom animations (in milliseconds).
const DRAGGER_SIZE = 20; // Like Plotly, the thickness of our axis (and corner) draggers.
const AXIS_DRAGGER_OFFSET = 0.5; // Like Plotly, the distance between our plotting area and our axis draggers.
-const HOVER_FONT = 'Arial, sans-serif'; // Like Plotly, the font of our hover label.
-const HOVER_FONT_SIZE = 13; // Like Plotly, the font size of our hover label.
-const HOVER_LABEL_HEIGHT = 21; // Like Plotly, the height of our hover label (in CSS pixels).
-const HOVER_ARROW_SIZE = 6; // Like Plotly, the size of the arrow of our hover label (in CSS pixels).
-const HOVER_TEXT_PADDING = 3; // Like Plotly, the horizontal padding of our hover label's text (in CSS pixels).
-const HOVER_NAME_LENGTH = 15; // Like Plotly, the maximum length of a trace name in our hover label.
+const HIGHLIGHT_FADED_ALPHA = 0.2; // Opacity of our traces that are not highlighted when one of them is.
const HOVER_DISTANCE = 20; // Like Plotly, the maximum distance between the mouse pointer and a point to hover it.
const HOVER_LINE_RADIUS = 0.5; // Like Plotly, the "radius" of a point without a marker when hovering it.
+const HOVER_POINT_RADIUS = 4; // Radius of the points that we highlight when hovering our traces.
+const HOVER_TOOLTIP_OFFSET = 16; // Distance between the mouse pointer and our hover tooltip.
+const HOVER_TOOLTIP_MARGIN = 4; // Minimum distance between our hover tooltip and the edges of the window.
+const HOVER_TOOLTIP_MAX_ROWS = 12; // Maximum number of traces listed in our hover tooltip.
// Resize handling.
@@ -280,6 +285,7 @@ const legendRef = vue.shallowRef(null);
const xAxisTitleRef = vue.shallowRef(null);
const yAxisTitleRef = vue.shallowRef(null);
const rangeEditorRef = vue.shallowRef(null);
+const hoverTooltipRef = vue.shallowRef(null);
const theme = vueCommon.useTheme();
const contextMenuRef = vue.ref | null>(null);
const appendTarget = vueCommon.useAppendTarget(rootRef);
@@ -303,12 +309,13 @@ let trackedMargins: IGraphPanelMargins | undefined;
interface IThemeColors {
background: string;
text: string;
+ tickLabel: string;
grid: string;
- minorGrid: string;
zeroLine: string;
- zoomMask: string;
- zoomCornersFill: string;
- zoomCornersStroke: string;
+ zoomFill: string;
+ zoomStroke: string;
+ hiddenTrace: string;
+ hoverGuide: string;
}
const themeColors = vue.computed(() => {
@@ -317,12 +324,13 @@ const themeColors = vue.computed(() => {
return {
background: 'var(--p-content-background)',
text: 'var(--p-text-color)',
- grid: useLightMode ? 'var(--p-surface-200)' : 'var(--p-surface-700)',
- minorGrid: useLightMode ? 'var(--p-surface-100)' : 'var(--p-surface-800)',
- zeroLine: useLightMode ? 'var(--p-surface-400)' : 'var(--p-surface-500)',
- zoomMask: `color-mix(in srgb, ${useLightMode ? 'var(--p-surface-400)' : 'var(--p-surface-500)'} 50%, transparent)`,
- zoomCornersFill: useLightMode ? 'var(--p-surface-800)' : 'var(--p-surface-100)',
- zoomCornersStroke: useLightMode ? 'var(--p-surface-200)' : 'var(--p-surface-700)'
+ tickLabel: 'var(--p-text-muted-color)',
+ grid: useLightMode ? 'var(--p-surface-200)' : 'var(--p-surface-800)',
+ zeroLine: useLightMode ? 'var(--p-surface-300)' : 'var(--p-surface-600)',
+ zoomFill: 'color-mix(in srgb, var(--p-primary-color) 12%, transparent)',
+ zoomStroke: 'var(--p-primary-color)',
+ hiddenTrace: useLightMode ? 'var(--p-surface-300)' : 'var(--p-surface-600)',
+ hoverGuide: 'color-mix(in srgb, var(--p-text-muted-color) 60%, transparent)'
};
});
@@ -345,6 +353,66 @@ const traceVisibilityKey = (trace: IGraphPanelPlotTrace): string | undefined =>
const traceVisibilities = vue.shallowRef([]);
+// Trace highlighting through our legend, i.e. hovering a legend item highlights its trace by fading our other traces.
+// Note #1: we keep track of the trace which legend item is hovered, so that we can highlight it once it becomes visible
+// (e.g., by clicking on its legend item) and stop highlighting it if it gets hidden or removed (e.g., when
+// runs get tracked in interactive mode, in which case its legend item may be removed without us being told
+// that it is no longer hovered).
+// Note #2: this must come before the watcher of our traces (below) since that watcher runs immediately and calls
+// highlightTrace().
+
+let hoveredTrace: number | null = null;
+let highlightedTrace: number | null = null;
+
+const seriesAlpha = (index: number): number => {
+ // Return the opacity of the given trace, depending on whether a trace is highlighted.
+
+ return highlightedTrace === null || highlightedTrace === index ? 1 : HIGHLIGHT_FADED_ALPHA;
+};
+
+const highlightTrace = (index: number | null): void => {
+ // Keep track of the trace which legend item is hovered, if any and if it still exists, and highlight it if it is
+ // visible, or stop highlighting a trace otherwise.
+
+ hoveredTrace = index !== null && index < props.data.traces.length ? index : null;
+
+ const newHighlightedTrace =
+ hoveredTrace !== null && (traceVisibilities.value[hoveredTrace] ?? true) ? hoveredTrace : null;
+
+ if (newHighlightedTrace === highlightedTrace) {
+ return;
+ }
+
+ highlightedTrace = newHighlightedTrace;
+
+ // Update the opacity of our traces and redraw our plot (without rebuilding the paths of our traces).
+
+ const currentPlot = plot;
+
+ if (!currentPlot) {
+ return;
+ }
+
+ for (let i = 1; i < currentPlot.series.length; ++i) {
+ const plotSeries = currentPlot.series[i];
+
+ if (plotSeries) {
+ plotSeries.alpha = seriesAlpha(i - 1);
+ }
+ }
+
+ currentPlot.redraw(false);
+};
+
+const onLegendPointerEnter = (event: PointerEvent, index: number): void => {
+ // Highlight the trace of the given legend item, but only if it is hovered using a mouse or a pen, i.e. not if it is
+ // touched since a touch only "hovers" a legend item while it is being touched, so our traces would only flash.
+
+ if (event.pointerType !== 'touch') {
+ highlightTrace(index);
+ }
+};
+
vue.watch(
() => props.data.traces,
(traces: IGraphPanelPlotTrace[], oldTraces: IGraphPanelPlotTrace[] | undefined) => {
@@ -365,6 +433,11 @@ vue.watch(
return key ? (previousVisibilityByKey.get(key) ?? true) : true;
});
+
+ // Highlight the trace which legend item is hovered, if any, if it still exists and is visible, or stop highlighting
+ // it otherwise (see highlightTrace()).
+
+ highlightTrace(hoveredTrace);
},
{ immediate: true }
);
@@ -398,7 +471,8 @@ const legendItems = vue.computed(() => {
.sort((item1: ILegendItem, item2: ILegendItem) => item1.legendRank - item2.legendRank || item1.index - item2.index);
});
-// Our plotting area, which we use to position our axis titles, axis draggers, zoom box, hover label, and legend.
+// Our plotting area, which we use to position our axis titles, axis draggers, zoom box, hover guide line, hover points,
+// hover tooltip, and legend.
interface IPlotRect {
left: number;
@@ -453,7 +527,8 @@ const measureLegendWidth = (): number => {
};
const syncLegendWidth = (): void => {
- // Get our plot to recompute its layout if the width of our legend has changed since it is part of our right margin.
+ // Get our plot to recompute its layout if the width of our legend has changed since our legend is part of our right
+ // margin.
const newLegendWidth = measureLegendWidth();
@@ -468,10 +543,16 @@ const syncLegendWidth = (): void => {
const setTraceVisibilities = (visibilities: boolean[]): void => {
// Set the visibility of our traces, both in our legend and in our plot.
- // Note: uPlot autoranges our axes again when the visibility of a trace changes.
+ // Note: uPlot autoranges our axes again when the visibility of a trace changes, in which case we animate our axes
+ // that are not zoomed from their current range to their new (natural) range (see animateScales()).
traceVisibilities.value = visibilities;
+ // Highlight the trace which legend item is hovered, if any, if it is now visible, or stop highlighting it if it is now
+ // hidden (see highlightTrace()).
+
+ highlightTrace(hoveredTrace);
+
// Update the visibility of our traces in our plot, if needed.
const currentPlot = plot;
@@ -480,17 +561,53 @@ const setTraceVisibilities = (visibilities: boolean[]): void => {
return;
}
- currentPlot.batch(() => {
- visibilities.forEach((visible: boolean, index: number) => {
- const plotSeries = currentPlot.series[index + 1];
+ // Determine the traces which visibility has changed in our plot, if any, and leave if there are none (so that we don't
+ // animate our axes for nothing).
- if (plotSeries && plotSeries.show !== visible) {
- currentPlot.setSeries(index + 1, {
- show: visible
- });
- }
- });
+ const seriesIndices: number[] = [];
+
+ visibilities.forEach((visible: boolean, index: number) => {
+ const plotSeries = currentPlot.series[index + 1];
+
+ if (plotSeries && plotSeries.show !== visible) {
+ seriesIndices.push(index + 1);
+ }
+ });
+
+ if (!seriesIndices.length) {
+ return;
+ }
+
+ // Keep our axes that are not zoomed at their current range while updating the visibility of our traces (by making
+ // them use their current range as their animated range, see scaleRange()), so that we can then animate them to their
+ // new range.
+
+ const axisKeys = (['x', 'y'] as AxisKey[]).filter((axisKey: AxisKey) => !zoomRanges[axisKey]);
+
+ for (const axisKey of axisKeys) {
+ const range = currentRange(axisKey);
+
+ if (range) {
+ animatedRanges[axisKey] = range;
+ }
+ }
+
+ currentPlot.batch(() => {
+ for (const seriesIndex of seriesIndices) {
+ currentPlot.setSeries(seriesIndex, {
+ show: visibilities[seriesIndex - 1]
+ });
+ }
});
+
+ // Animate our axes that are not zoomed to their new range.
+
+ if (axisKeys.length) {
+ animateScales(
+ Object.fromEntries(axisKeys.map((axisKey: AxisKey) => [axisKey, null])) as Partial>,
+ ZOOM_ANIMATION_DURATION
+ );
+ }
};
interface ILegendToggle {
@@ -619,6 +736,10 @@ const onContextMenu = (event: MouseEvent): void => {
event.preventDefault();
+ // Hide our hover tooltip, if any, so that it doesn't get in the way of our context menu.
+
+ hover.value = null;
+
// Keep track of where our context menu was requested, so that we can zoom in/out around that position (see zoomIn()
// and zoomOut()).
// Note: we only consider the position along an axis if it is within our plotting area along that axis (e.g., not if
@@ -659,7 +780,8 @@ const onContextMenu = (event: MouseEvent): void => {
contextMenuRef.value?.show(event);
};
-// Copy and export, like Plotly, i.e. an image of our plot and of our legend (but not of our hover label or zoom box).
+// Copy and export, like Plotly, i.e. an image of our plot and of our legend (but not of what we show when hovering or
+// zooming, i.e. our hover tooltip, guide line, and highlighted points, and our zoom box).
// Note: we build an SVG document from what we currently show, which we either export as is or rasterise. For our axis
// titles and legend, we use where the browser renders their text, so that any HTML they contain (e.g.,
// subscripts and superscripts) gets rendered as it is in our panel.
@@ -823,6 +945,11 @@ const svgLegend = (origin: DOMRect, scale: number): string => {
''
];
+ // Like the browser, we snap the swatch and marker of our legend items to whole (scaled) pixels, so that they are crisp
+ // and where the browser renders them.
+
+ const snap = (value: number): number => Math.round(scale * value) / scale;
+
Array.from(legend.children).forEach((legendItemElement: Element, index: number) => {
// Make sure that we have a legend item to export.
@@ -832,31 +959,38 @@ const svgLegend = (origin: DOMRect, scale: number): string => {
return;
}
- res.push(legendItem.visible ? '' : '');
+ const opacity = getComputedStyle(legendItemElement).opacity;
- // Add the swatch of our legend item, if it has one.
+ res.push(opacity === '1' ? '' : ``);
- const swatch = legendItemElement.querySelector('.legend-swatch')?.getBoundingClientRect();
+ // Add the swatch of our legend item, if it has one.
- if (swatch) {
- // Note: like the browser, we snap our swatch to whole (scaled) pixels, so that it is crisp.
+ const swatchElement = legendItemElement.querySelector('.legend-swatch');
+ const swatch = swatchElement?.getBoundingClientRect();
+ const swatchStyle = swatchElement ? getComputedStyle(swatchElement) : undefined;
- const snap = (value: number): number => Math.round(scale * value) / scale;
+ if (swatch && swatchStyle) {
const swatchLeft = snap(swatch.left - origin.left);
const swatchTop = snap(swatch.top - origin.top);
res.push(
- ``
+ ``
);
}
// Add the marker of our legend item, if it has one.
- const marker = legendItemElement.querySelector('.legend-marker')?.getBoundingClientRect();
+ const markerElement = legendItemElement.querySelector('.legend-marker');
+ const marker = markerElement?.getBoundingClientRect();
+
+ if (markerElement && marker) {
+ const markerLeft = snap(marker.left - origin.left);
+ const markerTop = snap(marker.top - origin.top);
+ const markerRight = snap(marker.right - origin.left);
+ const markerBottom = snap(marker.bottom - origin.top);
- if (marker) {
res.push(
- ``
+ ``
);
}
@@ -911,10 +1045,9 @@ const svgDocument = (scale: number = 1): ISvgDocument => {
``
];
- // Our minor and major grid lines, and our zero lines, which get drawn in that order on uPlot's canvas (see
- // drawMinorGrid() and drawZeroLines()).
- // Note: uPlot draws all our major grid lines, including one that is at the right (or bottom) edge of our plotting
- // area, i.e. that is just outside of it once aligned (if our line width is an odd number of pixels).
+ // Our grid lines and zero lines, which get drawn in that order on uPlot's canvas (see drawZeroLines()).
+ // Note: uPlot draws all our grid lines, including one that is at the right (or bottom) edge of our plotting area,
+ // i.e. that is just outside of it once aligned (if our line width is an odd number of pixels).
const [xAxis, yAxis] = u.axes as unknown as IAxisInternals[];
const xSplits = xAxis?._splits ?? [];
@@ -928,11 +1061,7 @@ const svgDocument = (scale: number = 1): ISvgDocument => {
return svgPathElement(path, `stroke="${colors.cssColor(color)}" stroke-width="${lineWidth(scale)}" fill="none"`);
};
- res.push(
- lines(themeColors.value.minorGrid, axisTicks.x?.minorTicks ?? [], axisTicks.y?.minorTicks ?? []),
- lines(themeColors.value.grid, xSplits, ySplits, false),
- lines(themeColors.value.zeroLine, [0], [0])
- );
+ res.push(lines(themeColors.value.grid, xSplits, ySplits, false), lines(themeColors.value.zeroLine, [0], [0]));
// Our visible traces and their markers, if any, which uPlot draws in that order, trace by trace.
@@ -976,7 +1105,7 @@ const svgDocument = (scale: number = 1): ISvgDocument => {
);
res.push(
- `${svgTickLabels.join('')}`
+ `${svgTickLabels.join('')}`
);
// Our axis titles and legend, which we render on top of our plot.
@@ -1192,8 +1321,8 @@ const exportToCsv = async (): Promise => {
console.warn('OpenCOR: failed to export to CSV:', common.formatError(error));
} finally {
- // Hide the progress message. If the export succeeded then delay briefly so that the user can see that we reached
- // 100%. Otherwise, hide immediately to avoid blocking the UI unnecessarily.
+ // Hide the progress message. If the export succeeded, then delay briefly so that the user can see that we reached
+ // 100%. Otherwise, hide it immediately to avoid blocking the UI unnecessarily.
if (successfulExport) {
await common.sleep(LONG_DELAY);
@@ -1292,7 +1421,7 @@ const autoRange = (u: uPlot, axisKey: AxisKey, min: number, max: number): Range
// Note: the minimum and maximum extrema of a trace with markers are also within the range of our traces, but they get
// padded more, so we consider them separately.
- // Make sure that our range is not zero-width, since uPlot can't handle it.
+ // Make sure that our range is not zero-width since uPlot can't handle it.
if (min === max) {
return [min - 1, max + 1];
@@ -1350,74 +1479,92 @@ let zoomTitles: string | undefined;
// along that axis gets reset) or when our axis titles change (i.e. when we are plotting something else, see
// updatePlot()).
-const scaleRange = (axisKey: AxisKey): uPlot.Range.Function => {
- return (u: uPlot, dataMin: number | null, dataMax: number | null): Range => {
- // If the user zoomed into a range for the axis, then we use it.
+const naturalRange = (u: uPlot, axisKey: AxisKey, dataMin: number | null, dataMax: number | null): Range => {
+ // Return the natural range of the given axis, i.e. the given range for the axis, if any, or the autorange of the
+ // given data range otherwise (or of our data if no data range is given).
- const zoomRange = zoomRanges[axisKey];
+ // If a (finite) range was given for the axis, then we use it.
- if (zoomRange) {
- return [...zoomRange];
+ const range = axisKey === 'x' ? props.data.xAxisRange : props.data.yAxisRange;
+
+ if (range && Number.isFinite(range[0]) && Number.isFinite(range[1])) {
+ // Make sure that the given range is in increasing order (e.g., if our ending point is before our starting point)
+ // and has a non-zero width (e.g., if our starting and ending points are the same) since uPlot can handle neither.
+ // Note: like Plotly, we widen a zero-width range by 1 or 1ppm, whichever is bigger.
+
+ const min = Math.min(range[0], range[1]);
+ const max = Math.max(range[0], range[1]);
+
+ if (min === max) {
+ const delta = Math.max(1, 1e-6 * Math.abs(min));
+
+ return [min - delta, max + delta];
}
- // If a (finite) range was given for the axis, then we use it.
+ return [min, max];
+ }
+
+ // No range was given, so we use the range of our data.
+ // Note: uPlot's range of our data may not be finite, e.g., if a trace starts with a NaN value (uPlot then never
+ // moves away from it) or has an infinite value. In that case, we determine the range of our data ourselves,
+ // ignoring non-finite values.
- const range = axisKey === 'x' ? props.data.xAxisRange : props.data.yAxisRange;
+ let min = dataMin;
+ let max = dataMax;
- if (range && Number.isFinite(range[0]) && Number.isFinite(range[1])) {
- // Make sure that the given range is in increasing order (e.g., if our ending point is before our starting point)
- // and has a non-zero width (e.g., if our starting and ending points are the same) since uPlot can handle neither.
- // Note: like Plotly, we widen a zero-width range by 1 or 1ppm, whichever is bigger.
+ if (min === null || max === null || !Number.isFinite(min) || !Number.isFinite(max)) {
+ const finiteRange = finiteDataRange(u, axisKey);
- const min = Math.min(range[0], range[1]);
- const max = Math.max(range[0], range[1]);
+ if (!finiteRange) {
+ // No (finite) data, so if some of our traces are hidden (e.g., the user hid all of them using our legend), then
+ // we keep our last autorange (so that the user sees the same view when showing some traces again). Otherwise,
+ // we use Plotly's default range.
- if (min === max) {
- const delta = Math.max(1, 1e-6 * Math.abs(min));
+ const lastAutoRange = lastAutoRanges[axisKey];
- return [min - delta, max + delta];
+ if (lastAutoRange && u.series.some((series: uPlot.Series, index: number) => index > 0 && !series.show)) {
+ return [...lastAutoRange];
}
- return [min, max];
+ return axisKey === 'x' ? [...DEFAULT_X_RANGE] : [...DEFAULT_Y_RANGE];
}
- // No range was given, so we use the range of our data.
- // Note: uPlot's range of our data may not be finite, e.g., if a trace starts with a NaN value (uPlot then never
- // moves away from it) or has an infinite value. In that case, we determine the range of our data ourselves,
- // ignoring non-finite values.
+ [min, max] = finiteRange;
+ }
- let min = dataMin;
- let max = dataMax;
+ // Autorange our axis and keep track of it.
+ // Note: we keep track of it here rather than rely on uPlot's scales since our plot may get recreated (e.g., when the
+ // theme changes) while all our traces are hidden.
- if (min === null || max === null || !Number.isFinite(min) || !Number.isFinite(max)) {
- const finiteRange = finiteDataRange(u, axisKey);
+ const res = autoRange(u, axisKey, min, max);
- if (!finiteRange) {
- // No (finite) data, so if some of our traces are hidden (e.g., the user hid all of them using our legend), then
- // we keep our last autorange (so that the user sees the same view when showing some traces again). Otherwise,
- // we use Plotly's default range.
+ lastAutoRanges[axisKey] = res;
- const lastAutoRange = lastAutoRanges[axisKey];
+ return [...res];
+};
- if (lastAutoRange && u.series.some((series: uPlot.Series, index: number) => index > 0 && !series.show)) {
- return [...lastAutoRange];
- }
+const scaleRange = (axisKey: AxisKey): uPlot.Range.Function => {
+ return (u: uPlot, dataMin: number | null, dataMax: number | null): Range => {
+ // If the axis is being animated, then we use its animated range (e.g., if our data gets updated while we are
+ // animating a zoom, see animateScales()).
- return axisKey === 'x' ? [...DEFAULT_X_RANGE] : [...DEFAULT_Y_RANGE];
- }
+ const animatedRange = animatedRanges[axisKey];
- [min, max] = finiteRange;
+ if (animatedRange) {
+ return [...animatedRange];
}
- // Autorange our axis and keep track of it.
- // Note: we keep track of it here rather than rely on uPlot's scales since our plot may get recreated (e.g., when the
- // theme changes) while all our traces are hidden.
+ // If the user zoomed into a range for the axis, then we use it.
+
+ const zoomRange = zoomRanges[axisKey];
- const res = autoRange(u, axisKey, min, max);
+ if (zoomRange) {
+ return [...zoomRange];
+ }
- lastAutoRanges[axisKey] = res;
+ // Use the natural range of the axis.
- return [...res];
+ return naturalRange(u, axisKey, dataMin, dataMax);
};
};
@@ -1436,8 +1583,8 @@ const setScales = (ranges: Partial>): void => {
// Set the scales of our plot to the given ranges with a null range meaning that the corresponding axis gets
// autoranged (see scaleRange()).
// Note #1: uPlot calls our range function when the min/max of a scale is null, hence the cast.
- // Note #2: in mode 2, uPlot only invalidates the paths of our traces when our Y scale changes, but it also does so
- // whenever our X scale is set, so our traces always get redrawn.
+ // Note #2: in mode 2, uPlot invalidates the paths of our traces when our Y scale changes and whenever our X scale is
+ // set, so our traces always get redrawn.
const currentPlot = plot;
@@ -1459,9 +1606,184 @@ const setScales = (ranges: Partial>): void => {
});
};
-const zoomTo = (ranges: Partial>): void => {
- // Zoom into the given ranges, but only if they are all valid (e.g., not if we zoomed in so much that the two ends of
- // a range are the same number).
+// Zoom animations, i.e. our ranges smoothly go from their current values to their new values (or their natural values
+// if they get reset) rather than jump to them.
+// Note #1: while animating, our range function uses our animated ranges (see scaleRange()), so that an update of our
+// data (e.g., while a simulation is running) doesn't interfere with our animation.
+// Note #2: an update of our data may, however, change the natural range of an axis that gets reset, so we update its
+// target whenever our data changes (see stepRangeAnimation()), so that it ends up at its natural range rather
+// than jump to it once we are done animating.
+// Note #3: we don't animate if the user prefers reduced motion.
+
+interface IRangeAnimation {
+ from: Partial>;
+ to: Partial>;
+ final: Partial>;
+ data: uPlot.AlignedData;
+ start: number;
+ duration: number;
+}
+
+let rangeAnimation: IRangeAnimation | undefined;
+let rangeAnimationRafId: number | undefined;
+let animatedRanges: Partial> = {};
+
+const targetRange = (axisKey: AxisKey): Range | undefined => {
+ // Return the range that the given axis has or will have once we are done animating it.
+
+ return rangeAnimation?.to[axisKey] ?? currentRange(axisKey);
+};
+
+const animateScales = (final: Partial>, duration: number): void => {
+ // Animate the scales of our plot from their current ranges to the given ones (with a null range meaning that the
+ // corresponding axis gets autoranged, see setScales()) over the given duration, carrying on with the axes that we may
+ // already be animating.
+
+ // Make sure that we have a plot to animate.
+
+ const u = plot;
+
+ if (!u) {
+ return;
+ }
+
+ // Merge our final ranges with the final ranges of our current animation, if any, so that we don't lose any axes that
+ // we are already animating.
+
+ const mergedFinal = { ...rangeAnimation?.final, ...final };
+
+ if (duration <= 0 || window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
+ stopRangeAnimation();
+ setScales(mergedFinal);
+
+ return;
+ }
+
+ // Determine the ranges that we are animating from and to, using our current ranges as the starting point for our
+ // animation (which, if we are already animating, are our current animated ranges, so that we carry on smoothly), and
+ // our final ranges as the ending point for our animation (with a null range meaning that the corresponding axis gets
+ // autoranged, see setScales()).
+
+ const from: Partial> = {};
+ const to: Partial> = {};
+
+ for (const axisKey of Object.keys(mergedFinal) as AxisKey[]) {
+ const range = currentRange(axisKey);
+
+ if (range) {
+ from[axisKey] = range;
+ to[axisKey] = mergedFinal[axisKey] ?? naturalRange(u, axisKey, null, null);
+ }
+ }
+
+ // Start our animation or, if we are already animating, carry on with it using our new ranges.
+
+ rangeAnimation = {
+ from,
+ to,
+ final: mergedFinal,
+ data: u.data,
+ start: performance.now(),
+ duration
+ };
+
+ rangeAnimationRafId ??= requestAnimationFrame(stepRangeAnimation);
+};
+
+const stepRangeAnimation = (now: number): void => {
+ // Move our ranges one step closer to their target, using an ease-out (cubic) curve, or finish our animation if we are
+ // done.
+
+ rangeAnimationRafId = undefined;
+
+ // Make sure that we are animating.
+
+ const animation = rangeAnimation;
+
+ if (!animation) {
+ return;
+ }
+
+ // Determine how far we are through our animation, clamping to 0% if we are before its start, and finish our animation
+ // if we have reached its end.
+
+ const progress = Math.max(0, (now - animation.start) / animation.duration);
+
+ if (progress >= 1) {
+ finishRangeAnimation();
+
+ return;
+ }
+
+ // Update the target of our axes that get reset if our data has changed since we last determined it (e.g., while a
+ // simulation is running).
+ // Note: uPlot replaces its data whenever it is given new data (see updatePlot()), so we can just compare references.
+
+ const u = plot;
+
+ if (u && u.data !== animation.data) {
+ animation.data = u.data;
+
+ for (const axisKey of Object.keys(animation.to) as AxisKey[]) {
+ if (animation.final[axisKey] === null) {
+ animation.to[axisKey] = naturalRange(u, axisKey, null, null);
+ }
+ }
+ }
+
+ // Move our ranges one step closer to their target.
+
+ const easedProgress = 1 - (1 - progress) ** 3;
+
+ animatedRanges = {};
+
+ for (const axisKey of Object.keys(animation.to) as AxisKey[]) {
+ const from = animation.from[axisKey];
+ const to = animation.to[axisKey];
+
+ if (from && to) {
+ animatedRanges[axisKey] = [
+ from[0] + easedProgress * (to[0] - from[0]),
+ from[1] + easedProgress * (to[1] - from[1])
+ ];
+ }
+ }
+
+ // Update our scales to our new ranges.
+
+ setScales(animatedRanges);
+
+ rangeAnimationRafId = requestAnimationFrame(stepRangeAnimation);
+};
+
+const stopRangeAnimation = (): void => {
+ // Stop our animation, if any, leaving our ranges as they currently are.
+
+ if (rangeAnimationRafId !== undefined) {
+ cancelAnimationFrame(rangeAnimationRafId);
+
+ rangeAnimationRafId = undefined;
+ }
+
+ rangeAnimation = undefined;
+ animatedRanges = {};
+};
+
+const finishRangeAnimation = (): void => {
+ // Finish our animation, if any, i.e. set our ranges to their final values.
+
+ const animation = rangeAnimation;
+
+ stopRangeAnimation();
+
+ if (animation) {
+ setScales(animation.final);
+ }
+};
+
+const zoomTo = (ranges: Partial>, duration: number = 0): void => {
+ // Zoom into the given ranges, animating our zoom over the given duration, but only if they are all valid (e.g., not
+ // if we zoomed in so much that the two ends of a range are the same number).
for (const range of Object.values(ranges)) {
if (!Number.isFinite(range[0]) || !Number.isFinite(range[1]) || range[0] >= range[1]) {
@@ -1471,26 +1793,40 @@ const zoomTo = (ranges: Partial>): void => {
zoomRanges = { ...zoomRanges, ...ranges };
- setScales(ranges);
+ if (duration > 0) {
+ animateScales(ranges, duration);
+ } else {
+ finishRangeAnimation();
+ setScales(ranges);
+ }
};
-const zoomBy = (factor: number, axisKeys: AxisKey[], centerFractions: Partial>): void => {
+const zoomBy = (
+ factor: number,
+ axisKeys: AxisKey[],
+ centerFractions: Partial>,
+ duration: number
+): void => {
// Scale the range of the given axes by the given factor around the given fractions of their range (or their middle
- // for an axis without a fraction).
+ // for an axis without a fraction), animating our zoom over the given duration.
+ // Note: we scale the range that an axis will have once we are done animating it (rather than its current range), so
+ // that zooming several times in a row (e.g., using the mouse wheel) is not affected by our animation. However,
+ // we zoom around what is currently under the mouse pointer.
const ranges: Partial> = {};
for (const axisKey of axisKeys) {
const range = currentRange(axisKey);
+ const target = targetRange(axisKey);
- if (range) {
+ if (range && target) {
const center = range[0] + (range[1] - range[0]) * (centerFractions[axisKey] ?? 0.5);
- ranges[axisKey] = [center + factor * (range[0] - center), center + factor * (range[1] - center)];
+ ranges[axisKey] = [center + factor * (target[0] - center), center + factor * (target[1] - center)];
}
}
- zoomTo(ranges);
+ zoomTo(ranges, duration);
};
let contextMenuCenterFractions: Partial> = {};
@@ -1498,17 +1834,18 @@ let contextMenuCenterFractions: Partial> = {};
const zoomIn = (): void => {
// Zoom in around where our context menu was requested (see onContextMenu()).
- zoomBy(ZOOM_FACTOR, ['x', 'y'], contextMenuCenterFractions);
+ zoomBy(ZOOM_FACTOR, ['x', 'y'], contextMenuCenterFractions, ZOOM_ANIMATION_DURATION);
};
const zoomOut = (): void => {
// Zoom out around where our context menu was requested (see onContextMenu()).
- zoomBy(1 / ZOOM_FACTOR, ['x', 'y'], contextMenuCenterFractions);
+ zoomBy(1 / ZOOM_FACTOR, ['x', 'y'], contextMenuCenterFractions, ZOOM_ANIMATION_DURATION);
};
const resetZoom = (axisKeys: AxisKey[] = ['x', 'y']): void => {
- // Like Plotly, reset the zoom of the given axes, i.e. go back to the given ranges, if any, or autorange the axes.
+ // Like Plotly, reset the zoom of the given axes, i.e. go back to the given ranges, if any, or autorange the axes, and
+ // animate our reset.
const ranges: Partial> = {};
@@ -1518,7 +1855,7 @@ const resetZoom = (axisKeys: AxisKey[] = ['x', 'y']): void => {
ranges[axisKey] = null;
}
- setScales(ranges);
+ animateScales(ranges, ZOOM_ANIMATION_DURATION);
};
// Mouse interaction, like with Plotly:
@@ -1594,8 +1931,11 @@ const draggedAxes = (type: DragType): AxisKey[] => {
type ZoomMode = '' | 'x' | 'y' | 'xy';
interface IZoomBox {
- path: string;
- corners: string;
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ strokeWidth: number;
}
interface IBox {
@@ -1635,37 +1975,6 @@ let dragRafId: number | undefined;
let pendingWheelZoom: IWheelZoom | undefined;
let wheelRafId: number | undefined;
-const xCorners = (box: IBox, y0: number): string => {
- // Like Plotly, the "corners" of a zoom box along our X axis.
-
- return (
- `M${box.left - 0.5},${y0 - MIN_ZOOM - 0.5}h-3v${2 * MIN_ZOOM + 1}h3Z` +
- `M${box.right + 0.5},${y0 - MIN_ZOOM - 0.5}h3v${2 * MIN_ZOOM + 1}h-3Z`
- );
-};
-
-const yCorners = (box: IBox, x0: number): string => {
- // Like Plotly, the "corners" of a zoom box along our Y axis.
-
- return (
- `M${x0 - MIN_ZOOM - 0.5},${box.top - 0.5}v-3h${2 * MIN_ZOOM + 1}v3Z` +
- `M${x0 - MIN_ZOOM - 0.5},${box.bottom + 0.5}v3h${2 * MIN_ZOOM + 1}v-3Z`
- );
-};
-
-const xyCorners = (box: IBox): string => {
- // Like Plotly, the corners of a zoom box along both of our axes.
-
- const length = Math.floor(0.5 * Math.min(box.bottom - box.top, box.right - box.left, MIN_ZOOM));
-
- return (
- `M${box.left - 3.5},${box.top - 0.5 + length}h3v${-length}h${length}v-3h-${length + 3}Z` +
- `M${box.right + 3.5},${box.top - 0.5 + length}h-3v${-length}h${-length}v-3h${length + 3}Z` +
- `M${box.right + 3.5},${box.bottom + 0.5 - length}h-3v${length}h${-length}v3h${length + 3}Z` +
- `M${box.left - 3.5},${box.bottom + 0.5 - length}h3v${length}h${length}v3h-${length + 3}Z`
- );
-};
-
const plotPosition = (
event: MouseEvent,
rect: IPlotRect
@@ -1684,7 +1993,8 @@ const plotPosition = (
};
const dZoom = (fraction: number): number => {
- // Like Plotly, the (non-linear) scaling of a range when moving one of its ends by the given fraction of its length.
+ // Like Plotly, return the (non-linear) scaling of a range when moving one of its ends by the given fraction of its
+ // length.
return 1 - (fraction >= 0 ? Math.min(fraction, 0.9) : 1 / (1 / Math.max(fraction, -0.3) + 3.222));
};
@@ -1753,6 +2063,10 @@ const onDragStart = (event: MouseEvent, type: DragType): void => {
applyWheelZoom();
}
+ // Finish our zoom animation, if any, so that our drag is based on our final ranges.
+
+ finishRangeAnimation();
+
// Start dragging, but only with the main mouse button and if our plot has been laid out.
const rect = plotRect.value;
@@ -1872,7 +2186,6 @@ const onDragMove = (event: MouseEvent): void => {
const dx = Math.abs(x1 - x0);
const dy = Math.abs(y1 - y0);
const box = { left: Math.min(x0, x1), right: Math.max(x0, x1), top: Math.min(y0, y1), bottom: Math.max(y0, y1) };
- let corners = '';
if (dy < Math.min(Math.max(0.6 * dx, MIN_DRAG), MIN_ZOOM)) {
if (dx < MIN_DRAG) {
@@ -1883,28 +2196,40 @@ const onDragMove = (event: MouseEvent): void => {
currentDrag.zoomMode = 'x';
box.top = 0;
box.bottom = rect.height;
- corners = xCorners(box, y0);
}
} else if (dx < Math.min(0.6 * dy, MIN_ZOOM)) {
currentDrag.zoomMode = 'y';
box.left = 0;
box.right = rect.width;
- corners = yCorners(box, x0);
} else {
currentDrag.zoomMode = 'xy';
- corners = xyCorners(box);
}
currentDrag.box = box;
- // Update our zoom box, which is shown on top of our plot.
-
- zoomBox.value = {
- path:
- `M0,0H${rect.width}V${rect.height}H0V0` +
- `M${box.left},${box.top}v${box.bottom - box.top}h${box.right - box.left}v-${box.bottom - box.top}h-${box.right - box.left}Z`,
- corners
- };
+ // Update our zoom box, which is shown on top of our plot (unless we haven't dragged far enough to zoom yet).
+ // Note: like our grid and zero lines (see linesPath()), our zoom box is snapped to whole device pixels and its border
+ // is a whole number of device pixels wide (see lineWidth()), so that it is crisp whatever our pixel ratio (e.g.,
+ // 125%). Its border is also drawn inside our zoom box, which itself is kept within our plotting area.
+
+ if (currentDrag.zoomMode) {
+ const pxRatio = dependencies._uPlot.pxRatio as number;
+ const strokeWidth = lineWidth(pxRatio);
+ const left = Math.round(box.left * pxRatio);
+ const top = Math.round(box.top * pxRatio);
+ const right = Math.min(Math.round(box.right * pxRatio), Math.round(rect.width * pxRatio));
+ const bottom = Math.min(Math.round(box.bottom * pxRatio), Math.round(rect.height * pxRatio));
+
+ zoomBox.value = {
+ x: (left + 0.5 * strokeWidth) / pxRatio,
+ y: (top + 0.5 * strokeWidth) / pxRatio,
+ width: Math.max(0, right - left - strokeWidth) / pxRatio,
+ height: Math.max(0, bottom - top - strokeWidth) / pxRatio,
+ strokeWidth: strokeWidth / pxRatio
+ };
+ } else {
+ zoomBox.value = null;
+ }
};
const onDragEnd = (event: MouseEvent): void => {
@@ -1977,10 +2302,10 @@ const onDragEnd = (event: MouseEvent): void => {
];
}
- // Zoom into the given ranges, but only if they are all valid (e.g., not if we zoomed in so much that the two ends of
- // a range are the same number).
+ // Zoom into the given ranges (animating our zoom), but only if they are all valid (e.g., not if we zoomed in so much
+ // that the two ends of a range are the same number).
- zoomTo(ranges);
+ zoomTo(ranges, ZOOM_ANIMATION_DURATION);
};
const onDragKeyDown = (event: KeyboardEvent): void => {
@@ -2176,7 +2501,7 @@ const closeRangeEditor = (apply: boolean): void => {
newRange[editor.end] = value;
- zoomTo({ [editor.axisKey]: newRange });
+ zoomTo({ [editor.axisKey]: newRange }, ZOOM_ANIMATION_DURATION);
};
const onRangeEditorKeyDown = (event: KeyboardEvent): void => {
@@ -2210,7 +2535,7 @@ const applyWheelZoom = (): void => {
pendingWheelZoom = undefined;
- zoomBy(factor, axisKeys, centerFractions);
+ zoomBy(factor, axisKeys, centerFractions, WHEEL_ZOOM_ANIMATION_DURATION);
};
const onWheel = (event: WheelEvent, type: DragType): void => {
@@ -2221,7 +2546,7 @@ const onWheel = (event: WheelEvent, type: DragType): void => {
return;
}
- // Prevent the default behavior of the mouse wheel, which is to scroll the page.
+ // Prevent the default behaviour of the mouse wheel, which is to scroll the page.
event.preventDefault();
@@ -2285,7 +2610,7 @@ const stopDrag = (): void => {
}
drag = undefined;
- hoverLabel.value = null;
+ hover.value = null;
dragCursor.value = null;
zoomBox.value = null;
};
@@ -2427,14 +2752,13 @@ const formatNumber = (value: number, format: INumberFormat): string => {
return isNegative ? MINUS_SIGN + res : res;
};
-// Ticks, like Plotly, i.e. major ticks that are multiples of a "nice" spacing (which depends on the length of our axis)
-// and minor ticks (only used for our minor grid lines) that split a major tick spacing in four or five. Unlike Plotly,
-// though, we use fewer major ticks if their labels would otherwise overlap (Plotly rotates its X tick labels instead
-// and lets its Y tick labels overlap).
+// Ticks, like Plotly, i.e. ticks that are multiples of a "nice" spacing (which depends on the length of our axis).
+// Unlike Plotly, though, we use fewer ticks if their labels would otherwise overlap (Plotly rotates its X tick labels
+// instead and lets its Y tick labels overlap), and we don't have minor ticks (and therefore minor grid lines), so that
+// our grid is lighter.
interface IAxisTicks {
format: INumberFormat;
- minorTicks: number[];
}
const axisTicks: Partial> = {};
@@ -2467,21 +2791,15 @@ const ticksBetween = (min: number, max: number, dtick: number): number[] => {
return res;
};
-const isMultiple = (bigger: number, smaller: number): boolean => {
- // Return whether the given bigger number is (almost) a multiple of the given smaller number.
-
- return Math.abs(((bigger / smaller + 0.5) % 1) - 0.5) < 0.001;
-};
-
const axisSplits = (axisKey: AxisKey): uPlot.Axis.Splits => {
- // Return a function that computes the splits (i.e. the positions of our major ticks) for the given axis.
+ // Return a function that computes the splits (i.e. the positions of our ticks) for the given axis.
return (u: uPlot, _axisIndex: number, min: number, max: number): number[] => {
- // Major ticks, like Plotly, i.e. we aim for one tick every 80 (X axis) or 40 (Y axis) pixels with at least four and
- // at most nine tick spacings.
+ // Like Plotly, we aim for one tick every 80 (X axis) or 40 (Y axis) pixels with at least four and at most nine tick
+ // spacings.
const length = (axisKey === 'x' ? u.bbox.width : u.bbox.height) / (dependencies._uPlot.pxRatio as number);
- const minSpacing = axisKey === 'x' ? MAJOR_TICK_MIN_X_SPACING : MAJOR_TICK_MIN_Y_SPACING;
+ const minSpacing = axisKey === 'x' ? TICK_MIN_X_SPACING : TICK_MIN_Y_SPACING;
const nbOfTicks = Math.min(Math.max(length / minSpacing, 4), 9) + 1;
const range = Math.abs(max - min);
const maxEnd = Math.max(Math.abs(min), Math.abs(max));
@@ -2496,8 +2814,7 @@ const axisSplits = (axisKey: AxisKey): uPlot.Axis.Splits => {
format: {
tickRound: 0,
exponent: 0
- },
- minorTicks: []
+ }
};
return [];
@@ -2521,7 +2838,7 @@ const axisSplits = (axisKey: AxisKey): uPlot.Axis.Splits => {
let res = 0;
for (const tick of ticks) {
- res = Math.max(res, textWidth(formatNumber(tick, format), tickFont));
+ res = Math.max(res, textWidth(formatNumber(tick, format), tickFont()));
}
return res;
@@ -2533,26 +2850,13 @@ const axisSplits = (axisKey: AxisKey): uPlot.Axis.Splits => {
format = numberFormat(dtick, maxEnd);
}
- // Minor ticks, like Plotly, i.e. we aim for five minor tick spacings per major tick spacing, making sure that our
- // major tick spacing is a multiple of our minor tick spacing.
-
- let minorDtick = roundDTick((0.99 * dtick) / NB_OF_MINOR_TICKS);
-
- if (!isMultiple(dtick, minorDtick)) {
- minorDtick = Math.abs(dtick / minorDtick - 2.5) < 0.001 * 2.5 ? 0.5 * dtick : dtick;
- }
-
- // Keep track of the format of our tick labels and of our minor ticks (excluding those that are also major ticks).
+ // Keep track of the format of our tick labels.
axisTicks[axisKey] = {
- format,
- minorTicks:
- minorDtick < dtick
- ? ticksBetween(min, max, minorDtick).filter((tick: number) => !isMultiple(Math.abs(tick) || dtick, dtick))
- : []
+ format
};
- // Return our major ticks.
+ // Return our ticks.
return ticks;
};
@@ -2605,7 +2909,7 @@ interface ITickLabel {
}
const tickLabels = (u: uPlot, area: IPathArea): ITickLabel[] => {
- // Return our tick labels and where to draw them (in pixels of the given area), i.e., like Plotly, with their
+ // Return our tick labels and where to draw them (in pixels of the given area), i.e. like Plotly, with their
// alphabetic baseline X_TICK_LABEL_BASELINE pixels below our plotting area for our X axis and, for our Y axis,
// Y_TICK_LABEL_GAP pixels to the left of our plotting area and with their alphabetic baseline Y_TICK_LABEL_BASELINE
// pixels below their tick.
@@ -2663,8 +2967,8 @@ const drawTickLabels = (u: uPlot): void => {
context.save();
- context.font = `${Math.round(TICK_FONT_SIZE * area.pxRatio)}px ${FONT_FAMILY}`;
- context.fillStyle = colors.cssColor(themeColors.value.text);
+ context.font = `${Math.round(TICK_FONT_SIZE * area.pxRatio)}px ${fontFamily}`;
+ context.fillStyle = colors.cssColor(themeColors.value.tickLabel);
context.textBaseline = 'alphabetic';
for (const tickLabel of tickLabels(u, area)) {
@@ -2676,10 +2980,17 @@ const drawTickLabels = (u: uPlot): void => {
context.restore();
};
-// Text measuring, which we use to make sure that our X tick labels don't overlap, to size our Y axis, right margin, and
-// hover label, and to position the text in our exports.
+// Text measuring, which we use to make sure that our X tick labels don't overlap, to size our Y axis and right margin,
+// and to position the text in our exports.
+
+let fontFamily = 'sans-serif';
+
+const tickFont = (): string => {
+ // Return the font of our tick labels, i.e. OpenCOR's font (see createPlot()) at TICK_FONT_SIZE pixels.
+
+ return `${TICK_FONT_SIZE}px ${fontFamily}`;
+};
-const tickFont = `${TICK_FONT_SIZE}px ${FONT_FAMILY}`;
let measureContext: CanvasRenderingContext2D | null | undefined;
const textWidth = (text: string, font: string): number => {
@@ -2731,7 +3042,7 @@ const yAxisSize = (): number => {
let maxWidth = 0;
for (const value of tickValues.y) {
- maxWidth = Math.max(maxWidth, textWidth(value, tickFont));
+ maxWidth = Math.max(maxWidth, textWidth(value, tickFont()));
}
naturalYAxisSize = Math.ceil(maxWidth + Y_AXIS_EXTRA_SIZE);
@@ -2763,7 +3074,7 @@ const rightMargin = (): number => {
const lastXValue = tickValues.x[tickValues.x.length - 1];
const legendSize = props.margins ? Math.max(legendRightMargin(), props.margins.right) : legendRightMargin();
- const lastXTickLabelSize = lastXValue ? 0.5 * textWidth(lastXValue, tickFont) + LEGEND_RIGHT_MARGIN : 0;
+ const lastXTickLabelSize = lastXValue ? 0.5 * textWidth(lastXValue, tickFont()) + LEGEND_RIGHT_MARGIN : 0;
return Math.ceil(Math.max(legendSize, lastXTickLabelSize));
};
@@ -2945,24 +3256,11 @@ const drawLines = (u: uPlot, color: string, xValues: number[], yValues: number[]
const drawZeroLines = (u: uPlot): void => {
// Like Plotly, draw our zero lines.
- // Note: this gets called after uPlot draws our (major) grid lines and before it draws our traces.
+ // Note: this gets called after uPlot draws our grid lines and before it draws our traces.
drawLines(u, colors.cssColor(themeColors.value.zeroLine), [0], [0]);
};
-const drawMinorGrid = (u: uPlot): void => {
- // Draw our minor grid lines.
- // Note: this gets called before uPlot draws our (major) grid lines, so that the latter are drawn on top of the
- // former.
-
- drawLines(
- u,
- colors.cssColor(themeColors.value.minorGrid),
- axisTicks.x?.minorTicks ?? [],
- axisTicks.y?.minorTicks ?? []
- );
-};
-
// Traces and markers.
const linearPath = (
@@ -3251,24 +3549,38 @@ const setPlotSize = (u: uPlot, width: number, height: number): void => {
});
};
-// Hover functionality, like Plotly (with its default 'closest' hover mode), i.e. a label for the point that is closest
-// to the mouse pointer (within HOVER_DISTANCE pixels) with its coordinates (and the name of its trace if we have
-// several traces).
+// Hover functionality, i.e. a tooltip that lists the value of each of our visible traces at the X value that is under
+// the mouse pointer (like Plotly's 'x unified' hover mode), with a vertical guide line at that X value and the
+// corresponding points of our traces highlighted.
+// Note: a trace which X values are not sorted (e.g., a phase plot) doesn't have one value per X value, so we use its
+// point that is closest to the mouse pointer instead (like Plotly's 'closest' hover mode).
-interface IHoverLabel {
+interface IHoverPoint {
+ seriesIndex: number;
x: number;
y: number;
- anchor: 'start' | 'end';
- path: string;
- text: string;
- textX: number;
+ color: string;
+}
+
+interface IHoverRow {
+ seriesIndex: number;
name: string;
- nameX: number;
+ value: string;
color: string;
- contrastColor: string;
}
-const hoverLabel = vue.shallowRef(null);
+interface IHover {
+ clientX: number;
+ clientY: number;
+ guideX: number;
+ guideWidth: number;
+ points: IHoverPoint[];
+ x: string;
+ rows: IHoverRow[];
+ otherRows: number;
+}
+
+const hover = vue.shallowRef(null);
let hoverRafId: number | undefined;
let sortedXValues: boolean[] = [];
@@ -3292,129 +3604,98 @@ const lowerBound = (values: math.FloatArray, value: number, end: number): number
return low;
};
-const relativeLuminance = (color: string): number | undefined => {
- // Return the (WCAG) relative luminance of the given (hexadecimal) colour, if valid.
-
- const match = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(color.trim());
-
- if (!match?.[1]) {
- return undefined;
- }
-
- const hex =
- match[1].length === 3 ? [...match[1]].map((character: string) => character + character).join('') : match[1];
- const [red, green, blue] = [0, 2, 4].map((index: number) => {
- const value = Number.parseInt(hex.slice(index, index + 2), 16) / 255;
-
- return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
- }) as [number, number, number];
-
- return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
-};
+const formatHoverValue = (value: number): string => {
+ // Like Plotly, format a value in our hover tooltip using the value itself as the tick spacing and adding four digits
+ // of precision.
-const contrastRatio = (luminance1: number, luminance2: number): number => {
- // Return the (WCAG) contrast ratio between two colours of the given relative luminances.
+ const absValue = Math.abs(value) || 1;
+ const format = numberFormat(absValue, absValue);
- return (Math.max(luminance1, luminance2) + 0.05) / (Math.min(luminance1, luminance2) + 0.05);
+ return formatNumber(value, {
+ ...format,
+ tickRound: format.tickRound + 4
+ });
};
-const contrastColor = (color: string): string => {
- // Like Plotly (see its Color.contrast()), return Plotly's background colour (i.e. white) for a colour that is dark,
- // i.e. that contrasts more with Plotly's background colour than with its default line colour, and Plotly's default
- // line colour otherwise.
-
- const PLOTLY_BACKGROUND = '#fff';
- const PLOTLY_DEFAULT_LINE = '#444';
+const hoveredIndex = (
+ xValues: math.FloatArray,
+ yValues: math.FloatArray,
+ sorted: boolean,
+ left: number,
+ top: number,
+ radius: number,
+ area: IPathArea
+): number => {
+ // Return the index of the point of the given trace that gets hovered, if any, i.e. the point which X value is the
+ // closest to the mouse pointer (within HOVER_DISTANCE pixels) if its X values are sorted, or its point that is the
+ // closest to the mouse pointer (within HOVER_DISTANCE pixels) otherwise.
+ // Note: like Plotly (see its scatter hoverPoints() and Fx.getClosest()), the distance between the mouse pointer and a
+ // point is reduced by the "radius" of the point (i.e. the radius of its marker, if any, or HOVER_LINE_RADIUS
+ // pixels otherwise), with points within that radius being at a distance of zero. Also, in case of a tie, the
+ // last point wins, so that a point that is drawn on top of another one gets hovered.
- const luminance = relativeLuminance(color);
+ const { width, height, xMin, xMax, yMin, yMax } = area;
+ const xFactor = width / (xMax - xMin);
+ const yFactor = height / (yMax - yMin);
+ const nbOfPoints = Math.min(xValues.length, yValues.length);
+ let res = -1;
+ let closestDistance = HOVER_DISTANCE;
- if (luminance === undefined) {
- return PLOTLY_BACKGROUND;
- }
+ if (sorted) {
+ // Look at the points on either side of the X value that is under the mouse pointer (using a binary search),
+ // skipping any point that doesn't have finite X and Y values.
- const backgroundLuminance = relativeLuminance(PLOTLY_BACKGROUND) as number;
- const defaultLineLuminance = relativeLuminance(PLOTLY_DEFAULT_LINE) as number;
+ const index = lowerBound(xValues, xMin + left / xFactor, nbOfPoints);
- return contrastRatio(luminance, backgroundLuminance) > contrastRatio(luminance, defaultLineLuminance)
- ? PLOTLY_BACKGROUND
- : PLOTLY_DEFAULT_LINE;
-};
+ for (const i of [index - 1, index]) {
+ if (i < 0 || i >= nbOfPoints) {
+ continue;
+ }
-const hoverName = (name: string): string => {
- // Like Plotly (see its svgTextUtils.plainText()), return the given (HTML) trace name with only some basic tags (which
- // don't count towards its length) and truncated to HOVER_NAME_LENGTH characters (with an ellipsis), if needed.
+ const x = xValues[i] as number;
+ const y = yValues[i] as number;
- const HOVER_NAME_TAGS = ['br', 'sub', 'sup', 'b', 'i', 'em', 's', 'u'];
+ if (Number.isFinite(x) && Number.isFinite(y)) {
+ const distance = Math.max(Math.abs((x - xMin) * xFactor - left) - radius, 0);
- const ellipsis = '...';
- const res: string[] = [];
- let previousTag = '';
- let length = 0;
+ if (distance <= closestDistance) {
+ res = i;
+ closestDistance = distance;
+ }
+ }
+ }
- for (const part of name.split(/(<[^<>]*>)/)) {
- // Keep any part that is an HTML tag that we support, but without its attributes (since our name gets rendered using
- // v-html), and skip any other HTML tag.
+ return res;
+ }
- const tagMatch = /<(\/?)([^ >]*)(\s+(.*))?>/i.exec(part);
- const tag = tagMatch?.[2]?.toLowerCase();
+ // Look at all the points of our trace.
- if (tag) {
- if (HOVER_NAME_TAGS.includes(tag)) {
- res.push(`<${tagMatch?.[1] ?? ''}${tag}>`);
+ for (let i = 0; i < nbOfPoints; ++i) {
+ // Skip our point if its horizontal distance (less its radius) is greater than the distance of the closest point so
+ // far (or than HOVER_DISTANCE pixels if we haven't found one yet), in which case it can't be as close to the mouse
+ // pointer.
+ // Note: we negate our comparison so that a point with a NaN X value gets skipped.
- previousTag = tag;
- }
+ const dx = ((xValues[i] as number) - xMin) * xFactor - left;
+ if (!(Math.abs(dx) - radius <= closestDistance)) {
continue;
}
- // Make sure that our name doesn't exceed HOVER_NAME_LENGTH characters, truncating it if needed.
-
- if (length + part.length < HOVER_NAME_LENGTH) {
- res.push(part);
-
- length += part.length;
- } else if (length < HOVER_NAME_LENGTH) {
- const remainingLength = HOVER_NAME_LENGTH - length;
-
- if (
- previousTag &&
- (previousTag !== 'br' || remainingLength <= ellipsis.length || part.length <= ellipsis.length)
- ) {
- res.pop();
- }
+ // Keep track of our point if it is at least as close to the mouse pointer as the closest point so far (so that, in
+ // case of a tie, the last point wins).
+ // Note: a point with a NaN Y value gets skipped since its distance is NaN.
- res.push(part.slice(0, Math.max(0, remainingLength - ellipsis.length)) + ellipsis);
+ const dy = height - ((yValues[i] as number) - yMin) * yFactor - top;
+ const distance = Math.max(Math.sqrt(dx * dx + dy * dy) - radius, 0);
- break;
+ if (distance <= closestDistance) {
+ res = i;
+ closestDistance = distance;
}
-
- // Our part is some text (rather than an HTML tag), so reset our previous tag.
-
- previousTag = '';
}
- return res.join('');
-};
-
-const formatHoverValue = (value: number): string => {
- // Like Plotly, format a value in our hover label using the value itself as the tick spacing and adding four digits of
- // precision.
-
- const absValue = Math.abs(value) || 1;
- const format = numberFormat(absValue, absValue);
-
- return formatNumber(value, {
- ...format,
- tickRound: format.tickRound + 4
- });
-};
-
-const plainText = (html: string): string => {
- // Return the given HTML (e.g., a trace name) as plain text.
- // Note: the document created by DOMParser is inert, i.e. no script gets executed and no resource gets loaded.
-
- return new DOMParser().parseFromString(html, 'text/html').body.textContent ?? '';
+ return res;
};
const updateHover = (): void => {
@@ -3422,42 +3703,27 @@ const updateHover = (): void => {
const u = plot;
const rect = plotRect.value;
+ const mainDiv = mainDivRef.value;
const left = u?.cursor.left;
const top = u?.cursor.top;
- const xMin = u?.scales.x?.min;
- const xMax = u?.scales.x?.max;
- const yMin = u?.scales.y?.min;
- const yMax = u?.scales.y?.max;
+ const area = u ? pathArea(u) : undefined;
- if (
- !u ||
- !rect ||
- drag ||
- left == null ||
- top == null ||
- left < 0 ||
- top < 0 ||
- xMin == null ||
- xMax == null ||
- yMin == null ||
- yMax == null
- ) {
- hoverLabel.value = null;
+ if (!u || !rect || !mainDiv || !area || drag || left == null || top == null || left < 0 || top < 0) {
+ hover.value = null;
return;
}
- // Find the point that is closest to the mouse pointer (within HOVER_DISTANCE pixels), if any.
- // Note: like Plotly (see its scatter hoverPoints() and Fx.getClosest()), the distance between the mouse pointer and a
- // point is reduced by the "radius" of the point (i.e. the radius of its marker, if any, or HOVER_LINE_RADIUS
- // pixels otherwise), with points within that radius being at a distance of zero. Also, in case of a tie, the
- // last point wins, so that a point that is drawn on top of another one gets hovered.
+ // Determine the point of each of our visible traces that gets hovered, if any, and the X value of the one that is the
+ // closest to the mouse pointer (horizontally), which is the X value that we show in our hover tooltip.
+ // Note: we use our plotting area in CSS pixels (since the position of the mouse pointer is in CSS pixels).
- const xFactor = rect.width / (xMax - xMin);
- const yFactor = rect.height / (yMax - yMin);
- let closestDistance = HOVER_DISTANCE;
- let closestSeriesIndex = -1;
- let closestIndex = -1;
+ const cssArea: IPathArea = { ...area, ...rect, pxRatio: 1 };
+ const xFactor = cssArea.width / (cssArea.xMax - cssArea.xMin);
+ const yFactor = cssArea.height / (cssArea.yMax - cssArea.yMin);
+ const hoveredIndices = new Map();
+ let xValue = Number.NaN;
+ let xDistance = Number.POSITIVE_INFINITY;
for (let seriesIndex = 1; seriesIndex < u.series.length; ++seriesIndex) {
// Make sure that our series is visible and that it has data.
@@ -3468,93 +3734,112 @@ const updateHover = (): void => {
continue;
}
- // Determine the points that we need to look at, i.e. all of them unless our X values are sorted, in which case we
- // only look at the points that are within HOVER_DISTANCE pixels (plus their radius) horizontally (using a binary
- // search).
+ // Determine the point of our series that gets hovered, if any.
const radius = seriesHasMarkers(u, seriesIndex) ? 0.5 * MARKER_SIZE : HOVER_LINE_RADIUS;
- const maxDx = HOVER_DISTANCE + radius;
- const nbOfPoints = Math.min(xValues.length, yValues.length);
- let start = 0;
- let end = nbOfPoints;
-
- if (sortedXValues[seriesIndex - 1]) {
- start = lowerBound(xValues, xMin + (left - maxDx) / xFactor, nbOfPoints);
- end = lowerBound(xValues, xMin + (left + maxDx) / xFactor, nbOfPoints);
+ const index = hoveredIndex(xValues, yValues, !!sortedXValues[seriesIndex - 1], left, top, radius, cssArea);
+
+ if (index !== -1) {
+ const x = xValues[index] as number;
+ const distance = Math.abs((x - cssArea.xMin) * xFactor - left);
+
+ hoveredIndices.set(seriesIndex, index);
+
+ if (distance < xDistance) {
+ xValue = x;
+ xDistance = distance;
+ }
}
+ }
- // Find the point that is closest to the mouse pointer (within HOVER_DISTANCE pixels) among the points that we are
- // looking at.
+ // Make sure that some points get hovered, and hide our hover tooltip if none do.
- for (let i = start; i < end; ++i) {
- // Skip our point if its horizontal distance (less its radius) is greater than the distance of the closest point
- // so far (or than HOVER_DISTANCE pixels if we haven't found one yet), in which case it can't be as close to the
- // mouse pointer.
- // Note: we negate our comparison so that a point with a NaN X value gets skipped.
+ if (!hoveredIndices.size) {
+ hover.value = null;
- const dx = ((xValues[i] as number) - xMin) * xFactor - left;
+ return;
+ }
- if (!(Math.abs(dx) - radius <= closestDistance)) {
- continue;
- }
+ // Build our hover tooltip, listing our traces in the order of our legend, and highlight the hovered points that are
+ // within our plotting area.
- // Keep track of our point if it is at least as close to the mouse pointer as the closest point so far (so that,
- // in case of a tie, the last point wins).
- // Note: a point with a NaN Y value gets skipped since its distance is NaN.
+ const points: IHoverPoint[] = [];
+ const rows: IHoverRow[] = [];
- const dy = rect.height - ((yValues[i] as number) - yMin) * yFactor - top;
- const distance = Math.max(Math.sqrt(dx * dx + dy * dy) - radius, 0);
+ for (const legendItem of legendItems.value) {
+ const seriesIndex = legendItem.index + 1;
+ const index = hoveredIndices.get(seriesIndex);
+ const [xValues, yValues] = seriesData(u, seriesIndex) ?? [];
- if (distance <= closestDistance) {
- closestDistance = distance;
- closestSeriesIndex = seriesIndex;
- closestIndex = i;
- }
+ if (index === undefined || !xValues || !yValues) {
+ continue;
+ }
+
+ const x = ((xValues[index] as number) - cssArea.xMin) * xFactor;
+ const y = cssArea.height - ((yValues[index] as number) - cssArea.yMin) * yFactor;
+
+ if (x >= 0 && x <= cssArea.width && y >= 0 && y <= cssArea.height) {
+ points.push({ seriesIndex, x, y, color: legendItem.color });
}
+
+ rows.push({
+ seriesIndex,
+ name: legendItem.name,
+ value: formatHoverValue(yValues[index] as number),
+ color: legendItem.color
+ });
}
- // Make sure that we found a point that is close enough to the mouse pointer, and hide our hover label if we didn't.
+ // Show our hover tooltip at the mouse pointer (in CSS pixels) and our guide line at our X value.
+ // Note: like our grid and zero lines (see linesPath()), our guide line is snapped to whole device pixels and is a
+ // whole number of device pixels wide (see lineWidth()), so that it is crisp whatever our pixel ratio.
+
+ const mainDivRect = mainDiv.getBoundingClientRect();
+ const pxRatio = dependencies._uPlot.pxRatio as number;
+ const guideWidth = lineWidth(pxRatio);
+
+ hover.value = {
+ clientX: mainDivRect.left + rect.left + left,
+ clientY: mainDivRect.top + rect.top + top,
+ guideX: (Math.round((xValue - cssArea.xMin) * xFactor * pxRatio) + 0.5 * (guideWidth % 2)) / pxRatio,
+ guideWidth: guideWidth / pxRatio,
+ points,
+ x: formatHoverValue(xValue),
+ rows: rows.slice(0, HOVER_TOOLTIP_MAX_ROWS),
+ otherRows: Math.max(0, rows.length - HOVER_TOOLTIP_MAX_ROWS)
+ };
+
+ // Position our hover tooltip once it has been rendered (since its position depends on its size).
- const closestTrace = props.data.traces[closestSeriesIndex - 1];
- const [closestXValues, closestYValues] = seriesData(u, closestSeriesIndex) ?? [];
+ void vue.nextTick().then(placeHoverTooltip);
+};
+
+const placeHoverTooltip = (): void => {
+ // Make sure that our hover tooltip has been rendered and that we are still hovering.
- if (!closestTrace || !closestXValues || !closestYValues) {
- hoverLabel.value = null;
+ const tooltip = hoverTooltipRef.value;
+ const currentHover = hover.value;
+ if (!tooltip || !currentHover) {
return;
}
- // Build our label, which is to the right of our point unless there isn't enough room for it, in which case it is to
- // the left of our point.
-
- const hoverFont = `${HOVER_FONT_SIZE}px ${HOVER_FONT}`;
- const xValue = closestXValues[closestIndex] as number;
- const yValue = closestYValues[closestIndex] as number;
- const text = `(${formatHoverValue(xValue)}, ${formatHoverValue(yValue)})`;
- const name = props.data.traces.length > 1 ? hoverName(closestTrace.name) : '';
- const boxWidth = textWidth(text, hoverFont) + 2 * HOVER_TEXT_PADDING;
- const nameWidth = name ? textWidth(plainText(name), hoverFont) + 2 * HOVER_TEXT_PADDING : 0;
- // Note: our name may contain some subscripts/superscripts, which are smaller, so nameWidth may overestimate the width
- // of our name, but that's fine since we only use it to decide whether our label should be to the left of our
- // point.
- const x = rect.left + (xValue - xMin) * xFactor;
- const y = rect.top + rect.height - (yValue - yMin) * yFactor;
- const anchor = x + HOVER_ARROW_SIZE + boxWidth + nameWidth <= (mainDivRef.value?.clientWidth || 0) ? 'start' : 'end';
- const sign = anchor === 'start' ? 1 : -1;
- const halfHeight = 0.5 * HOVER_LABEL_HEIGHT;
-
- hoverLabel.value = {
- x,
- y,
- anchor,
- path: `M0,0L${sign * HOVER_ARROW_SIZE},${HOVER_ARROW_SIZE}v${halfHeight - HOVER_ARROW_SIZE}h${sign * boxWidth}v${-HOVER_LABEL_HEIGHT}H${sign * HOVER_ARROW_SIZE}V${-HOVER_ARROW_SIZE}Z`,
- text,
- textX: sign * (HOVER_ARROW_SIZE + HOVER_TEXT_PADDING),
- name,
- nameX: x + sign * (HOVER_ARROW_SIZE + boxWidth),
- color: closestTrace.color,
- contrastColor: contrastColor(closestTrace.color)
- };
+ // Place our hover tooltip to the right of the mouse pointer or, if there isn't enough room for it, to its left, and
+ // vertically centred on it, while keeping it within the window.
+
+ const { width, height } = tooltip.getBoundingClientRect();
+ const rightLeft = currentHover.clientX + HOVER_TOOLTIP_OFFSET;
+ const left =
+ rightLeft + width + HOVER_TOOLTIP_MARGIN <= window.innerWidth
+ ? rightLeft
+ : currentHover.clientX - HOVER_TOOLTIP_OFFSET - width;
+ const top = Math.min(
+ Math.max(currentHover.clientY - 0.5 * height, HOVER_TOOLTIP_MARGIN),
+ window.innerHeight - height - HOVER_TOOLTIP_MARGIN
+ );
+
+ tooltip.style.left = `${Math.max(left, HOVER_TOOLTIP_MARGIN)}px`;
+ tooltip.style.top = `${top}px`;
};
const queueHoverUpdate = (): void => {
@@ -3585,7 +3870,7 @@ const isSorted = (values: math.FloatArray): boolean => {
};
const plotData = (): uPlot.AlignedData => {
- // The data to be plotted.
+ // Return the data to be plotted.
// Note #1: we also keep track of whether the X values of our traces are sorted (see updateHover()).
// Note #2: uPlot needs at least one series, hence we use an empty one if we have no traces.
@@ -3606,7 +3891,14 @@ const createPlot = (width: number, height: number): void => {
plot?.destroy();
- // Create a series for each trace, using the trace's color for the stroke, our linear path builder, and our markers
+ // Use OpenCOR's font, i.e. the one that we inherit, for what gets drawn on uPlot's canvas (and measured using
+ // textWidth()), so that it is consistent with our axis titles and legend (and the rest of OpenCOR).
+
+ if (mainDivRef.value) {
+ fontFamily = getComputedStyle(mainDivRef.value).fontFamily;
+ }
+
+ // Create a series for each trace, using the trace's colour for the stroke, our linear path builder, and our markers
// path builder (for traces with markers).
const series: uPlot.Series[] = [{}];
@@ -3614,6 +3906,7 @@ const createPlot = (width: number, height: number): void => {
for (let i = 0; i < Math.max(1, props.data.traces.length); ++i) {
series.push({
show: traceVisibilities.value[i] ?? true,
+ alpha: seriesAlpha(i),
stroke: (_plot: uPlot, seriesIndex: number) => props.data.traces[seriesIndex - 1]?.color ?? 'transparent',
width: LINE_WIDTH,
paths: linearPaths,
@@ -3621,7 +3914,7 @@ const createPlot = (width: number, height: number): void => {
show: (u: uPlot, seriesIndex: number) => seriesHasMarkers(u, seriesIndex),
paths: markerPaths,
size: MARKER_SIZE,
- width: 0 // Note: this means that our markers are filled using the trace's color.
+ width: 0 // Note: this means that our markers are filled using the trace's colour.
},
facets: [
{ scale: 'x', auto: true },
@@ -3634,8 +3927,8 @@ const createPlot = (width: number, height: number): void => {
const axis: uPlot.Axis = {
stroke: colors.cssColor(themeColors.value.text),
- font: tickFont,
- labelFont: `${TITLE_FONT_SIZE}px ${FONT_FAMILY}`,
+ font: tickFont(),
+ labelFont: `${TITLE_FONT_SIZE}px ${fontFamily}`,
labelGap: 0,
space: 1,
ticks: {
@@ -3724,17 +4017,16 @@ const createPlot = (width: number, height: number): void => {
updatePlotRect(u);
updateMarginsAsync();
- // Update our hover label, if any, since our plot may have changed (e.g., our data got updated or we zoomed
- // in/out).
+ // Update our hover tooltip, if any, since our plot may have changed (e.g., our data got updated or we
+ // zoomed in/out).
- if (hoverLabel.value) {
+ if (hover.value) {
queueHoverUpdate();
}
}
],
setCursor: [queueHoverUpdate],
drawAxes: [drawZeroLines, drawTickLabels],
- drawClear: [drawMinorGrid],
setSize: [
(u: uPlot) => {
// Make sure that our plotting area is at least one pixel wide and high (e.g., if our container is narrower
@@ -3818,6 +4110,8 @@ const updatePlot = (): void => {
if (titles !== zoomTitles) {
zoomTitles = titles;
zoomRanges = {};
+
+ stopRangeAnimation();
}
// Create or update our plot.
@@ -3927,7 +4221,8 @@ vue.onBeforeUnmount(() => {
isUnmounted = true;
- // Stop zooming, hovering, and dragging.
+ // Stop zooming (including any zoom animation), hovering, and dragging, and close our range editor (discarding its
+ // value).
if (wheelRafId !== undefined) {
cancelAnimationFrame(wheelRafId);
@@ -3942,6 +4237,7 @@ vue.onBeforeUnmount(() => {
}
stopDrag();
+ stopRangeAnimation();
closeRangeEditor(false);
@@ -4032,8 +4328,8 @@ vue.watch(
.axis-title :deep(sub),
.axis-title :deep(sup),
- .hover-name :deep(sub),
- .hover-name :deep(sup),
+ .hover-tooltip :deep(sub),
+ .hover-tooltip :deep(sup),
.legend :deep(sub),
.legend :deep(sup) {
position: static;
@@ -4041,13 +4337,13 @@ vue.watch(
}
.axis-title :deep(sub),
- .hover-name :deep(sub),
+ .hover-tooltip :deep(sub),
.legend :deep(sub) {
vertical-align: sub;
}
.axis-title :deep(sup),
- .hover-name :deep(sup),
+ .hover-tooltip :deep(sup),
.legend :deep(sup) {
vertical-align: super;
}
@@ -4067,32 +4363,68 @@ vue.watch(
}
/**
- * Note: we use the font size that Plotly means to use for its range editor, i.e. 12px (Plotly gets its unit wrong, so
- * it actually uses the font size of the page, i.e. 16px), with the same line height (relative to its font size)
- * as Plotly's range editor.
+ * Note: our hover tooltip is shown above our panels (so that it doesn't get clipped by short panels), but below
+ * PrimeVue's overlays (e.g., our context menu), which z-index is at least 1000.
*/
- .range-editor {
- position: absolute;
- z-index: 4;
- box-sizing: border-box;
- margin: -0.5px 0 0 -1px;
- padding: 0;
- font-family: Arial, sans-serif;
+ .hover-tooltip {
+ position: fixed;
+ z-index: 999;
+ max-width: 320px;
+ padding: 6px 10px;
+ border: 1px solid var(--p-overlay-popover-border-color);
+ border-radius: var(--p-overlay-popover-border-radius);
+ background-color: var(--p-overlay-popover-background);
+ box-shadow: var(--p-overlay-popover-shadow);
+ color: var(--p-overlay-popover-color);
font-size: 12px;
- line-height: 1.5;
- white-space: nowrap;
- outline: none;
- }
-
- .hover-name {
- position: absolute;
- z-index: 3;
+ line-height: 18px;
white-space: nowrap;
pointer-events: none;
user-select: none;
}
+ .hover-tooltip-header {
+ display: flex;
+ gap: 12px;
+ margin-bottom: 2px;
+ color: var(--p-text-muted-color);
+ }
+
+ .hover-tooltip-header .hover-tooltip-value {
+ color: var(--p-overlay-popover-color);
+ }
+
+ .hover-tooltip-name {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .hover-tooltip-other-rows {
+ color: var(--p-text-muted-color);
+ }
+
+ .hover-tooltip-row {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ }
+
+ .hover-tooltip-swatch {
+ flex: none;
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ }
+
+ .hover-tooltip-value {
+ margin-left: auto;
+ font-weight: 600;
+ font-variant-numeric: tabular-nums;
+ }
+
.legend {
position: absolute;
z-index: 2;
@@ -4105,23 +4437,30 @@ vue.watch(
.legend-item {
position: relative;
- min-height: 19px;
- padding: 0 5px 0 40px;
- line-height: 19px;
+ min-height: 20px;
+ margin: 0 4px;
+ padding: 0 6px 0 30px;
+ border-radius: var(--p-border-radius-sm);
+ line-height: 20px;
white-space: nowrap;
cursor: pointer;
+ transition: background-color 0.15s;
+ }
+
+ .legend-item:hover {
+ background-color: var(--p-content-hover-background);
}
.legend-item-hidden {
- opacity: 0.5;
+ color: var(--p-text-muted-color);
}
.legend-marker {
position: absolute;
top: -2px;
- left: 12px;
- width: 6px;
- height: 6px;
+ left: 5.5px;
+ width: 7px;
+ height: 7px;
border-radius: 50%;
background-color: inherit;
}
@@ -4129,9 +4468,28 @@ vue.watch(
.legend-swatch {
position: absolute;
top: 8.5px;
- left: 5px;
- width: 30px;
- height: 2px;
+ left: 6px;
+ width: 18px;
+ height: 3px;
+ border-radius: 1.5px;
+ }
+
+ /**
+ * Note: we use OpenCOR's font (which we inherit, like our axis titles and legend), but with the font size that Plotly
+ * means to use for its range editor, i.e. 12px (Plotly gets its unit wrong, so it actually uses the font size
+ * of the page, i.e. 16px), and with the same line height (relative to its font size) as Plotly's range editor.
+ */
+
+ .range-editor {
+ position: absolute;
+ z-index: 4;
+ box-sizing: border-box;
+ margin: -0.5px 0 0 -1px;
+ padding: 0;
+ font-size: 12px;
+ line-height: 1.5;
+ white-space: nowrap;
+ outline: none;
}
:deep(.u-over) {