Files
Tracearr/apps/mobile/src/components/charts/PlaysChart.tsx
Rephl3x 3015f48118
Some checks failed
CI / Lint & Typecheck (push) Has been cancelled
CI / Test (routes) (push) Has been cancelled
CI / Test (security) (push) Has been cancelled
CI / Test (services) (push) Has been cancelled
CI / Test (unit) (push) Has been cancelled
CI / Test (integration) (push) Has been cancelled
CI / Test Coverage (push) Has been cancelled
CI / Build (push) Has been cancelled
Initial Upload
2025-12-17 12:32:50 +13:00

165 lines
4.7 KiB
TypeScript

/* eslint-disable @typescript-eslint/no-deprecated */
/**
* Area chart showing plays over time with touch-to-reveal tooltip
*/
import React, { useState, useCallback } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { CartesianChart, Area, useChartPressState } from 'victory-native';
import { Circle } from '@shopify/react-native-skia';
import { useAnimatedReaction, runOnJS } from 'react-native-reanimated';
import type { SharedValue } from 'react-native-reanimated';
import { colors, spacing, borderRadius, typography } from '../../lib/theme';
import { useChartFont } from './useChartFont';
interface PlaysChartProps {
data: { date: string; count: number }[];
height?: number;
}
function ToolTip({ x, y }: { x: SharedValue<number>; y: SharedValue<number> }) {
return <Circle cx={x} cy={y} r={6} color={colors.cyan.core} />;
}
export function PlaysChart({ data, height = 200 }: PlaysChartProps) {
const font = useChartFont(10);
const { state, isActive } = useChartPressState({ x: 0, y: { count: 0 } });
// React state to display values (synced from SharedValues)
const [displayValue, setDisplayValue] = useState<{
index: number;
count: number;
} | null>(null);
// Transform data for victory-native
const chartData = data.map((d, index) => ({
x: index,
count: d.count,
label: d.date,
}));
// Sync SharedValue changes to React state
const updateDisplayValue = useCallback((index: number, count: number) => {
setDisplayValue({ index: Math.round(index), count: Math.round(count) });
}, []);
const clearDisplayValue = useCallback(() => {
setDisplayValue(null);
}, []);
// Watch for changes in chart press state
useAnimatedReaction(
() => ({
active: isActive,
x: state.x.value.value,
y: state.y.count.value.value,
}),
(current, previous) => {
if (current.active) {
runOnJS(updateDisplayValue)(current.x, current.y);
} else if (previous?.active && !current.active) {
runOnJS(clearDisplayValue)();
}
},
[isActive]
);
if (chartData.length === 0) {
return (
<View style={[styles.container, styles.emptyContainer, { height }]}>
<Text style={styles.emptyText}>No play data available</Text>
</View>
);
}
// Get date label from React state
const dateLabel = displayValue && chartData[displayValue.index]?.label
? new Date(chartData[displayValue.index].label).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
})
: '';
return (
<View style={[styles.container, { height }]}>
{/* Active value display */}
<View style={styles.valueDisplay}>
{displayValue ? (
<>
<Text style={styles.valueText}>{displayValue.count} plays</Text>
<Text style={styles.dateText}>{dateLabel}</Text>
</>
) : null}
</View>
<CartesianChart
data={chartData}
xKey="x"
yKeys={['count']}
domainPadding={{ top: 20, bottom: 10, left: 5, right: 5 }}
chartPressState={state}
axisOptions={{
font,
tickCount: { x: 5, y: 4 },
lineColor: colors.border.dark,
labelColor: colors.text.muted.dark,
formatXLabel: (value) => {
const item = chartData[Math.round(value)];
if (!item) return '';
const date = new Date(item.label);
return `${date.getMonth() + 1}/${date.getDate()}`;
},
formatYLabel: (value) => String(Math.round(value)),
}}
>
{({ points, chartBounds }) => (
<>
<Area
points={points.count}
y0={chartBounds.bottom}
color={colors.cyan.core}
opacity={0.6}
animate={{ type: 'timing', duration: 500 }}
/>
{isActive && (
<ToolTip x={state.x.position} y={state.y.count.position} />
)}
</>
)}
</CartesianChart>
</View>
);
}
const styles = StyleSheet.create({
container: {
backgroundColor: colors.card.dark,
borderRadius: borderRadius.lg,
padding: spacing.sm,
},
emptyContainer: {
justifyContent: 'center',
alignItems: 'center',
},
emptyText: {
color: colors.text.muted.dark,
fontSize: typography.fontSize.sm,
},
valueDisplay: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: spacing.xs,
marginBottom: spacing.xs,
minHeight: 20,
},
valueText: {
color: colors.cyan.core,
fontSize: typography.fontSize.sm,
fontWeight: '600',
},
dateText: {
color: colors.text.muted.dark,
fontSize: typography.fontSize.xs,
},
});