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,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
+ &lt; 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
+ &lt;
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
+ &gt;
522
+ </button>
523
+ </div>
524
+ </div>
525
+ );
526
+ };