Skip to content

Commit 4b2796c

Browse files
mortenatorclaude
andcommitted
Add advanced features (Phase 7)
- Waterfall charts: running totals, positive/negative coloring, total/subtotal bars, connector lines - Axis breaks: zigzag and straight break styles, UI for adding breaks - Theme system: 5 preset themes (Corporate, Colorful, Monochrome, Dark, Pastel) with color palettes and style options - Theme-aware rendering pipeline for consistent styling Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 90767fe commit 4b2796c

10 files changed

Lines changed: 1460 additions & 21 deletions

File tree

src/components/AxisPanel.tsx

Lines changed: 153 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React, { useState, useEffect } from "react";
2-
import { Axis, ScaleType } from "../types/vectorChart";
2+
import { Axis, AxisBreak, ScaleType } from "../types/vectorChart";
3+
import { createAxisBreak } from "../rendering/axes";
34

45
interface AxisPanelProps {
56
axis: Axis;
@@ -119,6 +120,14 @@ const AxisPanel: React.FC<AxisPanelProps> = ({ axis, label, onChange }) => {
119120
</label>
120121
</div>
121122

123+
{/* Axis Breaks */}
124+
{axis.orientation === "y" && (
125+
<AxisBreakEditor
126+
breaks={localAxis.breaks || []}
127+
onChange={(breaks) => handleChange({ breaks })}
128+
/>
129+
)}
130+
122131
{/* Reset Button */}
123132
<button
124133
onClick={() =>
@@ -184,4 +193,147 @@ export const AxisConfiguration: React.FC<AxisConfigurationProps> = ({
184193
);
185194
};
186195

196+
// ============================================================================
197+
// Axis Break Editor
198+
// ============================================================================
199+
200+
interface AxisBreakEditorProps {
201+
breaks: AxisBreak[];
202+
onChange: (breaks: AxisBreak[]) => void;
203+
}
204+
205+
const AxisBreakEditor: React.FC<AxisBreakEditorProps> = ({
206+
breaks,
207+
onChange,
208+
}) => {
209+
const [showAddForm, setShowAddForm] = useState(false);
210+
const [startValue, setStartValue] = useState("");
211+
const [endValue, setEndValue] = useState("");
212+
const [style, setStyle] = useState<"wiggle" | "straight">("wiggle");
213+
214+
const handleAddBreak = () => {
215+
const start = parseFloat(startValue);
216+
const end = parseFloat(endValue);
217+
if (isNaN(start) || isNaN(end) || start >= end) return;
218+
219+
const newBreak = createAxisBreak(start, end, style);
220+
onChange([...breaks, newBreak]);
221+
setStartValue("");
222+
setEndValue("");
223+
setShowAddForm(false);
224+
};
225+
226+
const handleRemoveBreak = (id: string) => {
227+
onChange(breaks.filter((b) => b.id !== id));
228+
};
229+
230+
return (
231+
<div className="border-t pt-3 mt-3">
232+
<label className="block text-xs font-medium text-gray-600 mb-2">
233+
Axis Breaks
234+
</label>
235+
236+
{/* Existing breaks */}
237+
{breaks.length > 0 && (
238+
<div className="space-y-1 mb-2">
239+
{breaks.map((b) => (
240+
<div
241+
key={b.id}
242+
className="flex items-center justify-between p-2 bg-gray-50 rounded text-xs"
243+
>
244+
<span>
245+
{b.startValue}{b.endValue} ({b.style})
246+
</span>
247+
<button
248+
onClick={() => handleRemoveBreak(b.id)}
249+
className="text-gray-400 hover:text-red-500"
250+
>
251+
<svg
252+
className="w-4 h-4"
253+
fill="none"
254+
stroke="currentColor"
255+
viewBox="0 0 24 24"
256+
>
257+
<path
258+
strokeLinecap="round"
259+
strokeLinejoin="round"
260+
strokeWidth={2}
261+
d="M6 18L18 6M6 6l12 12"
262+
/>
263+
</svg>
264+
</button>
265+
</div>
266+
))}
267+
</div>
268+
)}
269+
270+
{/* Add break form */}
271+
{showAddForm ? (
272+
<div className="space-y-2 p-2 bg-blue-50 rounded">
273+
<div className="grid grid-cols-2 gap-2">
274+
<div>
275+
<label className="block text-xs text-gray-500 mb-1">From</label>
276+
<input
277+
type="number"
278+
value={startValue}
279+
onChange={(e) => setStartValue(e.target.value)}
280+
placeholder="Start"
281+
className="w-full px-2 py-1 text-xs border border-gray-300 rounded"
282+
/>
283+
</div>
284+
<div>
285+
<label className="block text-xs text-gray-500 mb-1">To</label>
286+
<input
287+
type="number"
288+
value={endValue}
289+
onChange={(e) => setEndValue(e.target.value)}
290+
placeholder="End"
291+
className="w-full px-2 py-1 text-xs border border-gray-300 rounded"
292+
/>
293+
</div>
294+
</div>
295+
<div>
296+
<label className="block text-xs text-gray-500 mb-1">Style</label>
297+
<select
298+
value={style}
299+
onChange={(e) => setStyle(e.target.value as "wiggle" | "straight")}
300+
className="w-full px-2 py-1 text-xs border border-gray-300 rounded"
301+
>
302+
<option value="wiggle">Zigzag</option>
303+
<option value="straight">Straight</option>
304+
</select>
305+
</div>
306+
<div className="flex gap-2">
307+
<button
308+
onClick={handleAddBreak}
309+
className="flex-1 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700"
310+
>
311+
Add Break
312+
</button>
313+
<button
314+
onClick={() => setShowAddForm(false)}
315+
className="px-3 py-1 text-xs text-gray-600 hover:text-gray-800"
316+
>
317+
Cancel
318+
</button>
319+
</div>
320+
</div>
321+
) : (
322+
<button
323+
onClick={() => setShowAddForm(true)}
324+
className="w-full py-1.5 text-xs text-blue-600 border border-blue-300 rounded hover:bg-blue-50"
325+
>
326+
+ Add Axis Break
327+
</button>
328+
)}
329+
330+
{breaks.length === 0 && !showAddForm && (
331+
<p className="text-xs text-gray-400 italic mt-1">
332+
Breaks hide a range of values to focus on relevant data.
333+
</p>
334+
)}
335+
</div>
336+
);
337+
};
338+
187339
export default AxisPanel;

src/components/ChartPanel.tsx

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import DataGrid from "./DataGrid";
1717
import { AxisConfiguration } from "./AxisPanel";
1818
import LabelSettings from "./LabelSettings";
1919
import AnnotationPanel from "./AnnotationPanel";
20+
import { ThemeSelector } from "./ThemePanel";
2021

2122
interface ChartPanelProps {
2223
isLoading: boolean;
@@ -83,6 +84,25 @@ const chartTypes: { type: ChartType; label: string; icon: JSX.Element }[] = [
8384
</svg>
8485
),
8586
},
87+
{
88+
type: "waterfall",
89+
label: "Waterfall",
90+
icon: (
91+
<svg className="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
92+
{/* Rising bar from bottom */}
93+
<rect x="3" y="14" width="3" height="6" fill="#10b981" />
94+
{/* Floating bar (positive) */}
95+
<rect x="8" y="10" width="3" height="4" fill="#10b981" />
96+
{/* Floating bar (negative) */}
97+
<rect x="13" y="12" width="3" height="4" fill="#ef4444" />
98+
{/* Final total bar */}
99+
<rect x="18" y="8" width="3" height="12" fill="#3b82f6" />
100+
{/* Connector lines */}
101+
<rect x="6" y="13.5" width="2" height="1" fill="#9ca3af" />
102+
<rect x="11" y="11.5" width="2" height="1" fill="#9ca3af" />
103+
</svg>
104+
),
105+
},
86106
];
87107

88108
const ChartPanel: React.FC<ChartPanelProps> = ({
@@ -104,6 +124,7 @@ const ChartPanel: React.FC<ChartPanelProps> = ({
104124
]);
105125
const [labelConfig, setLabelConfig] = useState<LabelConfig>(defaultLabelConfig);
106126
const [annotations, setAnnotations] = useState<SimpleAnnotation[]>([]);
127+
const [themeId, setThemeId] = useState<string>("colorful");
107128
const [showAxisConfig, setShowAxisConfig] = useState(false);
108129
const [showLabelConfig, setShowLabelConfig] = useState(false);
109130
const [showAnnotations, setShowAnnotations] = useState(false);
@@ -120,7 +141,14 @@ const ChartPanel: React.FC<ChartPanelProps> = ({
120141

121142
const handleChartTypeChange = (type: ChartType) => {
122143
setSelectedChartType(type);
123-
setChartData((prev) => ({ ...prev, type }));
144+
// If switching to/from waterfall, reset to appropriate default data
145+
if (type === "waterfall" && chartData.type !== "waterfall") {
146+
setChartData(createDefaultChartData("waterfall"));
147+
} else if (type !== "waterfall" && chartData.type === "waterfall") {
148+
setChartData(createDefaultChartData(type));
149+
} else {
150+
setChartData((prev) => ({ ...prev, type }));
151+
}
124152
};
125153

126154
const handleDataChange = (newData: ChartData) => {
@@ -144,10 +172,11 @@ const ChartPanel: React.FC<ChartPanelProps> = ({
144172
);
145173
vectorChart.axes = axes;
146174

147-
// Use new rendering pipeline with VectorChart, labels, and annotations
175+
// Use new rendering pipeline with VectorChart, labels, annotations, and theme
148176
await insertVectorChartWithOptions(vectorChart, {
149177
labelConfig,
150178
annotations,
179+
themeId,
151180
});
152181

153182
showMessage(
@@ -245,6 +274,11 @@ const ChartPanel: React.FC<ChartPanelProps> = ({
245274
</div>
246275
</section>
247276

277+
{/* Theme Selector */}
278+
<section className="bg-white rounded-xl border border-gray-200 p-3 shadow-sm">
279+
<ThemeSelector selectedThemeId={themeId} onChange={setThemeId} />
280+
</section>
281+
248282
{/* Axis Configuration (collapsible) */}
249283
{selectedChartType !== "pie" && (
250284
<section className="bg-white rounded-xl border border-gray-200 p-3 shadow-sm">

src/components/ThemePanel.tsx

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import React from "react";
2+
import {
3+
VectorTheme,
4+
presetThemes,
5+
getDefaultTheme,
6+
} from "../types/theme";
7+
8+
interface ThemePanelProps {
9+
selectedThemeId: string | null;
10+
onChange: (theme: VectorTheme) => void;
11+
}
12+
13+
const ThemePanel: React.FC<ThemePanelProps> = ({
14+
selectedThemeId,
15+
onChange,
16+
}) => {
17+
const currentThemeId = selectedThemeId || getDefaultTheme().id;
18+
19+
return (
20+
<div className="space-y-3">
21+
<h3 className="text-xs font-medium text-gray-600 mb-2">Color Theme</h3>
22+
23+
<div className="grid grid-cols-2 gap-2">
24+
{presetThemes.map((theme) => (
25+
<ThemeCard
26+
key={theme.id}
27+
theme={theme}
28+
isSelected={theme.id === currentThemeId}
29+
onSelect={() => onChange(theme)}
30+
/>
31+
))}
32+
</div>
33+
</div>
34+
);
35+
};
36+
37+
interface ThemeCardProps {
38+
theme: VectorTheme;
39+
isSelected: boolean;
40+
onSelect: () => void;
41+
}
42+
43+
const ThemeCard: React.FC<ThemeCardProps> = ({
44+
theme,
45+
isSelected,
46+
onSelect,
47+
}) => {
48+
return (
49+
<button
50+
onClick={onSelect}
51+
className={`
52+
p-2 rounded-lg border-2 transition-all text-left
53+
${
54+
isSelected
55+
? "border-blue-500 bg-blue-50 shadow-sm"
56+
: "border-gray-200 bg-white hover:border-gray-300 hover:bg-gray-50"
57+
}
58+
`}
59+
>
60+
{/* Theme preview - color swatches */}
61+
<div className="flex gap-0.5 mb-2">
62+
{theme.colors.series.slice(0, 5).map((color, index) => (
63+
<div
64+
key={index}
65+
className="w-4 h-4 rounded-sm"
66+
style={{ backgroundColor: color }}
67+
/>
68+
))}
69+
</div>
70+
71+
{/* Theme name */}
72+
<div className="text-xs font-medium text-gray-700">{theme.name}</div>
73+
74+
{/* Theme description */}
75+
<div className="text-[10px] text-gray-500 line-clamp-1">
76+
{theme.description}
77+
</div>
78+
</button>
79+
);
80+
};
81+
82+
/**
83+
* Compact theme selector for inline use
84+
*/
85+
interface ThemeSelectorProps {
86+
selectedThemeId: string | null;
87+
onChange: (themeId: string) => void;
88+
}
89+
90+
export const ThemeSelector: React.FC<ThemeSelectorProps> = ({
91+
selectedThemeId,
92+
onChange,
93+
}) => {
94+
const currentThemeId = selectedThemeId || getDefaultTheme().id;
95+
const currentTheme = presetThemes.find((t) => t.id === currentThemeId) || getDefaultTheme();
96+
97+
return (
98+
<div className="flex items-center gap-2">
99+
<label className="text-xs text-gray-600">Theme:</label>
100+
<div className="relative flex-1">
101+
<select
102+
value={currentThemeId}
103+
onChange={(e) => onChange(e.target.value)}
104+
className="w-full pl-2 pr-8 py-1.5 text-sm border border-gray-300 rounded bg-white focus:outline-none focus:ring-1 focus:ring-blue-500 appearance-none"
105+
>
106+
{presetThemes.map((theme) => (
107+
<option key={theme.id} value={theme.id}>
108+
{theme.name}
109+
</option>
110+
))}
111+
</select>
112+
<div className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
113+
<svg
114+
className="w-4 h-4 text-gray-400"
115+
fill="none"
116+
stroke="currentColor"
117+
viewBox="0 0 24 24"
118+
>
119+
<path
120+
strokeLinecap="round"
121+
strokeLinejoin="round"
122+
strokeWidth={2}
123+
d="M19 9l-7 7-7-7"
124+
/>
125+
</svg>
126+
</div>
127+
</div>
128+
129+
{/* Preview swatches */}
130+
<div className="flex gap-0.5">
131+
{currentTheme.colors.series.slice(0, 4).map((color, index) => (
132+
<div
133+
key={index}
134+
className="w-3 h-3 rounded-sm border border-gray-200"
135+
style={{ backgroundColor: color }}
136+
/>
137+
))}
138+
</div>
139+
</div>
140+
);
141+
};
142+
143+
export default ThemePanel;

0 commit comments

Comments
 (0)