Skip to content

Commit e6d0632

Browse files
feat: Make Predict Charts Interactive (#22402)
# Interactive Chart for Predict Markets ## Overview This PR adds interactive drag-to-view functionality to the Predict market details chart, allowing users to explore historical price data by dragging their finger across the chart. CHANGELOG entry: null https://github.com/user-attachments/assets/036a225c-d751-4233-8e8a-453c03de7506 ## Features ### 1. **Touch Interaction** - Users can touch and drag on the chart to view values at different points in time - Crosshair line follows finger position from top to bottom of chart - Values update in real-time as user drags ### 2. **Visual Feedback** - **Crosshair Line**: Solid vertical line (theme-aware) extends from top to bottom - **Time Label**: Displayed at top of chart showing timestamp at dragged position - **Data Point Indicators**: Colored circles on each line at the active position - **Value Labels**: Colored badges showing series name and value at each data point ### 3. **Smart Label Positioning** - **Collision Detection**: Labels automatically adjust to prevent overlap when lines cross - **Side Switching**: Labels flip from right to left side when user drags past chart midpoint - **Character Limit**: Labels truncate to 25 characters to prevent overflow ### 4. **Dynamic Legend Values** - Legend above chart updates to show values at dragged position - Automatically reverts to latest values when user releases touch - Provides context for historical data exploration ### 5. **Gesture Handling** - **Directional Detection**: Distinguishes between horizontal drags (chart interaction) and vertical scrolls (page scrolling) - **No Scroll Conflicts**: Parent ScrollView continues to work for vertical scrolling - Smart gesture capture prevents interference between chart interaction and page navigation ### 6. **Theme Support** - All colors use design tokens for automatic light/dark mode support - Crosshair line: `colors.border.muted` (gray in light mode, white in dark mode) - Text: `colors.text.alternative` (gray in light mode, white in dark mode) - Labels: `colors.background.default` for text on colored backgrounds ## Technical Implementation ### Components Modified 1. **`PredictDetailsChart.tsx`** - Added `PanResponder` for touch gesture handling - Implemented collision detection algorithm for label positioning - Created tooltip overlay with proper z-index stacking - Added directional gesture discrimination 2. **`ChartLegend.tsx`** - Added `activeIndex` prop to display dragged position values - Updates dynamically during drag interaction - Reverts to latest values on release ### Key Technical Decisions - **Tooltip Rendering**: Uses separate transparent `LineChart` overlay rendered last for proper z-index - **Collision Detection**: Sorts labels by Y position and applies minimum 4px spacing - **Gesture Direction**: Uses `gestureState.dx` and `gestureState.dy` to determine horizontal vs vertical movement - **Label Truncation**: 25-character limit with ellipsis for long outcome titles ## Testing ### Manual Testing Steps 1. Navigate to any Predict market with price history 2. Touch and drag horizontally on the chart 3. Verify: - Crosshair line appears and follows touch - Time label shows at top of chart - Value labels appear next to data points - Legend values update to show dragged position - Labels flip to left side when dragging on right half - Labels don't overlap when lines cross 4. Release touch and verify: - Tooltip disappears - Legend reverts to latest values 5. Try vertical scrolling on chart area: - Verify page scrolls normally - Chart interaction only activates on horizontal drag ### Edge Cases Tested - ✅ Multiple series with crossing lines (collision detection) - ✅ Long outcome titles (truncation to 25 chars) - ✅ Single vs multiple series charts - ✅ Empty data states - ✅ Loading states - ✅ Light and dark modes - ✅ Gesture conflicts with ScrollView ## Screenshots/Demo _Add screenshots or screen recording demonstrating the interactive features_ ## Performance Considerations - Uses `useCallback` for gesture handlers to prevent unnecessary re-renders - Tooltip component defined outside render to avoid recreation - Collision detection runs only during active drag (not on every render) - Proper cleanup on gesture release ## Accessibility - Touch targets are appropriate size (chart fills available width) - Visual feedback is clear and theme-aware - Falls back to showing latest values when not interacting ## Breaking Changes None. All changes are additive and backward compatible. ## Dependencies No new dependencies added. Uses existing: - `react-native-svg-charts` (already in use) - `react-native-svg` (already in use) - React Native `PanResponder` (built-in) ## Future Enhancements Potential improvements for future PRs: - Haptic feedback on touch - Animation transitions for label movements - Pinch-to-zoom for chart data - Export chart data feature - Customizable tooltip appearance ## Commits - `ba2a015` - feat: add interactive chart with drag-to-view values in Predict - `3a40cd1` - refactor: improve chart tooltip styling and positioning - `93142d5` - feat: add label collision detection to chart tooltip - `2a6f32b` - feat: update legend values dynamically based on dragged position - `a9d37d6` - feat: add character limit to tooltip labels ## Checklist - [x] Code follows project coding guidelines - [x] No linting errors - [x] Changes are backward compatible - [x] Manual testing completed - [x] Works in both light and dark themes - [x] Gesture handling doesn't conflict with ScrollView - [ ] Unit tests added (if applicable) - [ ] E2E tests updated (if applicable) - [ ] Documentation updated (if applicable) ## Related Issues _Link any related issues or tickets here_ <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Adds touch-driven crosshair/tooltip overlay with dynamic legend updates to Predict details chart, plus extensive tests and minor type/mocks updates. > > - **PredictDetailsChart** (`PredictDetailsChart.tsx`): > - Add interactive drag support via `PanResponder` with horizontal/vertical gesture discrimination. > - Implement `ChartTooltip` overlay (crosshair, timestamp, per-series circles/labels) rendered via transparent `LineChart` layer. > - Dynamic legend: pass `activeIndex` to `ChartLegend` to show values at the dragged position; revert when inactive. > - Data/type updates: `ChartSeries.data` supports optional `label`; generate formatted `label` for timestamps; collision handling and side-switching for tooltip labels; theme-aware colors. > - **ChartLegend** (`components/ChartLegend.tsx`): > - Accept `activeIndex` to display contextual values; fallback to last point; formatting via `formatTickValue`. > - **Tests**: > - Expand `PredictDetailsChart.test.tsx` to cover interactions, overlays, label truncation/collisions, theme, multiple series, and axis labels; adjust `LineChart` mock to hide transparent overlay; add `Circle`/`Rect` to SVG mocks. > - Add `ChartLegend.test.tsx` with value formatting, activeIndex behaviors, edge cases, and multi-series coverage. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 98d1802. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Luis Taniça <matallui@gmail.com>
1 parent 47f8e57 commit e6d0632

File tree

4 files changed

+1000
-15
lines changed

4 files changed

+1000
-15
lines changed

app/components/UI/Predict/components/PredictDetailsChart/PredictDetailsChart.test.tsx

Lines changed: 308 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,15 @@ import renderWithProvider from '../../../../../util/test/renderWithProvider';
33
import PredictDetailsChart, { ChartSeries } from './PredictDetailsChart';
44

55
jest.mock('react-native-svg-charts', () => ({
6-
LineChart: jest.fn(({ children, data, ...props }) => {
6+
LineChart: jest.fn(({ children, data, svg, ...props }) => {
77
const { View, Text } = jest.requireActual('react-native');
8+
// Only add testID if the chart is visible (not the transparent tooltip overlay)
9+
const isVisible = svg?.stroke !== 'transparent';
810
return (
9-
<View testID="line-chart" {...props}>
10-
<Text testID="chart-data">{JSON.stringify(data)}</Text>
11+
<View testID={isVisible ? 'line-chart' : undefined} {...props}>
12+
<Text testID={isVisible ? 'chart-data' : undefined}>
13+
{JSON.stringify(data)}
14+
</Text>
1115
{children}
1216
</View>
1317
);
@@ -43,6 +47,14 @@ jest.mock('react-native-svg', () => ({
4347
const { View } = jest.requireActual('react-native');
4448
return <View testID="svg-path" {...props} />;
4549
}),
50+
Circle: jest.fn((props) => {
51+
const { View } = jest.requireActual('react-native');
52+
return <View testID="svg-circle" {...props} />;
53+
}),
54+
Rect: jest.fn((props) => {
55+
const { View } = jest.requireActual('react-native');
56+
return <View testID="svg-rect" {...props} />;
57+
}),
4658
}));
4759

4860
jest.mock('d3-shape', () => ({
@@ -142,11 +154,16 @@ describe('PredictDetailsChart', () => {
142154
});
143155

144156
it('renders chart with multiple series data', () => {
145-
const { getAllByTestId } = setupTest({ data: mockMultipleSeries });
157+
const { getAllByTestId, getByText } = setupTest({
158+
data: mockMultipleSeries,
159+
});
146160

147-
// Multiple LineChart components are rendered for multiple series
161+
// At least one chart is rendered
148162
const charts = getAllByTestId('line-chart');
149163
expect(charts.length).toBeGreaterThanOrEqual(1);
164+
// Legend with multiple series is displayed
165+
expect(getByText(/Outcome A/)).toBeOnTheScreen();
166+
expect(getByText(/Outcome B/)).toBeOnTheScreen();
150167
});
151168

152169
it('renders timeframe selector with all timeframes', () => {
@@ -390,4 +407,290 @@ describe('PredictDetailsChart', () => {
390407
expect(getByTestId('line-chart')).toBeOnTheScreen();
391408
});
392409
});
410+
411+
describe('Interactive Features', () => {
412+
describe('Touch Interactions', () => {
413+
it('renders chart with pan handler setup', () => {
414+
const { getByTestId } = setupTest();
415+
416+
const lineChart = getByTestId('line-chart');
417+
expect(lineChart).toBeOnTheScreen();
418+
});
419+
420+
it('handles chart layout calculation', () => {
421+
const { getAllByTestId } = setupTest({ data: mockMultipleSeries });
422+
423+
const lineCharts = getAllByTestId('line-chart');
424+
// Verify chart is rendered and can receive layout events
425+
expect(lineCharts.length).toBeGreaterThanOrEqual(1);
426+
});
427+
});
428+
429+
describe('ChartTooltip', () => {
430+
it('renders SVG tooltip elements for multiple series', () => {
431+
const { getAllByTestId } = setupTest({ data: mockMultipleSeries });
432+
433+
// Verify chart is rendered with SVG components
434+
const charts = getAllByTestId('line-chart');
435+
expect(charts.length).toBeGreaterThanOrEqual(1);
436+
});
437+
438+
it('includes tooltip overlay chart layer', () => {
439+
const { getAllByTestId } = setupTest({ data: mockMultipleSeries });
440+
441+
// With multiple series, we have primary + overlays + tooltip layer
442+
const charts = getAllByTestId('line-chart');
443+
expect(charts.length).toBeGreaterThanOrEqual(2);
444+
});
445+
});
446+
447+
describe('Label Truncation', () => {
448+
it('handles long series labels', () => {
449+
const longLabelSeries: ChartSeries[] = [
450+
{
451+
label: 'This is a very long outcome label that exceeds limit',
452+
color: '#4459FF',
453+
data: [
454+
{ timestamp: 1640995200000, value: 0.5 },
455+
{ timestamp: 1640998800000, value: 0.6 },
456+
],
457+
},
458+
{
459+
label: 'Short Label',
460+
color: '#FF6B6B',
461+
data: [
462+
{ timestamp: 1640995200000, value: 0.3 },
463+
{ timestamp: 1640998800000, value: 0.4 },
464+
],
465+
},
466+
];
467+
468+
const { getByText } = setupTest({ data: longLabelSeries });
469+
470+
// Component should render without crashing with long labels
471+
// Legend shows labels for multiple series
472+
expect(
473+
getByText(/This is a very long outcome label/),
474+
).toBeOnTheScreen();
475+
});
476+
477+
it('handles special characters in labels', () => {
478+
const specialCharSeries: ChartSeries[] = [
479+
{
480+
label: 'Outcome #1 (Test) - Result',
481+
color: '#4459FF',
482+
data: [
483+
{ timestamp: 1640995200000, value: 0.5 },
484+
{ timestamp: 1640998800000, value: 0.6 },
485+
],
486+
},
487+
{
488+
label: 'Normal Label',
489+
color: '#FF6B6B',
490+
data: [
491+
{ timestamp: 1640995200000, value: 0.3 },
492+
{ timestamp: 1640998800000, value: 0.4 },
493+
],
494+
},
495+
];
496+
497+
const { getByText } = setupTest({ data: specialCharSeries });
498+
499+
expect(getByText(/Outcome #1 \(Test\)/)).toBeOnTheScreen();
500+
});
501+
});
502+
503+
describe('Active Index and Legend Updates', () => {
504+
it('passes activeIndex to legend for multiple series', () => {
505+
const { getByText } = setupTest({ data: mockMultipleSeries });
506+
507+
// Legend should display last values initially
508+
expect(getByText(/Outcome A/)).toBeOnTheScreen();
509+
expect(getByText(/Outcome B/)).toBeOnTheScreen();
510+
});
511+
512+
it('legend displays values correctly for multiple series', () => {
513+
const { getByText } = setupTest({ data: mockMultipleSeries });
514+
515+
// Both series labels should be visible in legend
516+
expect(getByText(/Outcome A/)).toBeOnTheScreen();
517+
expect(getByText(/Outcome B/)).toBeOnTheScreen();
518+
});
519+
});
520+
521+
describe('Collision Detection', () => {
522+
it('handles series with crossing values', () => {
523+
const crossingSeries: ChartSeries[] = [
524+
{
525+
label: 'Series A',
526+
color: '#4459FF',
527+
data: [
528+
{ timestamp: 1, value: 0.3 },
529+
{ timestamp: 2, value: 0.7 },
530+
],
531+
},
532+
{
533+
label: 'Series B',
534+
color: '#FF6B6B',
535+
data: [
536+
{ timestamp: 1, value: 0.7 },
537+
{ timestamp: 2, value: 0.3 },
538+
],
539+
},
540+
];
541+
542+
const { getByText } = setupTest({ data: crossingSeries });
543+
544+
// Component should handle crossing values without errors
545+
expect(getByText(/Series A/)).toBeOnTheScreen();
546+
expect(getByText(/Series B/)).toBeOnTheScreen();
547+
});
548+
549+
it('handles series with very close values', () => {
550+
const closeSeries: ChartSeries[] = [
551+
{
552+
label: 'Close A',
553+
color: '#4459FF',
554+
data: [
555+
{ timestamp: 1, value: 0.5 },
556+
{ timestamp: 2, value: 0.501 },
557+
],
558+
},
559+
{
560+
label: 'Close B',
561+
color: '#FF6B6B',
562+
data: [
563+
{ timestamp: 1, value: 0.502 },
564+
{ timestamp: 2, value: 0.503 },
565+
],
566+
},
567+
];
568+
569+
const { getByText } = setupTest({ data: closeSeries });
570+
571+
expect(getByText(/Close A/)).toBeOnTheScreen();
572+
expect(getByText(/Close B/)).toBeOnTheScreen();
573+
});
574+
});
575+
576+
describe('Tooltip Positioning', () => {
577+
it('renders chart with proper layout for tooltip positioning', () => {
578+
const { getAllByTestId } = setupTest({ data: mockMultipleSeries });
579+
580+
const charts = getAllByTestId('line-chart');
581+
// Should have multiple chart layers including tooltip overlay
582+
expect(charts.length).toBeGreaterThanOrEqual(1);
583+
});
584+
585+
it('handles chart with full data range', () => {
586+
const fullRangeSeries: ChartSeries[] = [
587+
{
588+
label: 'Full Range',
589+
color: '#4459FF',
590+
data: Array.from({ length: 50 }, (_, i) => ({
591+
timestamp: 1640995200000 + i * 3600000,
592+
value: 0.3 + (i / 50) * 0.4, // Values from 0.3 to 0.7
593+
})),
594+
},
595+
];
596+
597+
const { getByTestId } = setupTest({ data: fullRangeSeries });
598+
599+
expect(getByTestId('line-chart')).toBeOnTheScreen();
600+
});
601+
});
602+
603+
describe('Theme Support', () => {
604+
it('renders chart with theme colors', () => {
605+
const { getAllByTestId } = setupTest({ data: mockMultipleSeries });
606+
607+
const lineCharts = getAllByTestId('line-chart');
608+
// Theme colors should be applied via mocked useTheme
609+
expect(lineCharts.length).toBeGreaterThanOrEqual(1);
610+
});
611+
612+
it('applies correct colors to series', () => {
613+
const coloredSeries: ChartSeries[] = [
614+
{
615+
label: 'Blue',
616+
color: '#0000FF',
617+
data: [{ timestamp: 1, value: 0.5 }],
618+
},
619+
{
620+
label: 'Red',
621+
color: '#FF0000',
622+
data: [{ timestamp: 1, value: 0.5 }],
623+
},
624+
];
625+
626+
const { getByText } = setupTest({ data: coloredSeries });
627+
628+
expect(getByText(/Blue/)).toBeOnTheScreen();
629+
expect(getByText(/Red/)).toBeOnTheScreen();
630+
});
631+
});
632+
633+
describe('Multiple Series Rendering', () => {
634+
it('renders primary and overlay charts for multiple series', () => {
635+
const { getAllByTestId } = setupTest({ data: mockMultipleSeries });
636+
637+
const charts = getAllByTestId('line-chart');
638+
// Primary chart + overlay for second series + tooltip overlay
639+
expect(charts.length).toBeGreaterThanOrEqual(2);
640+
});
641+
642+
it('renders up to 3 series with overlays', () => {
643+
const threeSeries: ChartSeries[] = [
644+
{
645+
label: 'Series 1',
646+
color: '#4459FF',
647+
data: [
648+
{ timestamp: 1, value: 0.5 },
649+
{ timestamp: 2, value: 0.6 },
650+
],
651+
},
652+
{
653+
label: 'Series 2',
654+
color: '#FF6B6B',
655+
data: [
656+
{ timestamp: 1, value: 0.3 },
657+
{ timestamp: 2, value: 0.4 },
658+
],
659+
},
660+
{
661+
label: 'Series 3',
662+
color: '#F0B034',
663+
data: [
664+
{ timestamp: 1, value: 0.2 },
665+
{ timestamp: 2, value: 0.25 },
666+
],
667+
},
668+
];
669+
670+
const { getByText } = setupTest({ data: threeSeries });
671+
672+
expect(getByText(/Series 1/)).toBeOnTheScreen();
673+
expect(getByText(/Series 2/)).toBeOnTheScreen();
674+
expect(getByText(/Series 3/)).toBeOnTheScreen();
675+
});
676+
});
677+
678+
describe('Data Point Formatting', () => {
679+
it('formats timestamp labels correctly', () => {
680+
const { getAllByText } = setupTest();
681+
682+
// Timestamps should be formatted as time labels
683+
const timeLabels = getAllByText(/PM/);
684+
expect(timeLabels.length).toBeGreaterThan(0);
685+
});
686+
687+
it('displays correct number of axis labels', () => {
688+
const { getAllByText } = setupTest();
689+
690+
// Should have time labels on x-axis
691+
const timeLabels = getAllByText(/PM|AM/);
692+
expect(timeLabels.length).toBeGreaterThan(0);
693+
});
694+
});
695+
});
393696
});

0 commit comments

Comments
 (0)