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,526 @@
|
|
|
1
|
+
import React, { useMemo, useState, useEffect } from 'react';
|
|
2
|
+
import { scaleBand, scaleLinear } from 'd3-scale';
|
|
3
|
+
import { getYear, getMonth, format, endOfWeek, eachWeekOfInterval, endOfMonth, isWithinInterval } from 'date-fns';
|
|
4
|
+
import { useChartData } from './hooks/useChartData';
|
|
5
|
+
import { Bar } from './components/Bar';
|
|
6
|
+
import type { SmartBarChartProps, DataPoint } from './types';
|
|
7
|
+
import { GeminiService } from './services/gemini';
|
|
8
|
+
|
|
9
|
+
export const SmartBarChart: React.FC<SmartBarChartProps> = ({
|
|
10
|
+
data,
|
|
11
|
+
view = 'month',
|
|
12
|
+
variant = 'default',
|
|
13
|
+
dataKeys,
|
|
14
|
+
geminiConfig,
|
|
15
|
+
colors,
|
|
16
|
+
axisLabels,
|
|
17
|
+
onViewChange,
|
|
18
|
+
height = 400,
|
|
19
|
+
width = '100%',
|
|
20
|
+
className = '',
|
|
21
|
+
theme
|
|
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 [isPickerOpen, setIsPickerOpen] = useState(false);
|
|
26
|
+
const [pickerMode, setPickerMode] = useState<'year' | 'month' | 'week'>('year');
|
|
27
|
+
|
|
28
|
+
// Filter Raw Data BEFORE hooks
|
|
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
|
+
// Derived Years for Picker
|
|
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
|
+
|
|
58
|
+
// Navigation State
|
|
59
|
+
const VISIBLE_COUNT = view === 'month' ? 12 : 7;
|
|
60
|
+
const [windowOffset, setWindowOffset] = useState(0);
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
setWindowOffset(0);
|
|
64
|
+
setPredictions([]);
|
|
65
|
+
|
|
66
|
+
// Auto-cleanup filters based on View hierarchy
|
|
67
|
+
// If we switch to Month view, we shouldn't be filtered to a specific Month or Week.
|
|
68
|
+
if (view === 'month') {
|
|
69
|
+
setFilterDate(prev => {
|
|
70
|
+
if (prev.month !== null || prev.weekStartDate !== null) {
|
|
71
|
+
return { ...prev, month: null, weekStartDate: null };
|
|
72
|
+
}
|
|
73
|
+
return prev;
|
|
74
|
+
});
|
|
75
|
+
setPickerMode('year'); // Reset picker to top level
|
|
76
|
+
} else if (view === 'week') {
|
|
77
|
+
setFilterDate(prev => {
|
|
78
|
+
if (prev.weekStartDate !== null) {
|
|
79
|
+
return { ...prev, weekStartDate: null };
|
|
80
|
+
}
|
|
81
|
+
return prev;
|
|
82
|
+
});
|
|
83
|
+
setPickerMode('month'); // Reset picker to Month level
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
}, [view]);
|
|
87
|
+
|
|
88
|
+
// Separate effect for filterDate so we don't create circular loops with the above
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
setWindowOffset(0);
|
|
91
|
+
setPredictions([]);
|
|
92
|
+
}, [filterDate]);
|
|
93
|
+
|
|
94
|
+
const handleYearSelect = (year: number) => {
|
|
95
|
+
setFilterDate({ year, month: null, weekStartDate: null });
|
|
96
|
+
setPickerMode('month');
|
|
97
|
+
onViewChange?.('month');
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const handleMonthSelect = (monthIndex: number) => {
|
|
101
|
+
setFilterDate(prev => ({ ...prev, month: monthIndex, weekStartDate: null }));
|
|
102
|
+
setPickerMode('week');
|
|
103
|
+
onViewChange?.('week');
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const handleWeekSelect = (weekStart: Date) => {
|
|
107
|
+
setFilterDate(prev => ({ ...prev, weekStartDate: weekStart }));
|
|
108
|
+
setIsPickerOpen(false);
|
|
109
|
+
onViewChange?.('day');
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const clearFilter = () => {
|
|
113
|
+
setFilterDate({ year: null, month: null, weekStartDate: null });
|
|
114
|
+
setPickerMode('year');
|
|
115
|
+
setIsPickerOpen(false);
|
|
116
|
+
onViewChange?.('month'); // Reset View
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// Data Slicing
|
|
120
|
+
const allData = useMemo(() => [...fullChartData, ...predictions], [fullChartData, predictions]);
|
|
121
|
+
const visibleData = useMemo(() => {
|
|
122
|
+
const totalLen = allData.length;
|
|
123
|
+
const start = Math.max(0, totalLen - VISIBLE_COUNT - windowOffset);
|
|
124
|
+
const end = Math.min(totalLen, totalLen - windowOffset);
|
|
125
|
+
return allData.slice(start, end);
|
|
126
|
+
}, [allData, windowOffset, VISIBLE_COUNT]);
|
|
127
|
+
|
|
128
|
+
// Dimensions
|
|
129
|
+
const margin = { top: 40, right: 20, bottom: 40, left: 40 };
|
|
130
|
+
// Container height is shared, but we need to account for layout.
|
|
131
|
+
// Let's assume height prop is for the chart area itself mostly.
|
|
132
|
+
const internalWidth = 800; // SVG internal coord system
|
|
133
|
+
const chartWidth = internalWidth - margin.left - margin.right;
|
|
134
|
+
const chartHeight = (typeof height === 'number' ? height : 400) - margin.top - margin.bottom;
|
|
135
|
+
|
|
136
|
+
// Scales
|
|
137
|
+
const xScale = useMemo(() => {
|
|
138
|
+
return scaleBand()
|
|
139
|
+
.domain(visibleData.map(d => d.id))
|
|
140
|
+
.range([0, chartWidth])
|
|
141
|
+
.padding(0.3);
|
|
142
|
+
}, [visibleData, chartWidth]);
|
|
143
|
+
|
|
144
|
+
const yScale = useMemo(() => {
|
|
145
|
+
const maxVal = Math.max(...visibleData.map(d => d.value), 0);
|
|
146
|
+
return scaleLinear()
|
|
147
|
+
.domain([0, maxVal * 1.1])
|
|
148
|
+
.range([chartHeight, 0]);
|
|
149
|
+
}, [visibleData, chartHeight]);
|
|
150
|
+
|
|
151
|
+
const handlePredict = async () => {
|
|
152
|
+
if (!geminiConfig?.apiKey) return;
|
|
153
|
+
setIsPredicting(true);
|
|
154
|
+
try {
|
|
155
|
+
const service = new GeminiService(geminiConfig.apiKey, geminiConfig.model);
|
|
156
|
+
const contextData = fullChartData.slice(-VISIBLE_COUNT);
|
|
157
|
+
const preds = await service.predictNext(contextData, 3, view);
|
|
158
|
+
setPredictions(preds);
|
|
159
|
+
setWindowOffset(0);
|
|
160
|
+
} catch (e) {
|
|
161
|
+
console.error(e);
|
|
162
|
+
} finally {
|
|
163
|
+
setIsPredicting(false);
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const handleNext = () => {
|
|
168
|
+
if (windowOffset > 0) setWindowOffset(c => Math.max(0, c - 1));
|
|
169
|
+
}
|
|
170
|
+
const handlePrev = () => {
|
|
171
|
+
if (allData.length > VISIBLE_COUNT + windowOffset) setWindowOffset(c => c + 1);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const canGoBack = allData.length > VISIBLE_COUNT + windowOffset;
|
|
175
|
+
const canGoForward = windowOffset > 0;
|
|
176
|
+
|
|
177
|
+
// Theme defaults
|
|
178
|
+
const bg = theme?.background || '#fff';
|
|
179
|
+
const barRadius = theme?.bar?.radius ?? 4;
|
|
180
|
+
const barOpacity = theme?.bar?.opacity ?? 1;
|
|
181
|
+
const maxBarWidth = theme?.bar?.maxWidth ?? 60;
|
|
182
|
+
|
|
183
|
+
const gridStroke = theme?.grid?.stroke || '#eee';
|
|
184
|
+
const gridDash = theme?.grid?.strokeDasharray || '4 4';
|
|
185
|
+
const gridVisible = theme?.grid?.visible !== false;
|
|
186
|
+
|
|
187
|
+
const axisColor = theme?.axis?.labelColor || '#9ca3af';
|
|
188
|
+
const tickColor = theme?.axis?.tickColor || '#9ca3af';
|
|
189
|
+
const axisSize = theme?.axis?.fontSize || 10;
|
|
190
|
+
|
|
191
|
+
return (
|
|
192
|
+
<div className={`smart-bar-chart-wrapper ${className}`} style={{ width, display: 'flex', flexDirection: 'column', gap: 10, fontFamily: 'sans-serif', background: bg, padding: 16, borderRadius: 12 }}>
|
|
193
|
+
|
|
194
|
+
{/* Legend / Info / Predict Header */}
|
|
195
|
+
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', height: 40 }}>
|
|
196
|
+
{/* Info Panel */}
|
|
197
|
+
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', marginRight: 10 }}>
|
|
198
|
+
{activeItem ? (
|
|
199
|
+
<>
|
|
200
|
+
<div style={{ fontSize: 14, fontWeight: 'bold' }}>{activeItem.label}</div>
|
|
201
|
+
<div style={{ fontSize: 12, color: '#666' }}>Total: {activeItem.value}</div>
|
|
202
|
+
</>
|
|
203
|
+
) : (
|
|
204
|
+
<div style={{ fontSize: 12, color: '#999' }}>Detailed Info Area</div>
|
|
205
|
+
)}
|
|
206
|
+
</div>
|
|
207
|
+
|
|
208
|
+
{geminiConfig && (
|
|
209
|
+
<button
|
|
210
|
+
onClick={handlePredict}
|
|
211
|
+
disabled={isPredicting}
|
|
212
|
+
style={{ fontSize: 10, padding: '6px 12px', cursor: 'pointer', background: '#6366f1', color: 'white', border: 'none', borderRadius: 4, fontWeight: 'bold' }}
|
|
213
|
+
>
|
|
214
|
+
{isPredicting ? 'Running...' : 'Predict AI'}
|
|
215
|
+
</button>
|
|
216
|
+
)}
|
|
217
|
+
|
|
218
|
+
{/* Calendar Button */}
|
|
219
|
+
<button
|
|
220
|
+
onClick={() => setIsPickerOpen(!isPickerOpen)}
|
|
221
|
+
style={{
|
|
222
|
+
marginLeft: 10, padding: 6, cursor: 'pointer', background: '#fff', border: '1px solid #ddd', borderRadius: 4,
|
|
223
|
+
display: 'flex', alignItems: 'center', justifyContent: 'center'
|
|
224
|
+
}}
|
|
225
|
+
>
|
|
226
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#666" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
227
|
+
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
|
|
228
|
+
<line x1="16" y1="2" x2="16" y2="6"></line>
|
|
229
|
+
<line x1="8" y1="2" x2="8" y2="6"></line>
|
|
230
|
+
<line x1="3" y1="10" x2="21" y2="10"></line>
|
|
231
|
+
</svg>
|
|
232
|
+
</button>
|
|
233
|
+
|
|
234
|
+
{/* Calendar Picker Popup */}
|
|
235
|
+
{isPickerOpen && (
|
|
236
|
+
<div style={{
|
|
237
|
+
position: 'absolute', top: 50, right: 0, width: 240, background: '#fff',
|
|
238
|
+
border: '1px solid #e0e0e0', borderRadius: 12,
|
|
239
|
+
boxShadow: '0 10px 25px rgba(0,0,0,0.1)', zIndex: 30, padding: 16,
|
|
240
|
+
fontFamily: 'sans-serif'
|
|
241
|
+
}}>
|
|
242
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16, alignItems: 'center' }}>
|
|
243
|
+
{pickerMode !== 'year' && (
|
|
244
|
+
<button
|
|
245
|
+
onClick={() => setPickerMode(pickerMode === 'week' ? 'month' : 'year')}
|
|
246
|
+
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 13, color: '#6366f1', fontWeight: 600 }}
|
|
247
|
+
>
|
|
248
|
+
< Back
|
|
249
|
+
</button>
|
|
250
|
+
)}
|
|
251
|
+
<div style={{ fontSize: 14, fontWeight: 'bold', color: '#1f2937', flex: 1, textAlign: pickerMode !== 'year' ? 'center' : 'left' }}>
|
|
252
|
+
{pickerMode === 'year' ? 'Select Year' : pickerMode === 'month' ? `${filterDate.year}` : `${format(new Date(filterDate.year!, filterDate.month!, 1), 'MMM yyyy')}`}
|
|
253
|
+
</div>
|
|
254
|
+
<button
|
|
255
|
+
onClick={() => { clearFilter(); }}
|
|
256
|
+
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: '#ef4444' }}
|
|
257
|
+
>
|
|
258
|
+
Reset
|
|
259
|
+
</button>
|
|
260
|
+
</div>
|
|
261
|
+
|
|
262
|
+
<div style={{ display: 'grid', gridTemplateColumns: pickerMode === 'week' ? '1fr' : 'repeat(3, 1fr)', gap: 8 }}>
|
|
263
|
+
{pickerMode === 'year' && (
|
|
264
|
+
availableYears.map(year => (
|
|
265
|
+
<button
|
|
266
|
+
key={year}
|
|
267
|
+
onClick={() => handleYearSelect(year)}
|
|
268
|
+
style={{
|
|
269
|
+
padding: '8px 4px', fontSize: 13,
|
|
270
|
+
border: '1px solid',
|
|
271
|
+
borderColor: filterDate.year === year ? '#6366f1' : '#eee',
|
|
272
|
+
borderRadius: 6,
|
|
273
|
+
background: filterDate.year === year ? '#e0e7ff' : '#fff',
|
|
274
|
+
color: filterDate.year === year ? '#4338ca' : '#374151',
|
|
275
|
+
cursor: 'pointer',
|
|
276
|
+
transition: 'all 0.2s'
|
|
277
|
+
}}
|
|
278
|
+
>
|
|
279
|
+
{year}
|
|
280
|
+
</button>
|
|
281
|
+
))
|
|
282
|
+
)}
|
|
283
|
+
{pickerMode === 'month' && (
|
|
284
|
+
Array.from({ length: 12 }).map((_, i) => (
|
|
285
|
+
<button
|
|
286
|
+
key={i}
|
|
287
|
+
onClick={() => handleMonthSelect(i)}
|
|
288
|
+
style={{
|
|
289
|
+
padding: '8px 4px', fontSize: 13,
|
|
290
|
+
border: '1px solid',
|
|
291
|
+
borderColor: filterDate.month === i ? '#6366f1' : '#eee',
|
|
292
|
+
borderRadius: 6,
|
|
293
|
+
background: filterDate.month === i ? '#e0e7ff' : '#fff',
|
|
294
|
+
color: filterDate.month === i ? '#4338ca' : '#374151',
|
|
295
|
+
cursor: 'pointer',
|
|
296
|
+
transition: 'all 0.2s'
|
|
297
|
+
}}
|
|
298
|
+
>
|
|
299
|
+
{format(new Date(2000, i, 1), 'MMM')}
|
|
300
|
+
</button>
|
|
301
|
+
))
|
|
302
|
+
)}
|
|
303
|
+
{pickerMode === 'week' && filterDate.year && filterDate.month !== null && (
|
|
304
|
+
eachWeekOfInterval({
|
|
305
|
+
start: new Date(filterDate.year, filterDate.month, 1),
|
|
306
|
+
end: endOfMonth(new Date(filterDate.year, filterDate.month, 1))
|
|
307
|
+
}).map((weekStart, i) => {
|
|
308
|
+
// Only show weeks that actually overlap with the month meaningfully?
|
|
309
|
+
// eachWeekOfInterval gives standard weeks.
|
|
310
|
+
const weekEnd = endOfWeek(weekStart);
|
|
311
|
+
const label = `${format(weekStart, 'd')} - ${format(weekEnd, 'd MMM')}`;
|
|
312
|
+
return (
|
|
313
|
+
<button
|
|
314
|
+
key={weekStart.toISOString()}
|
|
315
|
+
onClick={() => handleWeekSelect(weekStart)}
|
|
316
|
+
style={{
|
|
317
|
+
padding: '8px 12px', fontSize: 13,
|
|
318
|
+
border: '1px solid',
|
|
319
|
+
borderColor: '#eee',
|
|
320
|
+
borderRadius: 6,
|
|
321
|
+
background: '#fff',
|
|
322
|
+
color: '#374151',
|
|
323
|
+
cursor: 'pointer',
|
|
324
|
+
transition: 'all 0.2s',
|
|
325
|
+
textAlign: 'left'
|
|
326
|
+
}}
|
|
327
|
+
>
|
|
328
|
+
Week {i + 1}: {label}
|
|
329
|
+
</button>
|
|
330
|
+
)
|
|
331
|
+
})
|
|
332
|
+
)}
|
|
333
|
+
</div>
|
|
334
|
+
</div>
|
|
335
|
+
)}
|
|
336
|
+
</div>
|
|
337
|
+
|
|
338
|
+
<div style={{ display: 'flex', alignItems: 'center' }}>
|
|
339
|
+
{/* Left Button */}
|
|
340
|
+
<button
|
|
341
|
+
onClick={handlePrev}
|
|
342
|
+
disabled={!canGoBack}
|
|
343
|
+
style={{
|
|
344
|
+
flexShrink: 0, width: 40, height: 40, borderRadius: '50%', border: '1px solid #ddd', background: '#fff',
|
|
345
|
+
cursor: canGoBack ? 'pointer' : 'default', opacity: canGoBack ? 1 : 0.3,
|
|
346
|
+
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 18, color: '#333', marginRight: 10
|
|
347
|
+
}}
|
|
348
|
+
>
|
|
349
|
+
<
|
|
350
|
+
</button>
|
|
351
|
+
|
|
352
|
+
{/* Chart Area */}
|
|
353
|
+
<div style={{ flexGrow: 1, height: typeof height === 'number' ? height : 400, position: 'relative' }}>
|
|
354
|
+
<svg
|
|
355
|
+
width="100%"
|
|
356
|
+
height="100%"
|
|
357
|
+
viewBox={`0 0 ${internalWidth} ${(typeof height === 'number' ? height : 400)}`}
|
|
358
|
+
preserveAspectRatio="none"
|
|
359
|
+
style={{ overflow: 'visible' }}
|
|
360
|
+
role="img"
|
|
361
|
+
aria-label="Bar chart showing data over time"
|
|
362
|
+
>
|
|
363
|
+
<g transform={`translate(${margin.left},${margin.top})`}>
|
|
364
|
+
{/* Gridlines & Y-Axis */}
|
|
365
|
+
{gridVisible && yScale.ticks(5).map(tickValue => (
|
|
366
|
+
<g key={`y-tick-${tickValue}`} transform={`translate(0, ${yScale(tickValue)})`}>
|
|
367
|
+
<line x1={0} x2={chartWidth} stroke={gridStroke} strokeDasharray={gridDash} />
|
|
368
|
+
<text x={-10} y={4} textAnchor="end" fontSize={axisSize} fill={tickColor}>
|
|
369
|
+
{tickValue}
|
|
370
|
+
</text>
|
|
371
|
+
</g>
|
|
372
|
+
))}
|
|
373
|
+
|
|
374
|
+
{/* Axis Lines */}
|
|
375
|
+
<line x1={0} y1={chartHeight} x2={chartWidth} y2={chartHeight} stroke={gridStroke} />
|
|
376
|
+
<line x1={0} y1={0} x2={0} y2={chartHeight} stroke={gridStroke} />
|
|
377
|
+
|
|
378
|
+
{/* Custom Axis Labels */}
|
|
379
|
+
{axisLabels?.y && (
|
|
380
|
+
<text
|
|
381
|
+
x={-30}
|
|
382
|
+
y={chartHeight / 2}
|
|
383
|
+
transform={`rotate(-90, -30, ${chartHeight / 2})`}
|
|
384
|
+
textAnchor="middle"
|
|
385
|
+
fill={axisColor}
|
|
386
|
+
fontSize={12}
|
|
387
|
+
>
|
|
388
|
+
{axisLabels.y}
|
|
389
|
+
</text>
|
|
390
|
+
)}
|
|
391
|
+
{axisLabels?.x && (
|
|
392
|
+
<text
|
|
393
|
+
x={chartWidth / 2}
|
|
394
|
+
y={chartHeight + 35}
|
|
395
|
+
textAnchor="middle"
|
|
396
|
+
fill={axisColor}
|
|
397
|
+
fontSize={12}
|
|
398
|
+
>
|
|
399
|
+
{axisLabels.x}
|
|
400
|
+
</text>
|
|
401
|
+
)}
|
|
402
|
+
|
|
403
|
+
{/* Bars */}
|
|
404
|
+
{visibleData.map((d) => {
|
|
405
|
+
const xBand = xScale(d.id);
|
|
406
|
+
const bandwidth = xScale.bandwidth();
|
|
407
|
+
const barWidth = Math.min(bandwidth, maxBarWidth);
|
|
408
|
+
const x = xBand! + (bandwidth - barWidth) / 2; // Center the bar
|
|
409
|
+
|
|
410
|
+
const yTotal = yScale(d.value);
|
|
411
|
+
const barHeightTotal = chartHeight - yTotal;
|
|
412
|
+
|
|
413
|
+
if (xBand === undefined) return null;
|
|
414
|
+
|
|
415
|
+
const isSelected = activeItem?.id === d.id;
|
|
416
|
+
const isDimmed = activeItem !== null && !isSelected;
|
|
417
|
+
|
|
418
|
+
// Stacked Rendering
|
|
419
|
+
if (variant === 'stacked' && d.stackedValues) {
|
|
420
|
+
let currentY = chartHeight;
|
|
421
|
+
return (
|
|
422
|
+
<g key={d.id}
|
|
423
|
+
onMouseEnter={() => setActiveItem(d)}
|
|
424
|
+
onMouseLeave={() => setActiveItem(null)}
|
|
425
|
+
onClick={() => setActiveItem(activeItem === d ? null : d)}
|
|
426
|
+
style={{ opacity: isDimmed ? 0.3 : 1, transition: 'opacity 0.3s' }}
|
|
427
|
+
>
|
|
428
|
+
{d.stackedValues.map((stack, i) => {
|
|
429
|
+
const segmentHeight = Math.abs(yScale(stack.value) - yScale(0));
|
|
430
|
+
const segmentY = currentY - segmentHeight;
|
|
431
|
+
const rect = (
|
|
432
|
+
<rect
|
|
433
|
+
key={`${d.id}-${i}`}
|
|
434
|
+
x={x}
|
|
435
|
+
y={segmentY}
|
|
436
|
+
width={barWidth}
|
|
437
|
+
height={segmentHeight}
|
|
438
|
+
fill={stack.color}
|
|
439
|
+
rx={barRadius}
|
|
440
|
+
opacity={barOpacity}
|
|
441
|
+
stroke={isSelected ? '#fff' : 'none'}
|
|
442
|
+
strokeWidth={isSelected ? 1 : 0}
|
|
443
|
+
/>
|
|
444
|
+
);
|
|
445
|
+
currentY = segmentY;
|
|
446
|
+
return rect;
|
|
447
|
+
})}
|
|
448
|
+
{/* Transparent overlay */}
|
|
449
|
+
<rect x={x} y={yTotal} width={barWidth} height={barHeightTotal} fill="transparent" />
|
|
450
|
+
</g>
|
|
451
|
+
)
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Default Single Bar
|
|
455
|
+
return (
|
|
456
|
+
<g key={d.id}>
|
|
457
|
+
<Bar
|
|
458
|
+
x={x}
|
|
459
|
+
y={yTotal}
|
|
460
|
+
width={barWidth}
|
|
461
|
+
height={barHeightTotal}
|
|
462
|
+
data={d}
|
|
463
|
+
isActive={isSelected}
|
|
464
|
+
isDimmed={isDimmed}
|
|
465
|
+
onHover={setActiveItem}
|
|
466
|
+
onClick={(item) => {
|
|
467
|
+
setActiveItem(item === activeItem ? null : item);
|
|
468
|
+
}}
|
|
469
|
+
style={{ rx: barRadius, fillOpacity: barOpacity }}
|
|
470
|
+
aria-label={`${d.label}: ${d.value}`}
|
|
471
|
+
role="graphics-symbol"
|
|
472
|
+
/>
|
|
473
|
+
{isSelected && (
|
|
474
|
+
<text
|
|
475
|
+
x={x + barWidth / 2}
|
|
476
|
+
y={yTotal - 5}
|
|
477
|
+
textAnchor="middle"
|
|
478
|
+
fill="#333"
|
|
479
|
+
fontSize={12}
|
|
480
|
+
fontWeight="bold"
|
|
481
|
+
pointerEvents="none"
|
|
482
|
+
>
|
|
483
|
+
{d.value}
|
|
484
|
+
</text>
|
|
485
|
+
)}
|
|
486
|
+
</g>
|
|
487
|
+
);
|
|
488
|
+
})}
|
|
489
|
+
|
|
490
|
+
{/* X Axis Labels */}
|
|
491
|
+
{visibleData.map((d) => {
|
|
492
|
+
const x = xScale(d.id);
|
|
493
|
+
if (x === undefined) return null;
|
|
494
|
+
return (
|
|
495
|
+
<text
|
|
496
|
+
key={`label-${d.id}`}
|
|
497
|
+
x={x + xScale.bandwidth() / 2}
|
|
498
|
+
y={chartHeight + 15}
|
|
499
|
+
textAnchor="middle"
|
|
500
|
+
fill={tickColor}
|
|
501
|
+
fontSize={axisSize}
|
|
502
|
+
>
|
|
503
|
+
{d.label}
|
|
504
|
+
</text>
|
|
505
|
+
)
|
|
506
|
+
})}
|
|
507
|
+
</g>
|
|
508
|
+
</svg>
|
|
509
|
+
</div>
|
|
510
|
+
|
|
511
|
+
{/* Right Button */}
|
|
512
|
+
<button
|
|
513
|
+
onClick={handleNext}
|
|
514
|
+
disabled={!canGoForward}
|
|
515
|
+
style={{
|
|
516
|
+
flexShrink: 0, width: 40, height: 40, borderRadius: '50%', border: '1px solid #ddd', background: '#fff',
|
|
517
|
+
cursor: canGoForward ? 'pointer' : 'default', opacity: canGoForward ? 1 : 0.3,
|
|
518
|
+
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 18, color: '#333', marginLeft: 10
|
|
519
|
+
}}
|
|
520
|
+
>
|
|
521
|
+
>
|
|
522
|
+
</button>
|
|
523
|
+
</div>
|
|
524
|
+
</div>
|
|
525
|
+
);
|
|
526
|
+
};
|