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.
@@ -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' }}>&lt; 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 }}>&lt;</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 }}>&gt;</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
+ };