diff --git a/app/components/UI/Predict/components/PredictDetailsChart/PredictDetailsChart.test.tsx b/app/components/UI/Predict/components/PredictDetailsChart/PredictDetailsChart.test.tsx
index ec453a799b61..162b6fa42b4d 100644
--- a/app/components/UI/Predict/components/PredictDetailsChart/PredictDetailsChart.test.tsx
+++ b/app/components/UI/Predict/components/PredictDetailsChart/PredictDetailsChart.test.tsx
@@ -3,11 +3,15 @@ import renderWithProvider from '../../../../../util/test/renderWithProvider';
import PredictDetailsChart, { ChartSeries } from './PredictDetailsChart';
jest.mock('react-native-svg-charts', () => ({
- LineChart: jest.fn(({ children, data, ...props }) => {
+ LineChart: jest.fn(({ children, data, svg, ...props }) => {
const { View, Text } = jest.requireActual('react-native');
+ // Only add testID if the chart is visible (not the transparent tooltip overlay)
+ const isVisible = svg?.stroke !== 'transparent';
return (
-
- {JSON.stringify(data)}
+
+
+ {JSON.stringify(data)}
+
{children}
);
@@ -43,6 +47,14 @@ jest.mock('react-native-svg', () => ({
const { View } = jest.requireActual('react-native');
return ;
}),
+ Circle: jest.fn((props) => {
+ const { View } = jest.requireActual('react-native');
+ return ;
+ }),
+ Rect: jest.fn((props) => {
+ const { View } = jest.requireActual('react-native');
+ return ;
+ }),
}));
jest.mock('d3-shape', () => ({
@@ -142,11 +154,16 @@ describe('PredictDetailsChart', () => {
});
it('renders chart with multiple series data', () => {
- const { getAllByTestId } = setupTest({ data: mockMultipleSeries });
+ const { getAllByTestId, getByText } = setupTest({
+ data: mockMultipleSeries,
+ });
- // Multiple LineChart components are rendered for multiple series
+ // At least one chart is rendered
const charts = getAllByTestId('line-chart');
expect(charts.length).toBeGreaterThanOrEqual(1);
+ // Legend with multiple series is displayed
+ expect(getByText(/Outcome A/)).toBeOnTheScreen();
+ expect(getByText(/Outcome B/)).toBeOnTheScreen();
});
it('renders timeframe selector with all timeframes', () => {
@@ -390,4 +407,290 @@ describe('PredictDetailsChart', () => {
expect(getByTestId('line-chart')).toBeOnTheScreen();
});
});
+
+ describe('Interactive Features', () => {
+ describe('Touch Interactions', () => {
+ it('renders chart with pan handler setup', () => {
+ const { getByTestId } = setupTest();
+
+ const lineChart = getByTestId('line-chart');
+ expect(lineChart).toBeOnTheScreen();
+ });
+
+ it('handles chart layout calculation', () => {
+ const { getAllByTestId } = setupTest({ data: mockMultipleSeries });
+
+ const lineCharts = getAllByTestId('line-chart');
+ // Verify chart is rendered and can receive layout events
+ expect(lineCharts.length).toBeGreaterThanOrEqual(1);
+ });
+ });
+
+ describe('ChartTooltip', () => {
+ it('renders SVG tooltip elements for multiple series', () => {
+ const { getAllByTestId } = setupTest({ data: mockMultipleSeries });
+
+ // Verify chart is rendered with SVG components
+ const charts = getAllByTestId('line-chart');
+ expect(charts.length).toBeGreaterThanOrEqual(1);
+ });
+
+ it('includes tooltip overlay chart layer', () => {
+ const { getAllByTestId } = setupTest({ data: mockMultipleSeries });
+
+ // With multiple series, we have primary + overlays + tooltip layer
+ const charts = getAllByTestId('line-chart');
+ expect(charts.length).toBeGreaterThanOrEqual(2);
+ });
+ });
+
+ describe('Label Truncation', () => {
+ it('handles long series labels', () => {
+ const longLabelSeries: ChartSeries[] = [
+ {
+ label: 'This is a very long outcome label that exceeds limit',
+ color: '#4459FF',
+ data: [
+ { timestamp: 1640995200000, value: 0.5 },
+ { timestamp: 1640998800000, value: 0.6 },
+ ],
+ },
+ {
+ label: 'Short Label',
+ color: '#FF6B6B',
+ data: [
+ { timestamp: 1640995200000, value: 0.3 },
+ { timestamp: 1640998800000, value: 0.4 },
+ ],
+ },
+ ];
+
+ const { getByText } = setupTest({ data: longLabelSeries });
+
+ // Component should render without crashing with long labels
+ // Legend shows labels for multiple series
+ expect(
+ getByText(/This is a very long outcome label/),
+ ).toBeOnTheScreen();
+ });
+
+ it('handles special characters in labels', () => {
+ const specialCharSeries: ChartSeries[] = [
+ {
+ label: 'Outcome #1 (Test) - Result',
+ color: '#4459FF',
+ data: [
+ { timestamp: 1640995200000, value: 0.5 },
+ { timestamp: 1640998800000, value: 0.6 },
+ ],
+ },
+ {
+ label: 'Normal Label',
+ color: '#FF6B6B',
+ data: [
+ { timestamp: 1640995200000, value: 0.3 },
+ { timestamp: 1640998800000, value: 0.4 },
+ ],
+ },
+ ];
+
+ const { getByText } = setupTest({ data: specialCharSeries });
+
+ expect(getByText(/Outcome #1 \(Test\)/)).toBeOnTheScreen();
+ });
+ });
+
+ describe('Active Index and Legend Updates', () => {
+ it('passes activeIndex to legend for multiple series', () => {
+ const { getByText } = setupTest({ data: mockMultipleSeries });
+
+ // Legend should display last values initially
+ expect(getByText(/Outcome A/)).toBeOnTheScreen();
+ expect(getByText(/Outcome B/)).toBeOnTheScreen();
+ });
+
+ it('legend displays values correctly for multiple series', () => {
+ const { getByText } = setupTest({ data: mockMultipleSeries });
+
+ // Both series labels should be visible in legend
+ expect(getByText(/Outcome A/)).toBeOnTheScreen();
+ expect(getByText(/Outcome B/)).toBeOnTheScreen();
+ });
+ });
+
+ describe('Collision Detection', () => {
+ it('handles series with crossing values', () => {
+ const crossingSeries: ChartSeries[] = [
+ {
+ label: 'Series A',
+ color: '#4459FF',
+ data: [
+ { timestamp: 1, value: 0.3 },
+ { timestamp: 2, value: 0.7 },
+ ],
+ },
+ {
+ label: 'Series B',
+ color: '#FF6B6B',
+ data: [
+ { timestamp: 1, value: 0.7 },
+ { timestamp: 2, value: 0.3 },
+ ],
+ },
+ ];
+
+ const { getByText } = setupTest({ data: crossingSeries });
+
+ // Component should handle crossing values without errors
+ expect(getByText(/Series A/)).toBeOnTheScreen();
+ expect(getByText(/Series B/)).toBeOnTheScreen();
+ });
+
+ it('handles series with very close values', () => {
+ const closeSeries: ChartSeries[] = [
+ {
+ label: 'Close A',
+ color: '#4459FF',
+ data: [
+ { timestamp: 1, value: 0.5 },
+ { timestamp: 2, value: 0.501 },
+ ],
+ },
+ {
+ label: 'Close B',
+ color: '#FF6B6B',
+ data: [
+ { timestamp: 1, value: 0.502 },
+ { timestamp: 2, value: 0.503 },
+ ],
+ },
+ ];
+
+ const { getByText } = setupTest({ data: closeSeries });
+
+ expect(getByText(/Close A/)).toBeOnTheScreen();
+ expect(getByText(/Close B/)).toBeOnTheScreen();
+ });
+ });
+
+ describe('Tooltip Positioning', () => {
+ it('renders chart with proper layout for tooltip positioning', () => {
+ const { getAllByTestId } = setupTest({ data: mockMultipleSeries });
+
+ const charts = getAllByTestId('line-chart');
+ // Should have multiple chart layers including tooltip overlay
+ expect(charts.length).toBeGreaterThanOrEqual(1);
+ });
+
+ it('handles chart with full data range', () => {
+ const fullRangeSeries: ChartSeries[] = [
+ {
+ label: 'Full Range',
+ color: '#4459FF',
+ data: Array.from({ length: 50 }, (_, i) => ({
+ timestamp: 1640995200000 + i * 3600000,
+ value: 0.3 + (i / 50) * 0.4, // Values from 0.3 to 0.7
+ })),
+ },
+ ];
+
+ const { getByTestId } = setupTest({ data: fullRangeSeries });
+
+ expect(getByTestId('line-chart')).toBeOnTheScreen();
+ });
+ });
+
+ describe('Theme Support', () => {
+ it('renders chart with theme colors', () => {
+ const { getAllByTestId } = setupTest({ data: mockMultipleSeries });
+
+ const lineCharts = getAllByTestId('line-chart');
+ // Theme colors should be applied via mocked useTheme
+ expect(lineCharts.length).toBeGreaterThanOrEqual(1);
+ });
+
+ it('applies correct colors to series', () => {
+ const coloredSeries: ChartSeries[] = [
+ {
+ label: 'Blue',
+ color: '#0000FF',
+ data: [{ timestamp: 1, value: 0.5 }],
+ },
+ {
+ label: 'Red',
+ color: '#FF0000',
+ data: [{ timestamp: 1, value: 0.5 }],
+ },
+ ];
+
+ const { getByText } = setupTest({ data: coloredSeries });
+
+ expect(getByText(/Blue/)).toBeOnTheScreen();
+ expect(getByText(/Red/)).toBeOnTheScreen();
+ });
+ });
+
+ describe('Multiple Series Rendering', () => {
+ it('renders primary and overlay charts for multiple series', () => {
+ const { getAllByTestId } = setupTest({ data: mockMultipleSeries });
+
+ const charts = getAllByTestId('line-chart');
+ // Primary chart + overlay for second series + tooltip overlay
+ expect(charts.length).toBeGreaterThanOrEqual(2);
+ });
+
+ it('renders up to 3 series with overlays', () => {
+ const threeSeries: ChartSeries[] = [
+ {
+ label: 'Series 1',
+ color: '#4459FF',
+ data: [
+ { timestamp: 1, value: 0.5 },
+ { timestamp: 2, value: 0.6 },
+ ],
+ },
+ {
+ label: 'Series 2',
+ color: '#FF6B6B',
+ data: [
+ { timestamp: 1, value: 0.3 },
+ { timestamp: 2, value: 0.4 },
+ ],
+ },
+ {
+ label: 'Series 3',
+ color: '#F0B034',
+ data: [
+ { timestamp: 1, value: 0.2 },
+ { timestamp: 2, value: 0.25 },
+ ],
+ },
+ ];
+
+ const { getByText } = setupTest({ data: threeSeries });
+
+ expect(getByText(/Series 1/)).toBeOnTheScreen();
+ expect(getByText(/Series 2/)).toBeOnTheScreen();
+ expect(getByText(/Series 3/)).toBeOnTheScreen();
+ });
+ });
+
+ describe('Data Point Formatting', () => {
+ it('formats timestamp labels correctly', () => {
+ const { getAllByText } = setupTest();
+
+ // Timestamps should be formatted as time labels
+ const timeLabels = getAllByText(/PM/);
+ expect(timeLabels.length).toBeGreaterThan(0);
+ });
+
+ it('displays correct number of axis labels', () => {
+ const { getAllByText } = setupTest();
+
+ // Should have time labels on x-axis
+ const timeLabels = getAllByText(/PM|AM/);
+ expect(timeLabels.length).toBeGreaterThan(0);
+ });
+ });
+ });
});
diff --git a/app/components/UI/Predict/components/PredictDetailsChart/PredictDetailsChart.tsx b/app/components/UI/Predict/components/PredictDetailsChart/PredictDetailsChart.tsx
index 35cbf1fc41d0..0aad87d77893 100644
--- a/app/components/UI/Predict/components/PredictDetailsChart/PredictDetailsChart.tsx
+++ b/app/components/UI/Predict/components/PredictDetailsChart/PredictDetailsChart.tsx
@@ -1,6 +1,12 @@
import React from 'react';
-import { StyleSheet, ActivityIndicator } from 'react-native';
+import {
+ StyleSheet,
+ ActivityIndicator,
+ PanResponder,
+ View,
+} from 'react-native';
import { LineChart } from 'react-native-svg-charts';
+import { Circle, G, Line, Text as SvgText, Rect } from 'react-native-svg';
import { useTailwind } from '@metamask/design-system-twrnc-preset';
import {
Box,
@@ -12,6 +18,7 @@ import Text, {
TextVariant,
} from '../../../../../component-library/components/Texts/Text';
import { useTheme } from '../../../../../util/theme';
+import type { Colors } from '../../../../../util/theme/models';
import TimeframeSelector from './components/TimeframeSelector';
import ChartGrid from './components/ChartGrid';
import ChartArea from './components/ChartArea';
@@ -28,7 +35,7 @@ import {
export interface ChartSeries {
label: string;
color: string;
- data: { timestamp: number; value: number }[];
+ data: { timestamp: number; value: number; label?: string }[];
}
interface PredictDetailsChartProps {
@@ -40,6 +47,216 @@ interface PredictDetailsChartProps {
emptyLabel?: string;
}
+interface TooltipManualProps {
+ activeIndex: number;
+ primaryData: { timestamp: number; value: number; label: string }[];
+ nonEmptySeries: ChartSeries[];
+ colors: Colors;
+}
+
+interface TooltipInjectedProps {
+ x: (index: number) => number;
+ y: (value: number) => number;
+ ticks: number[];
+}
+
+type TooltipProps = TooltipManualProps & Partial;
+
+// Tooltip component for interactive chart - defined outside to avoid re-renders
+const ChartTooltip: React.FC = ({
+ x,
+ y,
+ ticks,
+ activeIndex,
+ primaryData,
+ nonEmptySeries,
+ colors,
+}) => {
+ if (!x || !y || !ticks) return null; // Injected props not yet available
+ if (activeIndex < 0 || !primaryData[activeIndex]) return null;
+
+ const activePoint = primaryData[activeIndex];
+ const xPos = x(activeIndex);
+
+ // Calculate label dimensions
+ const labelPadding = 6;
+ const fontSize = 12;
+ const labelHeight = fontSize + labelPadding * 2;
+ const labelOffset = 10;
+
+ // Determine if we should show labels on the left or right
+ // If we're in the right half of the chart, show labels on the left
+ const maxDataIndex = primaryData.length - 1;
+ const isRightSide = activeIndex > maxDataIndex / 2;
+
+ // Theme-aware colors for crosshair
+ const lineColor = colors.border.muted;
+ const textColor = colors.text.alternative;
+
+ return (
+
+ {/* Top solid line connecting to timestamp */}
+
+
+ {/* Main vertical crosshair line through data area */}
+
+
+ {/* Bottom line extending to chart bottom */}
+
+
+ {/* Display timestamp at very top - adjust position based on side */}
+
+
+ {activePoint.label}
+
+
+
+ {/* Render circles and labels for each series - positioned at their line points */}
+ {(() => {
+ const maxLabelChars = 25; // Maximum characters for label title
+
+ // Calculate initial positions for all labels
+ const labelData = nonEmptySeries
+ .map((series, seriesIndex) => {
+ const seriesData = series.data[activeIndex];
+ if (!seriesData) return null;
+
+ const lineYPos = y(seriesData.value);
+ // Truncate label if too long
+ const truncatedLabel =
+ series.label.length > maxLabelChars
+ ? `${series.label.substring(0, maxLabelChars)}...`
+ : series.label;
+ const labelText = `${truncatedLabel}: ${seriesData.value.toFixed(2)}%`;
+ const textWidth = labelText.length * (fontSize * 0.55);
+ const labelWidth = textWidth + labelPadding * 2;
+
+ return {
+ series,
+ seriesIndex,
+ seriesData,
+ lineYPos,
+ labelText,
+ labelWidth,
+ truncatedLabel,
+ adjustedY: lineYPos - labelHeight / 2, // Initial position
+ };
+ })
+ .filter(Boolean);
+
+ // Sort by Y position to detect collisions
+ const sortedLabels = [...labelData]
+ .filter((item): item is NonNullable => item !== null)
+ .sort((a, b) => a.adjustedY - b.adjustedY);
+
+ // Adjust positions to prevent overlap
+ const minSpacing = 4; // Minimum pixels between labels
+ for (let i = 1; i < sortedLabels.length; i++) {
+ const current = sortedLabels[i];
+ const previous = sortedLabels[i - 1];
+
+ if (!current || !previous) continue;
+
+ const overlap =
+ previous.adjustedY + labelHeight + minSpacing - current.adjustedY;
+
+ if (overlap > 0) {
+ // Shift current label down
+ current.adjustedY += overlap;
+ }
+ }
+
+ // Apply adjusted positions back to original array
+ sortedLabels.forEach((label) => {
+ if (!label) return;
+ const original = labelData.find(
+ (l) => l?.seriesIndex === label.seriesIndex,
+ );
+ if (original) {
+ original.adjustedY = label.adjustedY;
+ }
+ });
+
+ // Render all labels
+ return labelData.map((data) => {
+ if (!data) return null;
+
+ const {
+ series,
+ seriesIndex,
+ lineYPos,
+ labelText,
+ labelWidth,
+ adjustedY,
+ } = data;
+
+ // Position label based on which side of the chart we're on
+ const labelX = isRightSide
+ ? xPos - labelWidth - labelOffset // Left side of crosshair
+ : xPos + labelOffset; // Right side of crosshair
+
+ return (
+
+ {/* Circle on the line */}
+
+
+ {/* Background rectangle with series color */}
+
+
+ {/* White text */}
+
+ {labelText}
+
+
+ );
+ });
+ })()}
+
+ );
+};
+
const PredictDetailsChart: React.FC = ({
data,
timeframes,
@@ -51,6 +268,11 @@ const PredictDetailsChart: React.FC = ({
const tw = useTailwind();
const { colors } = useTheme();
+ // Interactive chart state
+ const [activeIndex, setActiveIndex] = React.useState(-1);
+ const chartWidthRef = React.useRef(0);
+ const isHorizontalGestureRef = React.useRef(false);
+
// Limit to MAX_SERIES
const seriesToRender = React.useMemo(() => data.slice(0, MAX_SERIES), [data]);
const isSingleSeries = seriesToRender.length === 1;
@@ -95,12 +317,104 @@ const PredictDetailsChart: React.FC = ({
? primaryData.map((point) => point.value)
: [0, 0];
+ // Handle chart layout to track width
+ const handleChartLayout = (event: {
+ nativeEvent: { layout: { width: number } };
+ }) => {
+ const { width } = event.nativeEvent.layout;
+ chartWidthRef.current = width;
+ };
+
+ // Update active position based on touch X coordinate
+ const updatePosition = React.useCallback(
+ (x: number) => {
+ if (primaryData.length === 0) return;
+
+ const adjustedX = x - CHART_CONTENT_INSET.left;
+ const chartDataWidth =
+ chartWidthRef.current -
+ CHART_CONTENT_INSET.left -
+ CHART_CONTENT_INSET.right;
+
+ if (chartDataWidth <= 0) return;
+
+ const index = Math.round(
+ (adjustedX / chartDataWidth) * (primaryData.length - 1),
+ );
+
+ const clampedIndex = Math.max(0, Math.min(primaryData.length - 1, index));
+ setActiveIndex(clampedIndex);
+ },
+ [primaryData.length],
+ );
+
+ // PanResponder for handling touch gestures
+ // Responds to touches but only blocks ScrollView for horizontal gestures
+ const panResponder = React.useMemo(
+ () =>
+ PanResponder.create({
+ // Claim gesture on initial touch
+ onStartShouldSetPanResponder: () => true,
+ onStartShouldSetPanResponderCapture: () => false,
+
+ // Determine if we should keep the gesture based on direction
+ onMoveShouldSetPanResponder: () => true,
+ onMoveShouldSetPanResponderCapture: (_event, gestureState) => {
+ // Only capture (block ScrollView) if movement is more horizontal than vertical
+ const { dx, dy } = gestureState;
+ const isHorizontal = Math.abs(dx) > Math.abs(dy) && Math.abs(dx) > 5;
+ isHorizontalGestureRef.current = isHorizontal;
+ return isHorizontal;
+ },
+
+ // Allow ScrollView to take over if user scrolls vertically
+ onPanResponderTerminationRequest: (_event, gestureState) => {
+ const { dx, dy } = gestureState;
+ const isVertical = Math.abs(dy) > Math.abs(dx);
+ return isVertical; // Allow termination for vertical scrolls
+ },
+
+ onPanResponderGrant: (event) => {
+ isHorizontalGestureRef.current = true; // Assume horizontal initially
+ updatePosition(event.nativeEvent.locationX);
+ },
+
+ onPanResponderMove: (event, gestureState) => {
+ const { dx, dy } = gestureState;
+ // Check if gesture is still horizontal
+ if (Math.abs(dy) > Math.abs(dx) && Math.abs(dy) > 10) {
+ // User is scrolling vertically, stop updating
+ isHorizontalGestureRef.current = false;
+ setActiveIndex(-1);
+ } else {
+ // Continue with horizontal interaction
+ updatePosition(event.nativeEvent.locationX);
+ }
+ },
+
+ onPanResponderRelease: () => {
+ setActiveIndex(-1);
+ isHorizontalGestureRef.current = false;
+ },
+
+ onPanResponderTerminate: () => {
+ setActiveIndex(-1);
+ isHorizontalGestureRef.current = false;
+ },
+ }),
+ [updatePosition],
+ );
+
const renderGraph = () => {
if (isLoading || !hasData) {
return (
{isMultipleSeries && (
-
+
)}
= ({
return (
{isMultipleSeries && (
-
+
)}
-
+
= ({
curve={LINE_CURVE}
/>
))}
-
+ {/* Tooltip overlay - rendered last to appear on top */}
+
+
+
+
({
+ formatTickValue: jest.fn((value: number, range: number) => {
+ if (!Number.isFinite(value)) {
+ return '0';
+ }
+ if (range < 1) {
+ return value.toFixed(2);
+ }
+ if (range < 10) {
+ return value.toFixed(1);
+ }
+ return value.toFixed(0);
+ }),
+}));
+
+describe('ChartLegend', () => {
+ const mockSingleSeries: ChartSeries[] = [
+ {
+ label: 'Outcome A',
+ color: '#4459FF',
+ data: [
+ { timestamp: 1640995200000, value: 0.5 },
+ { timestamp: 1640998800000, value: 0.6 },
+ { timestamp: 1641002400000, value: 0.75 },
+ ],
+ },
+ ];
+
+ const mockMultipleSeries: ChartSeries[] = [
+ {
+ label: 'Outcome A',
+ color: '#4459FF',
+ data: [
+ { timestamp: 1640995200000, value: 0.5 },
+ { timestamp: 1640998800000, value: 0.6 },
+ { timestamp: 1641002400000, value: 0.75 },
+ ],
+ },
+ {
+ label: 'Outcome B',
+ color: '#FF6B6B',
+ data: [
+ { timestamp: 1640995200000, value: 0.3 },
+ { timestamp: 1640998800000, value: 0.2 },
+ { timestamp: 1641002400000, value: 0.15 },
+ ],
+ },
+ ];
+
+ const mockEmptySeries: ChartSeries[] = [];
+
+ const setupTest = (props = {}) => {
+ const defaultProps = {
+ series: mockSingleSeries,
+ range: 1,
+ ...props,
+ };
+ return renderWithProvider();
+ };
+
+ describe('Component Rendering', () => {
+ it('renders with single series data', () => {
+ const { getByText } = setupTest();
+
+ expect(getByText(/Outcome A/)).toBeOnTheScreen();
+ });
+
+ it('renders multiple series items', () => {
+ const { getByText } = setupTest({ series: mockMultipleSeries });
+
+ expect(getByText(/Outcome A/)).toBeOnTheScreen();
+ expect(getByText(/Outcome B/)).toBeOnTheScreen();
+ });
+
+ it('renders nothing when series is empty', () => {
+ const { queryByText } = setupTest({ series: mockEmptySeries });
+
+ // Should not render any text since series is empty
+ expect(queryByText(/Outcome/)).not.toBeOnTheScreen();
+ });
+
+ it('renders series label with value', () => {
+ const { getByText } = setupTest();
+
+ expect(getByText(/Outcome A 0\.8%/)).toBeOnTheScreen();
+ });
+
+ it('renders each series item', () => {
+ const { getByText } = setupTest({ series: mockMultipleSeries });
+
+ // Verify each series is rendered (color indicators are rendered as part of the layout)
+ expect(getByText(/Outcome A/)).toBeOnTheScreen();
+ expect(getByText(/Outcome B/)).toBeOnTheScreen();
+ });
+ });
+
+ describe('Value Display', () => {
+ it('displays last value when activeIndex is not provided', () => {
+ const { getByText } = setupTest();
+
+ // Last value in mockSingleSeries is 0.75, which with range 1 formats to "0.8"
+ expect(getByText(/0\.8%/)).toBeOnTheScreen();
+ });
+
+ it('displays last value when activeIndex is -1', () => {
+ const { getByText } = setupTest({ activeIndex: -1 });
+
+ // Last value in mockSingleSeries is 0.75, which with range 1 formats to "0.8"
+ expect(getByText(/0\.8%/)).toBeOnTheScreen();
+ });
+
+ it('displays value at activeIndex when provided', () => {
+ const { getByText } = setupTest({ activeIndex: 0 });
+
+ // First value in mockSingleSeries is 0.5, which with range 1 formats to "0.5"
+ expect(getByText(/0\.5%/)).toBeOnTheScreen();
+ });
+
+ it('displays middle value when activeIndex points to middle', () => {
+ const { getByText } = setupTest({ activeIndex: 1 });
+
+ // Middle value in mockSingleSeries is 0.6
+ expect(getByText(/0\.6%/)).toBeOnTheScreen();
+ });
+
+ it('displays em-dash when data is empty', () => {
+ const seriesWithEmptyData: ChartSeries[] = [
+ {
+ label: 'Empty Series',
+ color: '#4459FF',
+ data: [],
+ },
+ ];
+
+ const { getByText } = setupTest({ series: seriesWithEmptyData });
+
+ expect(getByText(/Empty Series —/)).toBeOnTheScreen();
+ });
+
+ it('formats value with correct decimal places based on range', () => {
+ const { getByText, rerender } = setupTest({ range: 0.5 });
+
+ // Range < 1: 2 decimal places
+ expect(getByText(/0\.75%/)).toBeOnTheScreen();
+
+ // Re-render with different range
+ rerender();
+
+ // Range < 10: 1 decimal place
+ expect(getByText(/0\.8%/)).toBeOnTheScreen();
+
+ // Re-render with larger range
+ rerender();
+
+ // Range >= 10: 0 decimal places
+ expect(getByText(/1%/)).toBeOnTheScreen();
+ });
+ });
+
+ describe('Active Index Behavior', () => {
+ it('updates displayed value when activeIndex changes', () => {
+ const { getByText, rerender } = setupTest({ activeIndex: 0 });
+
+ expect(getByText(/0\.5%/)).toBeOnTheScreen();
+
+ rerender(
+ ,
+ );
+
+ expect(getByText(/0\.8%/)).toBeOnTheScreen();
+ });
+
+ it('reverts to last value when activeIndex becomes undefined', () => {
+ const { getByText, rerender } = setupTest({ activeIndex: 0 });
+
+ expect(getByText(/0\.5%/)).toBeOnTheScreen();
+
+ rerender(
+ ,
+ );
+
+ expect(getByText(/0\.8%/)).toBeOnTheScreen();
+ });
+
+ it('shows last value when activeIndex is negative', () => {
+ const { getByText } = setupTest({ activeIndex: -5 });
+
+ expect(getByText(/0\.8%/)).toBeOnTheScreen();
+ });
+ });
+
+ describe('Edge Cases', () => {
+ it('handles empty data array gracefully', () => {
+ const seriesWithEmptyData: ChartSeries[] = [
+ {
+ label: 'Empty',
+ color: '#4459FF',
+ data: [],
+ },
+ ];
+
+ const { getByText } = setupTest({ series: seriesWithEmptyData });
+
+ expect(getByText(/Empty —/)).toBeOnTheScreen();
+ });
+
+ it('handles single data point', () => {
+ const seriesWithSinglePoint: ChartSeries[] = [
+ {
+ label: 'Single Point',
+ color: '#4459FF',
+ data: [{ timestamp: 1640995200000, value: 0.42 }],
+ },
+ ];
+
+ const { getByText } = setupTest({ series: seriesWithSinglePoint });
+
+ expect(getByText(/Single Point 0\.4%/)).toBeOnTheScreen();
+ });
+
+ it('handles out-of-bounds activeIndex', () => {
+ const { getByText } = setupTest({ activeIndex: 999 });
+
+ // Out of bounds should return undefined, showing em-dash
+ expect(getByText(/—/)).toBeOnTheScreen();
+ });
+
+ it('renders all series with different activeIndex values', () => {
+ const { getByText } = setupTest({
+ series: mockMultipleSeries,
+ activeIndex: 1,
+ });
+
+ // Outcome A at index 1: 0.6
+ expect(getByText(/Outcome A 0\.6%/)).toBeOnTheScreen();
+ // Outcome B at index 1: 0.2
+ expect(getByText(/Outcome B 0\.2%/)).toBeOnTheScreen();
+ });
+
+ it('handles very small values', () => {
+ const seriesWithSmallValues: ChartSeries[] = [
+ {
+ label: 'Small',
+ color: '#4459FF',
+ data: [{ timestamp: 1640995200000, value: 0.001 }],
+ },
+ ];
+
+ const { getByText } = setupTest({
+ series: seriesWithSmallValues,
+ range: 0.5,
+ });
+
+ expect(getByText(/Small 0\.00%/)).toBeOnTheScreen();
+ });
+
+ it('handles very large values', () => {
+ const seriesWithLargeValues: ChartSeries[] = [
+ {
+ label: 'Large',
+ color: '#4459FF',
+ data: [{ timestamp: 1640995200000, value: 999.99 }],
+ },
+ ];
+
+ const { getByText } = setupTest({
+ series: seriesWithLargeValues,
+ range: 100,
+ });
+
+ expect(getByText(/Large 1000%/)).toBeOnTheScreen();
+ });
+ });
+
+ describe('Multiple Series', () => {
+ it('renders correct values for all series at activeIndex', () => {
+ const { getByText } = setupTest({
+ series: mockMultipleSeries,
+ activeIndex: 0,
+ });
+
+ expect(getByText(/Outcome A 0\.5%/)).toBeOnTheScreen();
+ expect(getByText(/Outcome B 0\.3%/)).toBeOnTheScreen();
+ });
+
+ it('renders correct last values for all series when not dragging', () => {
+ const { getByText } = setupTest({ series: mockMultipleSeries });
+
+ expect(getByText(/Outcome A 0\.8%/)).toBeOnTheScreen();
+ expect(getByText(/Outcome B 0\.1%/)).toBeOnTheScreen();
+ });
+
+ it('handles series with different data lengths', () => {
+ const seriesWithDifferentLengths: ChartSeries[] = [
+ {
+ label: 'Long',
+ color: '#4459FF',
+ data: [
+ { timestamp: 1, value: 0.1 },
+ { timestamp: 2, value: 0.2 },
+ { timestamp: 3, value: 0.3 },
+ ],
+ },
+ {
+ label: 'Short',
+ color: '#FF6B6B',
+ data: [{ timestamp: 1, value: 0.5 }],
+ },
+ ];
+
+ const { getByText } = setupTest({
+ series: seriesWithDifferentLengths,
+ activeIndex: 2,
+ });
+
+ expect(getByText(/Long 0\.3%/)).toBeOnTheScreen();
+ // Short series doesn't have index 2, should show em-dash
+ expect(getByText(/Short —/)).toBeOnTheScreen();
+ });
+ });
+});
diff --git a/app/components/UI/Predict/components/PredictDetailsChart/components/ChartLegend.tsx b/app/components/UI/Predict/components/PredictDetailsChart/components/ChartLegend.tsx
index b91b5e6de3f4..f58bbe83db71 100644
--- a/app/components/UI/Predict/components/PredictDetailsChart/components/ChartLegend.tsx
+++ b/app/components/UI/Predict/components/PredictDetailsChart/components/ChartLegend.tsx
@@ -14,9 +14,14 @@ import { ChartSeries } from '../PredictDetailsChart';
interface ChartLegendProps {
series: ChartSeries[];
range: number;
+ activeIndex?: number; // Index of the active dragged position
}
-const ChartLegend: React.FC = ({ series, range }) => {
+const ChartLegend: React.FC = ({
+ series,
+ range,
+ activeIndex,
+}) => {
if (!series.length) return null;
return (
@@ -26,9 +31,14 @@ const ChartLegend: React.FC = ({ series, range }) => {
twClassName="px-4 mb-2 flex-wrap"
>
{series.map((seriesItem, index) => {
- const lastPoint = seriesItem.data[seriesItem.data.length - 1];
- const valueLabel = lastPoint
- ? `${formatTickValue(lastPoint.value, range)}%`
+ // Show value at active index if dragging, otherwise show last value
+ const dataPoint =
+ activeIndex !== undefined && activeIndex >= 0
+ ? seriesItem.data[activeIndex]
+ : seriesItem.data[seriesItem.data.length - 1];
+
+ const valueLabel = dataPoint
+ ? `${formatTickValue(dataPoint.value, range)}%`
: '\u2014';
return (