universal-adaptive-bars 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +110 -0
- package/dist/smart-bar-chart.js +3863 -0
- package/dist/smart-bar-chart.umd.cjs +20 -0
- package/dist/vite.svg +1 -0
- package/package.json +72 -0
- package/src/lib/SmartBarChart.tsx +526 -0
- package/src/lib/SmartBarChartNative.tsx +528 -0
- package/src/lib/__tests__/SmartBarChart.test.tsx +69 -0
- package/src/lib/components/Bar.tsx +49 -0
- package/src/lib/components/BarNative.tsx +44 -0
- package/src/lib/components/Tooltip.tsx +35 -0
- package/src/lib/components/TooltipNative.tsx +60 -0
- package/src/lib/hooks/useChartData.ts +212 -0
- package/src/lib/index.ts +2 -0
- package/src/lib/native.ts +1 -0
- package/src/lib/services/gemini.ts +79 -0
- package/src/lib/types.ts +64 -0
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
import React, { useMemo, useState, useEffect } from 'react';
|
|
2
|
+
import { View, Text, TouchableOpacity, StyleSheet, Dimensions, Modal, ScrollView } from 'react-native';
|
|
3
|
+
import Svg, { G, Text as SvgText, Line, Rect } from 'react-native-svg';
|
|
4
|
+
import { scaleBand, scaleLinear } from 'd3-scale';
|
|
5
|
+
import { getYear, getMonth, format, endOfWeek, eachWeekOfInterval, endOfMonth, isWithinInterval } from 'date-fns';
|
|
6
|
+
import { useChartData } from './hooks/useChartData';
|
|
7
|
+
import { BarNative } from './components/BarNative';
|
|
8
|
+
import type { SmartBarChartProps, DataPoint } from './types';
|
|
9
|
+
import { GeminiService } from './services/gemini';
|
|
10
|
+
|
|
11
|
+
export const SmartBarChartNative: React.FC<SmartBarChartProps> = ({
|
|
12
|
+
data,
|
|
13
|
+
view = 'month',
|
|
14
|
+
variant = 'default',
|
|
15
|
+
dataKeys,
|
|
16
|
+
geminiConfig,
|
|
17
|
+
colors,
|
|
18
|
+
axisLabels,
|
|
19
|
+
onViewChange,
|
|
20
|
+
height = 400,
|
|
21
|
+
width = '100%',
|
|
22
|
+
}) => {
|
|
23
|
+
// Calendar / Filter State
|
|
24
|
+
const [filterDate, setFilterDate] = useState<{ year: number | null, month: number | null, weekStartDate: Date | null }>({ year: null, month: null, weekStartDate: null });
|
|
25
|
+
const [isPickerVisible, setIsPickerVisible] = useState(false);
|
|
26
|
+
const [pickerMode, setPickerMode] = useState<'year' | 'month' | 'week'>('year');
|
|
27
|
+
|
|
28
|
+
// Filter Raw Data
|
|
29
|
+
const filteredRawData = useMemo(() => {
|
|
30
|
+
if (!filterDate.year) return data;
|
|
31
|
+
return data.filter(d => {
|
|
32
|
+
const dateStr = d[dataKeys.date] as string;
|
|
33
|
+
const date = new Date(dateStr);
|
|
34
|
+
if (getYear(date) !== filterDate.year) return false;
|
|
35
|
+
if (filterDate.month !== null && getMonth(date) !== filterDate.month) return false;
|
|
36
|
+
|
|
37
|
+
if (filterDate.weekStartDate) {
|
|
38
|
+
const start = filterDate.weekStartDate;
|
|
39
|
+
const end = endOfWeek(start);
|
|
40
|
+
return isWithinInterval(date, { start, end });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return true;
|
|
44
|
+
});
|
|
45
|
+
}, [data, filterDate, dataKeys.date]);
|
|
46
|
+
|
|
47
|
+
// Available Years
|
|
48
|
+
const availableYears = useMemo(() => {
|
|
49
|
+
const years = new Set(data.map(d => getYear(new Date(d[dataKeys.date] as string))));
|
|
50
|
+
return Array.from(years).sort((a, b) => b - a);
|
|
51
|
+
}, [data, dataKeys.date]);
|
|
52
|
+
|
|
53
|
+
const fullChartData = useChartData({ data: filteredRawData, view, dataKeys, colors });
|
|
54
|
+
const [activeItem, setActiveItem] = useState<DataPoint | null>(null);
|
|
55
|
+
const [predictions, setPredictions] = useState<DataPoint[]>([]);
|
|
56
|
+
const [isPredicting, setIsPredicting] = useState(false);
|
|
57
|
+
const [layoutWidth, setLayoutWidth] = useState(Dimensions.get('window').width - 40);
|
|
58
|
+
|
|
59
|
+
// Navigation State
|
|
60
|
+
const VISIBLE_COUNT = view === 'month' ? 12 : 7;
|
|
61
|
+
const [windowOffset, setWindowOffset] = useState(0);
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
setWindowOffset(0);
|
|
65
|
+
setPredictions([]);
|
|
66
|
+
|
|
67
|
+
if (view === 'month') {
|
|
68
|
+
setFilterDate(prev => {
|
|
69
|
+
if (prev.month !== null || prev.weekStartDate !== null) {
|
|
70
|
+
return { ...prev, month: null, weekStartDate: null };
|
|
71
|
+
}
|
|
72
|
+
return prev;
|
|
73
|
+
});
|
|
74
|
+
setPickerMode('year');
|
|
75
|
+
} else if (view === 'week') {
|
|
76
|
+
setFilterDate(prev => {
|
|
77
|
+
if (prev.weekStartDate !== null) {
|
|
78
|
+
return { ...prev, weekStartDate: null };
|
|
79
|
+
}
|
|
80
|
+
return prev;
|
|
81
|
+
});
|
|
82
|
+
setPickerMode('month');
|
|
83
|
+
}
|
|
84
|
+
}, [view]);
|
|
85
|
+
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
setWindowOffset(0);
|
|
88
|
+
setPredictions([]);
|
|
89
|
+
}, [filterDate]);
|
|
90
|
+
|
|
91
|
+
const handleYearSelect = (year: number) => {
|
|
92
|
+
setFilterDate({ year, month: null, weekStartDate: null });
|
|
93
|
+
setPickerMode('month');
|
|
94
|
+
onViewChange?.('month');
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const handleMonthSelect = (monthIndex: number) => {
|
|
98
|
+
setFilterDate(prev => ({ ...prev, month: monthIndex, weekStartDate: null }));
|
|
99
|
+
setPickerMode('week');
|
|
100
|
+
onViewChange?.('week');
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const handleWeekSelect = (weekStart: Date) => {
|
|
104
|
+
setFilterDate(prev => ({ ...prev, weekStartDate: weekStart }));
|
|
105
|
+
setIsPickerVisible(false);
|
|
106
|
+
onViewChange?.('day');
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const clearFilter = () => {
|
|
110
|
+
setFilterDate({ year: null, month: null, weekStartDate: null });
|
|
111
|
+
setPickerMode('year');
|
|
112
|
+
setIsPickerVisible(false);
|
|
113
|
+
onViewChange?.('month'); // Reset View
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// Combine & Slice
|
|
117
|
+
const allData = useMemo(() => [...fullChartData, ...predictions], [fullChartData, predictions]);
|
|
118
|
+
const visibleData = useMemo(() => {
|
|
119
|
+
const totalLen = allData.length;
|
|
120
|
+
const start = Math.max(0, totalLen - VISIBLE_COUNT - windowOffset);
|
|
121
|
+
const end = Math.min(totalLen, totalLen - windowOffset);
|
|
122
|
+
return allData.slice(start, end);
|
|
123
|
+
}, [allData, windowOffset, VISIBLE_COUNT]);
|
|
124
|
+
|
|
125
|
+
// Dimensions
|
|
126
|
+
const margin = { top: 40, right: 20, bottom: 40, left: 40 };
|
|
127
|
+
const containerHeight = typeof height === 'number' ? height : 400;
|
|
128
|
+
const chartHeight = containerHeight - margin.top - margin.bottom;
|
|
129
|
+
const chartWidth = layoutWidth - margin.left - margin.right; // Need to account for button space in layout?
|
|
130
|
+
// Actually in Native, if we put buttons OUTSIDE, we decrease available width for main chart.
|
|
131
|
+
// Simplifying: we'll just overlay specific buttons outside margin.
|
|
132
|
+
|
|
133
|
+
// Scales
|
|
134
|
+
const xScale = useMemo(() => {
|
|
135
|
+
return scaleBand()
|
|
136
|
+
.domain(visibleData.map(d => d.id))
|
|
137
|
+
.range([0, chartWidth])
|
|
138
|
+
.padding(0.3);
|
|
139
|
+
}, [visibleData, chartWidth]);
|
|
140
|
+
|
|
141
|
+
const yScale = useMemo(() => {
|
|
142
|
+
const maxVal = Math.max(...visibleData.map(d => d.value), 0);
|
|
143
|
+
return scaleLinear()
|
|
144
|
+
.domain([0, maxVal * 1.1])
|
|
145
|
+
.range([chartHeight, 0]);
|
|
146
|
+
}, [visibleData, chartHeight]);
|
|
147
|
+
|
|
148
|
+
const handlePredict = async () => {
|
|
149
|
+
if (!geminiConfig?.apiKey) return;
|
|
150
|
+
setIsPredicting(true);
|
|
151
|
+
try {
|
|
152
|
+
const service = new GeminiService(geminiConfig.apiKey, geminiConfig.model);
|
|
153
|
+
const contextData = fullChartData.slice(-VISIBLE_COUNT);
|
|
154
|
+
const preds = await service.predictNext(contextData, 3, view);
|
|
155
|
+
setPredictions(preds);
|
|
156
|
+
setWindowOffset(0);
|
|
157
|
+
} catch (e) {
|
|
158
|
+
console.error(e);
|
|
159
|
+
} finally {
|
|
160
|
+
setIsPredicting(false);
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const handleNext = () => {
|
|
165
|
+
if (windowOffset > 0) setWindowOffset(c => Math.max(0, c - 1));
|
|
166
|
+
}
|
|
167
|
+
const handlePrev = () => {
|
|
168
|
+
if (allData.length > VISIBLE_COUNT + windowOffset) setWindowOffset(c => c + 1);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const canGoBack = allData.length > VISIBLE_COUNT + windowOffset;
|
|
172
|
+
const canGoForward = windowOffset > 0;
|
|
173
|
+
|
|
174
|
+
return (
|
|
175
|
+
<View style={{ width: width as any }}>
|
|
176
|
+
{/* Header */}
|
|
177
|
+
<View style={{ flexDirection: 'row', justifyContent: 'flex-end', marginBottom: 10, alignItems: 'center' }}>
|
|
178
|
+
<View style={{ alignItems: 'flex-end', flex: 1, marginRight: 10 }}>
|
|
179
|
+
{activeItem ? (
|
|
180
|
+
<>
|
|
181
|
+
<Text style={{ fontSize: 14, fontWeight: 'bold' }}>{activeItem.label}</Text>
|
|
182
|
+
<Text style={{ fontSize: 12, color: '#666' }}>Total: {activeItem.value}</Text>
|
|
183
|
+
</>
|
|
184
|
+
) : (
|
|
185
|
+
<Text style={{ fontSize: 12, color: '#999' }}>Detailed Info Area</Text>
|
|
186
|
+
)}
|
|
187
|
+
</View>
|
|
188
|
+
|
|
189
|
+
{geminiConfig && (
|
|
190
|
+
<TouchableOpacity
|
|
191
|
+
onPress={handlePredict}
|
|
192
|
+
disabled={isPredicting}
|
|
193
|
+
style={[styles.button, { backgroundColor: '#6366f1', borderWidth: 0 }]}
|
|
194
|
+
accessible={true}
|
|
195
|
+
accessibilityLabel="Predict future values"
|
|
196
|
+
accessibilityRole="button"
|
|
197
|
+
accessibilityState={{ disabled: isPredicting }}
|
|
198
|
+
>
|
|
199
|
+
<Text style={[styles.buttonText, { color: '#fff', fontWeight: 'bold' }]}>
|
|
200
|
+
{isPredicting ? '...' : 'Predict'}
|
|
201
|
+
</Text>
|
|
202
|
+
</TouchableOpacity>
|
|
203
|
+
)}
|
|
204
|
+
|
|
205
|
+
{/* Calendar Button */}
|
|
206
|
+
<TouchableOpacity
|
|
207
|
+
onPress={() => setIsPickerVisible(true)}
|
|
208
|
+
style={{ marginLeft: 10, padding: 6, borderWidth: 1, borderColor: '#ddd', borderRadius: 4 }}
|
|
209
|
+
accessible={true}
|
|
210
|
+
accessibilityLabel="Open Date Picker"
|
|
211
|
+
accessibilityRole="button"
|
|
212
|
+
>
|
|
213
|
+
<Text>📅</Text>
|
|
214
|
+
</TouchableOpacity>
|
|
215
|
+
|
|
216
|
+
{/* Picker Modal */}
|
|
217
|
+
<Modal visible={isPickerVisible} transparent animationType="fade">
|
|
218
|
+
<View style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.6)', justifyContent: 'center', alignItems: 'center' }}>
|
|
219
|
+
<View style={{ backgroundColor: '#fff', width: 320, borderRadius: 16, padding: 24, shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.2, shadowRadius: 10, elevation: 5 }}>
|
|
220
|
+
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 20, alignItems: 'center' }}>
|
|
221
|
+
{pickerMode !== 'year' ? (
|
|
222
|
+
<TouchableOpacity onPress={() => setPickerMode(pickerMode === 'week' ? 'month' : 'year')} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
|
|
223
|
+
<Text style={{ color: '#6366f1', fontWeight: '600' }}>< Back</Text>
|
|
224
|
+
</TouchableOpacity>
|
|
225
|
+
) : <View style={{ width: 40 }} />}
|
|
226
|
+
|
|
227
|
+
<Text style={{ fontWeight: 'bold', fontSize: 16, color: '#1f2937' }}>
|
|
228
|
+
{pickerMode === 'year' ? 'Select Year' : pickerMode === 'month' ? `${filterDate.year}` : `${format(new Date(filterDate.year!, filterDate.month!, 1), 'MMM yyyy')}`}
|
|
229
|
+
</Text>
|
|
230
|
+
|
|
231
|
+
<TouchableOpacity onPress={() => setIsPickerVisible(false)} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
|
|
232
|
+
<Text style={{ color: '#9ca3af', fontSize: 20 }}>×</Text>
|
|
233
|
+
</TouchableOpacity>
|
|
234
|
+
</View>
|
|
235
|
+
|
|
236
|
+
<ScrollView style={{ maxHeight: 300 }} showsVerticalScrollIndicator={false}>
|
|
237
|
+
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 10, justifyContent: 'center' }}>
|
|
238
|
+
{pickerMode === 'year' && (
|
|
239
|
+
availableYears.map(year => (
|
|
240
|
+
<TouchableOpacity
|
|
241
|
+
key={year}
|
|
242
|
+
onPress={() => handleYearSelect(year)}
|
|
243
|
+
style={{
|
|
244
|
+
paddingVertical: 10,
|
|
245
|
+
width: '30%',
|
|
246
|
+
alignItems: 'center',
|
|
247
|
+
borderRadius: 8,
|
|
248
|
+
borderWidth: 1,
|
|
249
|
+
borderColor: filterDate.year === year ? '#6366f1' : '#e5e7eb',
|
|
250
|
+
backgroundColor: filterDate.year === year ? '#e0e7ff' : '#fff'
|
|
251
|
+
}}
|
|
252
|
+
>
|
|
253
|
+
<Text style={{
|
|
254
|
+
color: filterDate.year === year ? '#4338ca' : '#374151',
|
|
255
|
+
fontWeight: filterDate.year === year ? 'bold' : 'normal'
|
|
256
|
+
}}>{year}</Text>
|
|
257
|
+
</TouchableOpacity>
|
|
258
|
+
))
|
|
259
|
+
)}
|
|
260
|
+
{pickerMode === 'month' && (
|
|
261
|
+
Array.from({ length: 12 }).map((_, i) => (
|
|
262
|
+
<TouchableOpacity
|
|
263
|
+
key={i}
|
|
264
|
+
onPress={() => handleMonthSelect(i)}
|
|
265
|
+
style={{
|
|
266
|
+
paddingVertical: 10,
|
|
267
|
+
width: '30%',
|
|
268
|
+
alignItems: 'center',
|
|
269
|
+
borderRadius: 8,
|
|
270
|
+
borderWidth: 1,
|
|
271
|
+
borderColor: filterDate.month === i ? '#6366f1' : '#e5e7eb',
|
|
272
|
+
backgroundColor: filterDate.month === i ? '#e0e7ff' : '#fff'
|
|
273
|
+
}}
|
|
274
|
+
>
|
|
275
|
+
<Text style={{
|
|
276
|
+
color: filterDate.month === i ? '#4338ca' : '#374151',
|
|
277
|
+
fontWeight: filterDate.month === i ? 'bold' : 'normal'
|
|
278
|
+
}}>{format(new Date(2000, i, 1), 'MMM')}</Text>
|
|
279
|
+
</TouchableOpacity>
|
|
280
|
+
))
|
|
281
|
+
)}
|
|
282
|
+
{pickerMode === 'week' && filterDate.year && filterDate.month !== null && (
|
|
283
|
+
eachWeekOfInterval({
|
|
284
|
+
start: new Date(filterDate.year, filterDate.month, 1),
|
|
285
|
+
end: endOfMonth(new Date(filterDate.year, filterDate.month, 1))
|
|
286
|
+
}).map((weekStart, i) => {
|
|
287
|
+
const weekEnd = endOfWeek(weekStart);
|
|
288
|
+
const label = `${format(weekStart, 'd')} - ${format(weekEnd, 'd MMM')}`;
|
|
289
|
+
return (
|
|
290
|
+
<TouchableOpacity
|
|
291
|
+
key={weekStart.toISOString()}
|
|
292
|
+
onPress={() => handleWeekSelect(weekStart)}
|
|
293
|
+
style={{
|
|
294
|
+
paddingVertical: 10,
|
|
295
|
+
paddingHorizontal: 15,
|
|
296
|
+
width: '100%',
|
|
297
|
+
borderRadius: 8,
|
|
298
|
+
borderWidth: 1,
|
|
299
|
+
borderColor: '#e5e7eb',
|
|
300
|
+
backgroundColor: '#fff',
|
|
301
|
+
marginBottom: 5
|
|
302
|
+
}}
|
|
303
|
+
>
|
|
304
|
+
<Text style={{ color: '#374151' }}>Week {i + 1}: {label}</Text>
|
|
305
|
+
</TouchableOpacity>
|
|
306
|
+
)
|
|
307
|
+
})
|
|
308
|
+
)}
|
|
309
|
+
</View>
|
|
310
|
+
</ScrollView>
|
|
311
|
+
|
|
312
|
+
<TouchableOpacity onPress={clearFilter} style={{ marginTop: 20, alignItems: 'center', padding: 10 }}>
|
|
313
|
+
<Text style={{ color: '#ef4444', fontWeight: '500' }}>Reset Filter</Text>
|
|
314
|
+
</TouchableOpacity>
|
|
315
|
+
</View>
|
|
316
|
+
</View>
|
|
317
|
+
</Modal>
|
|
318
|
+
</View>
|
|
319
|
+
|
|
320
|
+
<View style={{ flexDirection: 'row', alignItems: 'center', height: containerHeight }}>
|
|
321
|
+
{/* Left Button */}
|
|
322
|
+
<TouchableOpacity
|
|
323
|
+
onPress={handlePrev}
|
|
324
|
+
disabled={!canGoBack}
|
|
325
|
+
style={[styles.arrowBtn, { opacity: canGoBack ? 1 : 0.3 }]}
|
|
326
|
+
accessible={true}
|
|
327
|
+
accessibilityLabel="Previous Period"
|
|
328
|
+
accessibilityRole="button"
|
|
329
|
+
accessibilityState={{ disabled: !canGoBack }}
|
|
330
|
+
>
|
|
331
|
+
<Text style={{ fontSize: 20 }}><</Text>
|
|
332
|
+
</TouchableOpacity>
|
|
333
|
+
|
|
334
|
+
<View
|
|
335
|
+
style={{ flex: 1, height: '100%' }}
|
|
336
|
+
onLayout={(e) => {
|
|
337
|
+
setLayoutWidth(e.nativeEvent.layout.width);
|
|
338
|
+
}}
|
|
339
|
+
>
|
|
340
|
+
<Svg width="100%" height="100%">
|
|
341
|
+
<G transform={`translate(${margin.left},${margin.top})`}>
|
|
342
|
+
{/* Gridlines & Y-Axis */}
|
|
343
|
+
{yScale.ticks(5).map(tickValue => (
|
|
344
|
+
<G key={`y-tick-${tickValue}`} transform={`translate(0, ${yScale(tickValue)})`}>
|
|
345
|
+
<Line x1={0} x2={chartWidth} stroke="#eee" strokeDasharray="4 4" />
|
|
346
|
+
<SvgText x={-10} y={4} textAnchor="end" fontSize={10} fill="#999">
|
|
347
|
+
{tickValue}
|
|
348
|
+
</SvgText>
|
|
349
|
+
</G>
|
|
350
|
+
))}
|
|
351
|
+
|
|
352
|
+
{/* Axis Lines */}
|
|
353
|
+
<Line x1={0} y1={chartHeight} x2={chartWidth} y2={chartHeight} stroke="#ccc" />
|
|
354
|
+
<Line x1={0} y1={0} x2={0} y2={chartHeight} stroke="#ccc" />
|
|
355
|
+
|
|
356
|
+
{/* Custom Axis Labels */}
|
|
357
|
+
{axisLabels?.y && (
|
|
358
|
+
<SvgText
|
|
359
|
+
x={-30}
|
|
360
|
+
y={chartHeight / 2}
|
|
361
|
+
rotation={-90}
|
|
362
|
+
origin={`-30, ${chartHeight / 2}`}
|
|
363
|
+
textAnchor="middle"
|
|
364
|
+
fill="#999"
|
|
365
|
+
fontSize={12}
|
|
366
|
+
>
|
|
367
|
+
{axisLabels.y}
|
|
368
|
+
</SvgText>
|
|
369
|
+
)}
|
|
370
|
+
{axisLabels?.x && (
|
|
371
|
+
<SvgText
|
|
372
|
+
x={chartWidth / 2}
|
|
373
|
+
y={chartHeight + 35}
|
|
374
|
+
textAnchor="middle"
|
|
375
|
+
fill="#999"
|
|
376
|
+
fontSize={12}
|
|
377
|
+
>
|
|
378
|
+
{axisLabels.x}
|
|
379
|
+
</SvgText>
|
|
380
|
+
)}
|
|
381
|
+
|
|
382
|
+
{/* Bars */}
|
|
383
|
+
{visibleData.map((d) => {
|
|
384
|
+
const xBand = xScale(d.id);
|
|
385
|
+
const bandwidth = xScale.bandwidth();
|
|
386
|
+
const maxBarWidth = 60;
|
|
387
|
+
const barWidth = Math.min(bandwidth, maxBarWidth);
|
|
388
|
+
const x = xBand! + (bandwidth - barWidth) / 2;
|
|
389
|
+
|
|
390
|
+
const yTotal = yScale(d.value);
|
|
391
|
+
const barHeightTotal = chartHeight - yTotal;
|
|
392
|
+
|
|
393
|
+
if (xBand === undefined) return null;
|
|
394
|
+
|
|
395
|
+
const isSelected = activeItem?.id === d.id;
|
|
396
|
+
const isDimmed = activeItem !== null && !isSelected;
|
|
397
|
+
|
|
398
|
+
// Stacked Rendering
|
|
399
|
+
if (variant === 'stacked' && d.stackedValues) {
|
|
400
|
+
let currentY = chartHeight;
|
|
401
|
+
return (
|
|
402
|
+
<G key={d.id}
|
|
403
|
+
onPress={() => setActiveItem(activeItem === d ? null : d)}
|
|
404
|
+
opacity={isDimmed ? 0.3 : 1}
|
|
405
|
+
>
|
|
406
|
+
{d.stackedValues.map((stack, i) => {
|
|
407
|
+
const segmentHeight = Math.abs(yScale(stack.value) - yScale(0));
|
|
408
|
+
const segmentY = currentY - segmentHeight;
|
|
409
|
+
const rect = (
|
|
410
|
+
<Rect
|
|
411
|
+
key={`${d.id}-${i}`}
|
|
412
|
+
x={x}
|
|
413
|
+
y={segmentY}
|
|
414
|
+
width={barWidth}
|
|
415
|
+
height={segmentHeight}
|
|
416
|
+
fill={stack.color}
|
|
417
|
+
stroke={isSelected ? '#fff' : 'none'}
|
|
418
|
+
strokeWidth={isSelected ? 1 : 0}
|
|
419
|
+
/>
|
|
420
|
+
);
|
|
421
|
+
currentY = segmentY;
|
|
422
|
+
return rect;
|
|
423
|
+
})}
|
|
424
|
+
{/* Hitbox */}
|
|
425
|
+
<Rect x={x} y={yTotal} width={barWidth} height={barHeightTotal} fill="transparent" onPress={() => setActiveItem(activeItem === d ? null : d)} />
|
|
426
|
+
</G>
|
|
427
|
+
)
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return (
|
|
431
|
+
<G key={d.id}>
|
|
432
|
+
<BarNative
|
|
433
|
+
x={x}
|
|
434
|
+
y={yTotal}
|
|
435
|
+
width={barWidth}
|
|
436
|
+
height={barHeightTotal}
|
|
437
|
+
data={d}
|
|
438
|
+
isActive={isSelected}
|
|
439
|
+
isDimmed={isDimmed}
|
|
440
|
+
onClick={(item) => {
|
|
441
|
+
setActiveItem(item === activeItem ? null : item);
|
|
442
|
+
}}
|
|
443
|
+
accessibilityLabel={`${d.label}, value ${d.value}`}
|
|
444
|
+
accessibilityRole="image"
|
|
445
|
+
/>
|
|
446
|
+
{isSelected && (
|
|
447
|
+
<SvgText
|
|
448
|
+
x={x + barWidth / 2}
|
|
449
|
+
y={yTotal - 5}
|
|
450
|
+
textAnchor="middle"
|
|
451
|
+
fill="#333"
|
|
452
|
+
fontSize={10}
|
|
453
|
+
fontWeight="bold"
|
|
454
|
+
>
|
|
455
|
+
{d.value}
|
|
456
|
+
</SvgText>
|
|
457
|
+
)}
|
|
458
|
+
</G>
|
|
459
|
+
);
|
|
460
|
+
})}
|
|
461
|
+
|
|
462
|
+
{/* X Axis Labels */}
|
|
463
|
+
{visibleData.map((d) => {
|
|
464
|
+
const x = xScale(d.id);
|
|
465
|
+
if (x === undefined) return null;
|
|
466
|
+
return (
|
|
467
|
+
<SvgText
|
|
468
|
+
key={`label-${d.id}`}
|
|
469
|
+
x={x + xScale.bandwidth() / 2}
|
|
470
|
+
y={chartHeight + 15}
|
|
471
|
+
textAnchor="middle"
|
|
472
|
+
fill="#666"
|
|
473
|
+
fontSize={10}
|
|
474
|
+
>
|
|
475
|
+
{d.label}
|
|
476
|
+
</SvgText>
|
|
477
|
+
)
|
|
478
|
+
})}
|
|
479
|
+
</G>
|
|
480
|
+
</Svg>
|
|
481
|
+
</View>
|
|
482
|
+
|
|
483
|
+
{/* Right Button */}
|
|
484
|
+
<TouchableOpacity
|
|
485
|
+
onPress={handleNext}
|
|
486
|
+
disabled={!canGoForward}
|
|
487
|
+
style={[styles.arrowBtn, { opacity: canGoForward ? 1 : 0.3 }]}
|
|
488
|
+
accessible={true}
|
|
489
|
+
accessibilityLabel="Next Period"
|
|
490
|
+
accessibilityRole="button"
|
|
491
|
+
accessibilityState={{ disabled: !canGoForward }}
|
|
492
|
+
>
|
|
493
|
+
<Text style={{ fontSize: 20 }}>></Text>
|
|
494
|
+
</TouchableOpacity>
|
|
495
|
+
</View>
|
|
496
|
+
</View>
|
|
497
|
+
);
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
const styles = StyleSheet.create({
|
|
501
|
+
button: {
|
|
502
|
+
paddingVertical: 6,
|
|
503
|
+
paddingHorizontal: 12,
|
|
504
|
+
borderRadius: 4,
|
|
505
|
+
alignItems: 'center',
|
|
506
|
+
justifyContent: 'center'
|
|
507
|
+
},
|
|
508
|
+
buttonText: {
|
|
509
|
+
fontSize: 12,
|
|
510
|
+
color: '#333'
|
|
511
|
+
},
|
|
512
|
+
arrowBtn: {
|
|
513
|
+
width: 40,
|
|
514
|
+
height: 40,
|
|
515
|
+
borderRadius: 20,
|
|
516
|
+
backgroundColor: '#fff',
|
|
517
|
+
alignItems: 'center',
|
|
518
|
+
justifyContent: 'center',
|
|
519
|
+
borderWidth: 1,
|
|
520
|
+
borderColor: '#ddd',
|
|
521
|
+
elevation: 2,
|
|
522
|
+
shadowColor: '#000',
|
|
523
|
+
shadowOpacity: 0.1,
|
|
524
|
+
shadowRadius: 2,
|
|
525
|
+
shadowOffset: { width: 0, height: 1 },
|
|
526
|
+
marginHorizontal: 5
|
|
527
|
+
}
|
|
528
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { render, screen, fireEvent } from '@testing-library/react';
|
|
3
|
+
import { SmartBarChart } from '../SmartBarChart';
|
|
4
|
+
import type { RawDataPoint } from '../types';
|
|
5
|
+
|
|
6
|
+
// Mock data
|
|
7
|
+
const mockData: RawDataPoint[] = [
|
|
8
|
+
{ id: '1', date: '2023-01-01', value: 100, label: 'Jan' },
|
|
9
|
+
{ id: '2', date: '2023-02-01', value: 200, label: 'Feb' },
|
|
10
|
+
{ id: '3', date: '2023-03-01', value: 150, label: 'Mar' }
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
const dataKeys = {
|
|
14
|
+
date: 'date',
|
|
15
|
+
value: 'value',
|
|
16
|
+
label: 'label'
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
describe('SmartBarChart', () => {
|
|
20
|
+
it('renders without crashing', () => {
|
|
21
|
+
render(<SmartBarChart data={mockData} dataKeys={dataKeys} view="month" />);
|
|
22
|
+
const chart = screen.getByRole('img', { name: /bar chart/i });
|
|
23
|
+
expect(chart).toBeInTheDocument();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('renders correct number of bars', async () => {
|
|
27
|
+
render(<SmartBarChart data={mockData} dataKeys={dataKeys} view="month" />);
|
|
28
|
+
// We expect individual bars with role="graphics-symbol"
|
|
29
|
+
// Wait for bars to render
|
|
30
|
+
const bars = screen.getAllByRole('graphics-symbol');
|
|
31
|
+
expect(bars.length).toBeGreaterThan(0);
|
|
32
|
+
// Note: Might be 3 or less depending on visibility logic, but here month view with 3 items should show all 3 if within window
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('displays axis labels', () => {
|
|
36
|
+
const axisLabels = { x: 'Time', y: 'Revenue' };
|
|
37
|
+
render(<SmartBarChart data={mockData} dataKeys={dataKeys} view="month" axisLabels={axisLabels} />);
|
|
38
|
+
expect(screen.getByText('Time')).toBeInTheDocument();
|
|
39
|
+
expect(screen.getByText('Revenue')).toBeInTheDocument();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('handles interaction (click)', () => {
|
|
43
|
+
render(<SmartBarChart data={mockData} dataKeys={dataKeys} view="month" />);
|
|
44
|
+
const bars = screen.getAllByRole('graphics-symbol');
|
|
45
|
+
const firstBar = bars[0];
|
|
46
|
+
|
|
47
|
+
fireEvent.click(firstBar);
|
|
48
|
+
// "100" appears on the axis AND as the active label.
|
|
49
|
+
// We want to verify the active label appears.
|
|
50
|
+
const labels = screen.getAllByText('100');
|
|
51
|
+
// We expect at least 2: one from axis, one from the active bar label
|
|
52
|
+
expect(labels.length).toBeGreaterThanOrEqual(2);
|
|
53
|
+
|
|
54
|
+
// Optional: verify one of them is bold (the active label)
|
|
55
|
+
const activeLabel = labels.find(l => l.getAttribute('font-weight') === 'bold');
|
|
56
|
+
expect(activeLabel).toBeInTheDocument();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('applies theme correctly', () => {
|
|
60
|
+
const theme = {
|
|
61
|
+
background: 'rgb(31, 41, 55)', // Custom bg
|
|
62
|
+
};
|
|
63
|
+
const { container } = render(<SmartBarChart data={mockData} dataKeys={dataKeys} view="month" theme={theme} />);
|
|
64
|
+
// Check if wrapper has background
|
|
65
|
+
// The wrapper class is smart-bar-chart-wrapper
|
|
66
|
+
const wrapper = container.querySelector('.smart-bar-chart-wrapper');
|
|
67
|
+
expect(wrapper).toHaveStyle('background: rgb(31, 41, 55)');
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import React, { useMemo } from 'react';
|
|
2
|
+
import type { DataPoint } from '../types';
|
|
3
|
+
|
|
4
|
+
interface BarProps extends Omit<React.SVGProps<SVGRectElement>, 'onClick' | 'onMouseOver' | 'onMouseOut'> {
|
|
5
|
+
x: number;
|
|
6
|
+
y: number;
|
|
7
|
+
width: number;
|
|
8
|
+
height: number;
|
|
9
|
+
data: DataPoint;
|
|
10
|
+
onClick?: (data: DataPoint) => void;
|
|
11
|
+
onHover?: (data: DataPoint | null) => void;
|
|
12
|
+
isActive?: boolean;
|
|
13
|
+
isDimmed?: boolean;
|
|
14
|
+
style?: React.CSSProperties;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const Bar: React.FC<BarProps> = ({
|
|
18
|
+
x, y, width, height, data, onClick, onHover, isActive, isDimmed, style, ...rest
|
|
19
|
+
}) => {
|
|
20
|
+
const fillColor = useMemo(() => {
|
|
21
|
+
if (data.isPrediction) return '#8e44ad'; // Purple for prediction
|
|
22
|
+
if (isActive) return '#e74c3c'; // Red for active
|
|
23
|
+
return data.color || '#3498db'; // Default blue
|
|
24
|
+
}, [data, isActive]);
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<rect
|
|
28
|
+
x={x}
|
|
29
|
+
y={y}
|
|
30
|
+
width={width}
|
|
31
|
+
height={height}
|
|
32
|
+
fill={fillColor}
|
|
33
|
+
rx={4} // Rounded corners top
|
|
34
|
+
ry={4}
|
|
35
|
+
className="transition-all duration-300 cursor-pointer hover:opacity-80"
|
|
36
|
+
onClick={() => onClick?.(data)}
|
|
37
|
+
onMouseEnter={() => onHover?.(data)}
|
|
38
|
+
onMouseLeave={() => onHover?.(null)}
|
|
39
|
+
style={{
|
|
40
|
+
opacity: isDimmed ? 0.3 : (data.isPrediction ? 0.7 : 1),
|
|
41
|
+
stroke: isActive ? '#c0392b' : 'none',
|
|
42
|
+
strokeWidth: isActive ? 2 : 0,
|
|
43
|
+
transition: 'all 0.3s ease',
|
|
44
|
+
...style
|
|
45
|
+
}}
|
|
46
|
+
{...rest}
|
|
47
|
+
/>
|
|
48
|
+
);
|
|
49
|
+
};
|