返回 presentation-ai
ChartEditorControls.tsx
根目录 / src / components / presentation / edit-panel / sections / ChartEditorControls.tsx
1 "use client";
2
3 import { Check, Edit3, Trash2 } from "lucide-react";
4 import {
5 useCallback,
6 useEffect,
7 useMemo,
8 useRef,
9 useState,
10 type KeyboardEvent,
11 } from "react";
12
13 import {
14 ChartDataEditorDialog,
15 type ChartDataMode,
16 type ChartDataType,
17 type SeriesChartType,
18 } from "@/components/notebook/presentation/editor/custom-elements/chart-data-editor-dialog";
19 import {
20 sanitizeSankeyCycleData,
21 type RemovedSankeyCycleLink,
22 } from "@/components/notebook/presentation/editor/custom-elements/chart-utils";
23 import {
24 AREA_CHART_ELEMENT,
25 areChartTypesCompatible,
26 BAR_CHART_ELEMENT,
27 BOX_PLOT_CHART_ELEMENT,
28 BUBBLE_CHART_ELEMENT,
29 CANDLESTICK_CHART_ELEMENT,
30 CHORD_CHART_ELEMENT,
31 COMPOSED_CHART_ELEMENT,
32 CONE_FUNNEL_CHART_ELEMENT,
33 DONUT_CHART_ELEMENT,
34 FUNNEL_CHART_ELEMENT,
35 getChartDataCategory,
36 HEATMAP_CHART_ELEMENT,
37 HISTOGRAM_CHART_ELEMENT,
38 LINE_CHART_ELEMENT,
39 LINEAR_GAUGE_ELEMENT,
40 NIGHTINGALE_CHART_ELEMENT,
41 OHLC_CHART_ELEMENT,
42 PIE_CHART_ELEMENT,
43 PYRAMID_CHART_ELEMENT,
44 RADAR_CHART_ELEMENT,
45 RADIAL_BAR_CHART_ELEMENT,
46 RADIAL_COLUMN_CHART_ELEMENT,
47 RADIAL_GAUGE_ELEMENT,
48 RANGE_AREA_CHART_ELEMENT,
49 RANGE_BAR_CHART_ELEMENT,
50 SANKEY_CHART_ELEMENT,
51 SCATTER_CHART_ELEMENT,
52 SUNBURST_CHART_ELEMENT,
53 TREEMAP_CHART_ELEMENT,
54 WATERFALL_CHART_ELEMENT,
55 } from "@/components/notebook/presentation/editor/lib";
56 import { PALETTE_DROP_MUTABLE_KEY } from "@/components/notebook/presentation/editor/utils/paletteDrop";
57 import { BlurInput } from "@/components/ui/blur-input";
58 import { Button } from "@/components/ui/button";
59 import {
60 Dialog,
61 DialogContent,
62 DialogDescription,
63 DialogFooter,
64 DialogHeader,
65 DialogTitle,
66 } from "@/components/ui/dialog";
67 import { Input } from "@/components/ui/input";
68 import { Label } from "@/components/ui/label";
69 import { ScrollArea } from "@/components/ui/scroll-area";
70 import {
71 Select,
72 SelectContent,
73 SelectItem,
74 SelectTrigger,
75 SelectValue,
76 } from "@/components/ui/select";
77 import { Slider } from "@/components/ui/slider";
78 import { Switch } from "@/components/ui/switch";
79 import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
80 import { useDebouncedSave } from "@/hooks/presentation/useDebouncedSave";
81 import { cn } from "@/lib/utils";
82 import { usePresentationState } from "@/states/presentation-state";
83 import { InteractiveChartPreview } from "./InteractiveChartPreview";
84 import { matchesPanelSearch, PanelSearchFilter } from "./PanelSearchFilter";
85
86 const KEYBOARD_APPLY_DELAY_MS = 250;
87
88 // Chart type display names
89 const CHART_TYPE_NAMES: Record<string, string> = {
90 "chart-pie": "Pie Chart",
91 "chart-donut": "Donut Chart",
92 "chart-bar": "Bar Chart",
93 "chart-line": "Line Chart",
94 "chart-area": "Area Chart",
95 "chart-scatter": "Scatter Chart",
96 "chart-bubble": "Bubble Chart",
97 "chart-radar": "Radar Chart",
98 "chart-radial-bar": "Radial Bar",
99 "chart-composed": "Composed Chart",
100 "chart-treemap": "Treemap",
101 "chart-radial-gauge": "Radial Gauge",
102 "chart-linear-gauge": "Linear Gauge",
103 "chart-funnel": "Funnel Chart",
104 "chart-cone-funnel": "Cone Funnel",
105 "chart-pyramid": "Pyramid Chart",
106 "chart-waterfall": "Waterfall Chart",
107 "chart-range-bar": "Range Bar Chart",
108 "chart-range-area": "Range Area Chart",
109 "chart-box-plot": "Box Plot",
110 "chart-candlestick": "Candlestick",
111 "chart-ohlc": "OHLC",
112 "chart-nightingale": "Nightingale",
113 "chart-radial-column": "Radial Column",
114 "chart-heatmap": "Heatmap",
115 "chart-histogram": "Histogram",
116 "chart-sunburst": "Sunburst",
117 "chart-sankey": "Sankey",
118 "chart-chord": "Chord",
119 };
120
121 const ALL_CHART_TYPES = [
122 // Polar-categorical charts (label + value in polar form)
123 { type: PIE_CHART_ELEMENT, name: "Pie Chart" },
124 { type: DONUT_CHART_ELEMENT, name: "Donut Chart" },
125 { type: RADAR_CHART_ELEMENT, name: "Radar Chart" },
126 { type: RADIAL_BAR_CHART_ELEMENT, name: "Radial Bar" },
127 { type: RADIAL_COLUMN_CHART_ELEMENT, name: "Radial Column" },
128 { type: NIGHTINGALE_CHART_ELEMENT, name: "Nightingale" },
129 // Cartesian charts (label + values on x/y axes)
130 { type: BAR_CHART_ELEMENT, name: "Bar Chart" },
131 { type: LINE_CHART_ELEMENT, name: "Line Chart" },
132 { type: AREA_CHART_ELEMENT, name: "Area Chart" },
133 { type: COMPOSED_CHART_ELEMENT, name: "Composed Chart" },
134 { type: WATERFALL_CHART_ELEMENT, name: "Waterfall Chart" },
135 // XY/XYZ coordinate charts
136 { type: SCATTER_CHART_ELEMENT, name: "Scatter Chart" },
137 { type: BUBBLE_CHART_ELEMENT, name: "Bubble Chart" },
138 // Range charts
139 { type: RANGE_BAR_CHART_ELEMENT, name: "Range Bar Chart" },
140 { type: RANGE_AREA_CHART_ELEMENT, name: "Range Area Chart" },
141 // Financial charts
142 { type: CANDLESTICK_CHART_ELEMENT, name: "Candlestick" },
143 { type: OHLC_CHART_ELEMENT, name: "OHLC" },
144 // Statistical charts
145 { type: BOX_PLOT_CHART_ELEMENT, name: "Box Plot" },
146 { type: HISTOGRAM_CHART_ELEMENT, name: "Histogram" },
147 // Hierarchical charts
148 { type: TREEMAP_CHART_ELEMENT, name: "Treemap" },
149 { type: SUNBURST_CHART_ELEMENT, name: "Sunburst" },
150 // Flow charts
151 { type: SANKEY_CHART_ELEMENT, name: "Sankey" },
152 { type: CHORD_CHART_ELEMENT, name: "Chord" },
153 // Funnel charts
154 { type: FUNNEL_CHART_ELEMENT, name: "Funnel Chart" },
155 { type: CONE_FUNNEL_CHART_ELEMENT, name: "Cone Funnel" },
156 { type: PYRAMID_CHART_ELEMENT, name: "Pyramid Chart" },
157 // Gauge charts
158 { type: RADIAL_GAUGE_ELEMENT, name: "Radial Gauge" },
159 { type: LINEAR_GAUGE_ELEMENT, name: "Linear Gauge" },
160 // Other
161 { type: HEATMAP_CHART_ELEMENT, name: "Heatmap" },
162 ];
163
164 // Charts that support orientation (vertical/horizontal)
165 const ORIENTATION_SUPPORTED_TYPES = [
166 "chart-bar",
167 "chart-range-bar",
168 "chart-funnel",
169 "chart-cone-funnel",
170 "chart-pyramid",
171 "chart-linear-gauge",
172 "chart-box-plot",
173 "chart-waterfall",
174 ];
175
176 // Chart types that support variants
177 const VARIANT_SUPPORTED_TYPES = [
178 "chart-bar",
179 "chart-area",
180 "chart-pie",
181 "chart-radar",
182 ];
183
184 // Chart types that support curve type
185 const CURVE_TYPE_SUPPORTED = ["chart-line", "chart-area"];
186
187 // Scatter shape supported types
188 const SCATTER_SHAPE_SUPPORTED = ["chart-scatter"];
189
190 // Chart variant type for transformation options
191 type ChartVariant = {
192 type: string;
193 name: string;
194 options?: Record<string, unknown>;
195 };
196
197 // Generate chart variants from compatible chart types
198 function generateChartVariants(
199 compatibleTypes: { type: string; name: string }[],
200 ): ChartVariant[] {
201 const variants: ChartVariant[] = [];
202
203 for (const chart of compatibleTypes) {
204 // Bar chart - orientation and stacked variants
205 if (chart.type === BAR_CHART_ELEMENT) {
206 variants.push({
207 type: chart.type,
208 name: "Bar (Vertical)",
209 options: { orientation: "vertical", variant: "default" },
210 });
211 variants.push({
212 type: chart.type,
213 name: "Bar (Horizontal)",
214 options: { orientation: "horizontal", variant: "default" },
215 });
216 variants.push({
217 type: chart.type,
218 name: "Bar (Stacked Vertical)",
219 options: { orientation: "vertical", variant: "stacked" },
220 });
221 variants.push({
222 type: chart.type,
223 name: "Bar (Stacked Horizontal)",
224 options: { orientation: "horizontal", variant: "stacked" },
225 });
226 continue;
227 }
228
229 // Pie chart - just base type (Donut is a separate chart type)
230 if (chart.type === PIE_CHART_ELEMENT) {
231 variants.push({
232 type: chart.type,
233 name: "Pie",
234 options: {},
235 });
236 continue;
237 }
238
239 // Radar chart - default and outline variants
240 if (chart.type === RADAR_CHART_ELEMENT) {
241 variants.push({
242 type: chart.type,
243 name: "Radar",
244 options: { variant: "default" },
245 });
246 variants.push({
247 type: chart.type,
248 name: "Radar (Outline)",
249 options: { variant: "outline" },
250 });
251 continue;
252 }
253
254 // Line chart - interpolation variants
255 if (chart.type === LINE_CHART_ELEMENT) {
256 variants.push({
257 type: chart.type,
258 name: "Line (Linear)",
259 options: { interpolation: "linear" },
260 });
261 variants.push({
262 type: chart.type,
263 name: "Line (Smooth)",
264 options: { interpolation: "smooth" },
265 });
266 variants.push({
267 type: chart.type,
268 name: "Line (Step)",
269 options: { interpolation: "step" },
270 });
271 variants.push({
272 type: chart.type,
273 name: "Line (Step Start)",
274 options: { interpolation: "step-start" },
275 });
276 variants.push({
277 type: chart.type,
278 name: "Line (Step End)",
279 options: { interpolation: "step-end" },
280 });
281 continue;
282 }
283
284 // Area chart - interpolation variants
285 if (chart.type === AREA_CHART_ELEMENT) {
286 variants.push({
287 type: chart.type,
288 name: "Area (Linear)",
289 options: { interpolation: "linear" },
290 });
291 variants.push({
292 type: chart.type,
293 name: "Area (Smooth)",
294 options: { interpolation: "smooth" },
295 });
296 variants.push({
297 type: chart.type,
298 name: "Area (Step)",
299 options: { interpolation: "step" },
300 });
301 variants.push({
302 type: chart.type,
303 name: "Area (Step Start)",
304 options: { interpolation: "step-start" },
305 });
306 variants.push({
307 type: chart.type,
308 name: "Area (Step End)",
309 options: { interpolation: "step-end" },
310 });
311 variants.push({
312 type: chart.type,
313 name: "Area (Stacked)",
314 options: { interpolation: "smooth", variant: "stacked" },
315 });
316 continue;
317 }
318
319 // Range bar - orientation variants
320 if (chart.type === RANGE_BAR_CHART_ELEMENT) {
321 variants.push({
322 type: chart.type,
323 name: "Range Bar (Vertical)",
324 options: { orientation: "vertical" },
325 });
326 variants.push({
327 type: chart.type,
328 name: "Range Bar (Horizontal)",
329 options: { orientation: "horizontal" },
330 });
331 continue;
332 }
333
334 // Funnel variants
335 if (chart.type === FUNNEL_CHART_ELEMENT) {
336 variants.push({
337 type: chart.type,
338 name: "Funnel (Vertical)",
339 options: { orientation: "vertical" },
340 });
341 variants.push({
342 type: chart.type,
343 name: "Funnel (Horizontal)",
344 options: { orientation: "horizontal" },
345 });
346 continue;
347 }
348
349 // Gauge variants
350 if (chart.type === RADIAL_GAUGE_ELEMENT) {
351 variants.push({
352 type: chart.type,
353 name: "Radial Gauge (Needle)",
354 options: { needle: { enabled: true }, bar: { enabled: false } },
355 });
356 variants.push({
357 type: chart.type,
358 name: "Radial Gauge (Bar)",
359 options: { needle: { enabled: false }, bar: { enabled: true } },
360 });
361 variants.push({
362 type: chart.type,
363 name: "Radial Gauge (Both)",
364 options: { needle: { enabled: true }, bar: { enabled: true } },
365 });
366 continue;
367 }
368 if (chart.type === LINEAR_GAUGE_ELEMENT) {
369 variants.push({
370 type: chart.type,
371 name: "Linear Gauge (Horizontal)",
372 options: { orientation: "horizontal" },
373 });
374 variants.push({
375 type: chart.type,
376 name: "Linear Gauge (Vertical)",
377 options: { orientation: "vertical" },
378 });
379 continue;
380 }
381
382 // Cone Funnel variants
383 if (chart.type === CONE_FUNNEL_CHART_ELEMENT) {
384 variants.push({
385 type: chart.type,
386 name: "Cone Funnel (Vertical)",
387 options: { orientation: "vertical" },
388 });
389 variants.push({
390 type: chart.type,
391 name: "Cone Funnel (Horizontal)",
392 options: { orientation: "horizontal" },
393 });
394 continue;
395 }
396
397 // Pyramid Chart variants
398 if (chart.type === PYRAMID_CHART_ELEMENT) {
399 variants.push({
400 type: chart.type,
401 name: "Pyramid (Vertical)",
402 options: { orientation: "vertical" },
403 });
404 variants.push({
405 type: chart.type,
406 name: "Pyramid (Horizontal)",
407 options: { orientation: "horizontal" },
408 });
409 continue;
410 }
411
412 // Waterfall variants
413 if (chart.type === WATERFALL_CHART_ELEMENT) {
414 variants.push({
415 type: chart.type,
416 name: "Waterfall (Vertical)",
417 options: { orientation: "vertical" },
418 });
419 variants.push({
420 type: chart.type,
421 name: "Waterfall (Horizontal)",
422 options: { orientation: "horizontal" },
423 });
424 continue;
425 }
426
427 // Range Area variants (just base type, no variants yet)
428 if (chart.type === RANGE_AREA_CHART_ELEMENT) {
429 variants.push({
430 type: chart.type,
431 name: "Range Area",
432 options: {},
433 });
434 continue;
435 }
436
437 // Default - just add the base chart type
438 variants.push({
439 type: chart.type,
440 name: chart.name,
441 options: {},
442 });
443 }
444
445 return variants;
446 }
447
448 type AxisConfig = {
449 title?: string | { text?: string; enabled?: boolean };
450 label?: { enabled?: boolean };
451 gridLine?: { enabled?: boolean };
452 };
453
454 interface ChartEditorControlsProps {
455 slideId: string;
456 }
457
458 type InlineChartEditorData = {
459 chartType: string;
460 chartData: unknown;
461 chartOptions: Record<string, unknown>;
462 };
463
464 function getChartVariantGroupName(type: string) {
465 if (type === BAR_CHART_ELEMENT) return "Bar Charts";
466 if (type === LINE_CHART_ELEMENT) return "Line Charts";
467 if (type === AREA_CHART_ELEMENT) return "Area Charts";
468 if (type === FUNNEL_CHART_ELEMENT) return "Funnel Charts";
469 if (type === RANGE_BAR_CHART_ELEMENT) return "Range Charts";
470 if (type === LINEAR_GAUGE_ELEMENT) return "Gauge Charts";
471 if (type === RADIAL_GAUGE_ELEMENT) return "Gauge Charts";
472 if (
473 type === RADIAL_BAR_CHART_ELEMENT ||
474 type === NIGHTINGALE_CHART_ELEMENT ||
475 type === RADIAL_COLUMN_CHART_ELEMENT
476 ) {
477 return "Polar Charts";
478 }
479 if (type === PIE_CHART_ELEMENT || type === DONUT_CHART_ELEMENT) {
480 return "Pie Charts";
481 }
482 if (type === TREEMAP_CHART_ELEMENT || type === SUNBURST_CHART_ELEMENT) {
483 return "Hierarchical Charts";
484 }
485 if (type === SCATTER_CHART_ELEMENT || type === BUBBLE_CHART_ELEMENT) {
486 return "Coordinate Charts";
487 }
488 if (type === SANKEY_CHART_ELEMENT || type === CHORD_CHART_ELEMENT) {
489 return "Flow Charts";
490 }
491 return CHART_TYPE_NAMES[type] || "Charts";
492 }
493
494 export function ChartEditorControls({ slideId }: ChartEditorControlsProps) {
495 const { saveImmediately } = useDebouncedSave();
496 const slides = usePresentationState((s) => s.slides);
497 const setSlides = usePresentationState((s) => s.setSlides);
498 const closeChartEditor = usePresentationState((s) => s.closeChartEditor);
499 const [chartDataEditorOpen, setChartDataEditorOpen] = useState(false);
500
501 // Get inline chart editor data from state (when editing inline chart elements)
502 const chartEditorData = usePresentationState((s) => s.chartEditorData);
503 const boundUpdateElement = usePresentationState((s) => s.boundUpdateElement);
504 const setPaletteDropTarget = usePresentationState(
505 (s) => s.setPaletteDropTarget,
506 );
507 const [inlineChartEditorData, setInlineChartEditorData] =
508 useState<InlineChartEditorData | null>(chartEditorData);
509 const [removedSankeyLinks, setRemovedSankeyLinks] = useState<
510 RemovedSankeyCycleLink[]
511 >([]);
512 const [focusedChartVariantIndex, setFocusedChartVariantIndex] = useState(0);
513 const [chartSearchQuery, setChartSearchQuery] = useState("");
514 const chartVariantRefs = useRef<Array<HTMLButtonElement | null>>([]);
515 const applyChartVariantTimeoutRef = useRef<ReturnType<
516 typeof setTimeout
517 > | null>(null);
518
519 useEffect(() => {
520 setInlineChartEditorData(chartEditorData);
521 }, [chartEditorData]);
522
523 // Determine if we're editing an inline chart (via boundUpdateElement) or root image chart
524 const isInlineChartEditing = !!boundUpdateElement && !!chartEditorData;
525 const activeInlineChartData = isInlineChartEditing
526 ? inlineChartEditorData
527 : null;
528
529 // Get current slide data for root image chart editing
530 const currentSlide = slides.find((s) => s.id === slideId);
531 const rootImage = currentSlide?.rootImage;
532
533 // Chart properties - use chartEditorData for inline charts, rootImage for root image charts
534 const chartType = isInlineChartEditing
535 ? (activeInlineChartData?.chartType ?? "")
536 : (rootImage?.chartType ?? "");
537 const chartData = isInlineChartEditing
538 ? (activeInlineChartData?.chartData as ChartDataType | undefined)
539 : (rootImage?.chartData as ChartDataType | undefined);
540 const chartOptions = isInlineChartEditing
541 ? (activeInlineChartData?.chartOptions ?? {})
542 : ((rootImage?.chartOptions ?? {}) as Record<string, unknown>);
543
544 const normalizeTitle = (title?: AxisConfig["title"]) =>
545 typeof title === "string" ? { text: title } : (title ?? {});
546 const xAxisConfig = (chartOptions?.xAxis as AxisConfig) ?? {};
547 const yAxisConfig = (chartOptions?.yAxis as AxisConfig) ?? {};
548
549 // Get compatible chart types for conversion
550 const compatibleChartTypes = useMemo(
551 () =>
552 ALL_CHART_TYPES.filter((chart) =>
553 areChartTypesCompatible(chartType, chart.type),
554 ),
555 [chartType],
556 );
557 const chartVariants = useMemo(
558 () => generateChartVariants(compatibleChartTypes),
559 [compatibleChartTypes],
560 );
561 const filteredChartVariants = useMemo(
562 () =>
563 chartVariants.filter((variant) => {
564 const groupName = getChartVariantGroupName(variant.type);
565
566 return matchesPanelSearch(chartSearchQuery, [
567 variant.name,
568 variant.type,
569 groupName,
570 ]);
571 }),
572 [chartSearchQuery, chartVariants],
573 );
574 const groupedChartVariants = useMemo(() => {
575 return filteredChartVariants.reduce<Record<string, ChartVariant[]>>(
576 (acc, variant) => {
577 const groupName = getChartVariantGroupName(variant.type);
578 const group = acc[groupName] ?? [];
579
580 group.push(variant);
581 acc[groupName] = group;
582 return acc;
583 },
584 {},
585 );
586 }, [filteredChartVariants]);
587
588 useEffect(() => {
589 chartVariantRefs.current = chartVariantRefs.current.slice(
590 0,
591 filteredChartVariants.length,
592 );
593 }, [filteredChartVariants.length]);
594
595 useEffect(() => {
596 setFocusedChartVariantIndex(0);
597 }, [chartSearchQuery]);
598
599 useEffect(() => {
600 window.requestAnimationFrame(() => {
601 chartVariantRefs.current[focusedChartVariantIndex]?.focus();
602 });
603 }, [focusedChartVariantIndex]);
604
605 useEffect(
606 () => () => {
607 if (applyChartVariantTimeoutRef.current) {
608 clearTimeout(applyChartVariantTimeoutRef.current);
609 }
610 },
611 [],
612 );
613
614 // Chart title and subtitle
615 const chartTitle = (chartOptions?.title as { text?: string })?.text ?? "";
616 const chartSubtitle =
617 (chartOptions?.subtitle as { text?: string })?.text ?? "";
618
619 const showLegend = (chartOptions?.showLegend as boolean) !== false;
620 const legendPosition =
621 (chartOptions?.legend as { position?: string })?.position ?? "bottom";
622 const legacyShowGrid = (chartOptions?.showGrid as boolean) !== false;
623 const showAxisLabels = (chartOptions?.showAxisLabels as boolean) ?? true;
624 const xAxisLabelEnabled = xAxisConfig.label?.enabled ?? showAxisLabels;
625 const yAxisLabelEnabled = yAxisConfig.label?.enabled ?? showAxisLabels;
626 const xAxisGridEnabled = xAxisConfig.gridLine?.enabled ?? false;
627 const yAxisGridEnabled = yAxisConfig.gridLine?.enabled ?? legacyShowGrid;
628 const xAxisTitleConfig = normalizeTitle(xAxisConfig.title);
629 const xAxisTitleText = xAxisTitleConfig.text ?? "";
630 const yAxisTitleConfig = normalizeTitle(yAxisConfig.title);
631 const yAxisTitleText = yAxisTitleConfig.text ?? "";
632 const xAxisTitleEnabled =
633 xAxisTitleConfig.enabled ?? Boolean(xAxisTitleConfig.text);
634 const yAxisTitleEnabled =
635 yAxisTitleConfig.enabled ?? Boolean(yAxisTitleConfig.text);
636 const variant = (chartOptions?.variant as string) ?? "default";
637 const interpolation = (chartOptions?.interpolation as string) ?? "smooth";
638 const scatterShape = (chartOptions?.scatterShape as string) ?? "circle";
639 const seriesChartTypes =
640 (chartOptions?.seriesChartTypes as Record<string, SeriesChartType>) ?? {};
641
642 // Animation settings
643 const animationEnabled =
644 (chartOptions?.disableAnimation as boolean) === false ||
645 (chartOptions?.animation as { enabled?: boolean })?.enabled === true;
646 const animationDuration =
647 (chartOptions?.animation as { duration?: number })?.duration ?? 500;
648
649 // Background settings
650 const backgroundFill =
651 (chartOptions?.background as { fill?: string })?.fill ?? "";
652 const backgroundVisible =
653 (chartOptions?.background as { visible?: boolean })?.visible ?? false;
654
655 // Donut chart inner labels
656 type InnerLabelConfig = {
657 text: string;
658 fontWeight?: "normal" | "bold";
659 fontSize?: number;
660 color?: string;
661 spacing?: number;
662 };
663 const innerLabels = (chartOptions?.innerLabels as InnerLabelConfig[]) ?? [];
664 const innerCircleFill =
665 (chartOptions?.innerCircle as { fill?: string })?.fill ?? "";
666 const innerRadiusRatio = (chartOptions?.innerRadiusRatio as number) ?? 0.7;
667 const innerLabelTitle = innerLabels[0]?.text ?? "";
668 const innerLabelValue = innerLabels[1]?.text ?? "";
669
670 // Check if chart type supports axes
671 const supportsAxes = ![
672 "chart-pie",
673 "chart-donut",
674 "chart-radar",
675 "chart-radial-bar",
676 "chart-nightingale",
677 "chart-radial-column",
678 "chart-sunburst",
679 "chart-sankey",
680 "chart-chord",
681 "chart-funnel",
682 "chart-cone-funnel",
683 "chart-pyramid",
684 "chart-radial-gauge",
685 "chart-linear-gauge",
686 "chart-treemap",
687 ].includes(chartType);
688
689 // Check if this is a donut chart
690 const isDonutChart = chartType === DONUT_CHART_ELEMENT;
691
692 const isComposedChart = chartType === COMPOSED_CHART_ELEMENT;
693 const supportsVariant = VARIANT_SUPPORTED_TYPES.includes(chartType);
694 const supportsCurveType = CURVE_TYPE_SUPPORTED.includes(chartType);
695 const supportsScatterShape = SCATTER_SHAPE_SUPPORTED.includes(chartType);
696 const supportsOrientation = ORIENTATION_SUPPORTED_TYPES.includes(chartType);
697
698 // Gauge chart detection
699 const isRadialGauge = chartType === RADIAL_GAUGE_ELEMENT;
700 const isLinearGauge = chartType === LINEAR_GAUGE_ELEMENT;
701 const isGaugeChart = isRadialGauge || isLinearGauge;
702
703 // Orientation value
704 const orientation = (chartOptions?.orientation as string) ?? "vertical";
705
706 // Gauge-specific options
707 const needleEnabled =
708 (chartOptions?.needle as { enabled?: boolean })?.enabled ?? false;
709 const barEnabled =
710 (chartOptions?.bar as { enabled?: boolean })?.enabled ?? true;
711
712 // Get gauge value from chart data
713 const getGaugeValue = (): number => {
714 if (typeof chartData === "number") return chartData;
715 if (
716 typeof chartData === "object" &&
717 chartData !== null &&
718 "value" in chartData
719 ) {
720 return (chartData as { value: number }).value;
721 }
722 if (Array.isArray(chartData) && chartData.length > 0) {
723 const firstItem = chartData[0] as Record<string, unknown>;
724 const numericKey = Object.keys(firstItem).find(
725 (key) => typeof firstItem[key] === "number",
726 );
727 if (numericKey) return firstItem[numericKey] as number;
728 }
729 return 50;
730 };
731
732 const handleGaugeValueChange = (newValue: number) => {
733 if (!currentSlide) return;
734 const clampedValue = Math.min(100, Math.max(0, newValue));
735 handleChartDataUpdate([{ value: clampedValue }]);
736 };
737
738 // Determine chart data mode
739 const chartDataMode: ChartDataMode =
740 (getChartDataCategory(chartType) as ChartDataMode | null) ?? "categorical";
741
742 const updateInlineChartEditorData = useCallback(
743 (updates: Partial<InlineChartEditorData>) => {
744 setInlineChartEditorData((current) =>
745 current
746 ? {
747 ...current,
748 ...updates,
749 chartOptions: updates.chartOptions ?? current.chartOptions,
750 }
751 : current,
752 );
753 },
754 [],
755 );
756
757 // Update chart options handler
758 const updateChartOption = (key: string, value: unknown) => {
759 // For inline charts, use boundUpdateElement
760 setPaletteDropTarget(null);
761
762 if (isInlineChartEditing && boundUpdateElement) {
763 boundUpdateElement({ [key]: value, [PALETTE_DROP_MUTABLE_KEY]: false });
764 updateInlineChartEditorData({
765 chartOptions: {
766 ...chartOptions,
767 [key]: value,
768 },
769 });
770 return;
771 }
772
773 // For root image charts, update via setSlides
774 if (!currentSlide) return;
775
776 setSlides(
777 slides.map((slide) =>
778 slide.id === slideId
779 ? {
780 ...slide,
781 rootImage: {
782 ...slide.rootImage!,
783 chartOptions: {
784 ...slide.rootImage?.chartOptions,
785 [key]: value,
786 },
787 paletteDropMutable: false,
788 },
789 }
790 : slide,
791 ),
792 );
793 void saveImmediately();
794 };
795
796 // Update multiple chart options handler
797 const updateChartOptions = (updates: Record<string, unknown>) => {
798 // For inline charts, use boundUpdateElement
799 setPaletteDropTarget(null);
800
801 if (isInlineChartEditing && boundUpdateElement) {
802 boundUpdateElement({ ...updates, [PALETTE_DROP_MUTABLE_KEY]: false });
803 updateInlineChartEditorData({
804 chartOptions: {
805 ...chartOptions,
806 ...updates,
807 },
808 });
809 return;
810 }
811
812 // For root image charts, update via setSlides
813 if (!currentSlide) return;
814
815 setSlides(
816 slides.map((slide) =>
817 slide.id === slideId
818 ? {
819 ...slide,
820 rootImage: {
821 ...slide.rootImage!,
822 chartOptions: {
823 ...slide.rootImage?.chartOptions,
824 ...updates,
825 },
826 paletteDropMutable: false,
827 },
828 }
829 : slide,
830 ),
831 );
832 void saveImmediately();
833 };
834
835 // Update chart data handler
836 const handleChartDataUpdate = (newData: ChartDataType) => {
837 // For inline charts, use boundUpdateElement
838 setPaletteDropTarget(null);
839
840 if (isInlineChartEditing && boundUpdateElement) {
841 boundUpdateElement({
842 data: newData,
843 [PALETTE_DROP_MUTABLE_KEY]: false,
844 });
845 updateInlineChartEditorData({ chartData: newData });
846 return;
847 }
848
849 // For root image charts, update via setSlides
850 if (!currentSlide) return;
851
852 setSlides(
853 slides.map((slide) =>
854 slide.id === slideId
855 ? {
856 ...slide,
857 rootImage: {
858 ...slide.rootImage!,
859 chartData: newData,
860 paletteDropMutable: false,
861 },
862 }
863 : slide,
864 ),
865 );
866 void saveImmediately();
867 };
868
869 // Update series chart types handler (for composed charts)
870 const handleSeriesChartTypesUpdate = (
871 types: Record<string, SeriesChartType>,
872 ) => {
873 updateChartOption("seriesChartTypes", types);
874 };
875
876 const updateAxisOption = (
877 axisKey: "xAxis" | "yAxis",
878 updates: Partial<AxisConfig>,
879 ) => {
880 const currentAxis = axisKey === "xAxis" ? xAxisConfig : yAxisConfig;
881 const nextTitle = normalizeTitle(
882 updates.title ?? currentAxis.title ?? undefined,
883 );
884
885 updateChartOption(axisKey, {
886 ...currentAxis,
887 ...updates,
888 label: {
889 ...currentAxis.label,
890 ...updates.label,
891 },
892 gridLine: {
893 ...currentAxis.gridLine,
894 ...updates.gridLine,
895 },
896 title:
897 updates.title !== undefined
898 ? nextTitle
899 : (currentAxis.title ?? nextTitle),
900 });
901 };
902
903 // Remove chart handler
904 const handleRemoveChart = () => {
905 if (!currentSlide) return;
906 setPaletteDropTarget(null);
907
908 setSlides(
909 slides.map((slide) =>
910 slide.id === slideId
911 ? {
912 ...slide,
913 rootImage: {
914 ...slide.rootImage!,
915 chartType: undefined,
916 chartData: undefined,
917 chartOptions: undefined,
918 paletteDropMutable: false,
919 },
920 }
921 : slide,
922 ),
923 );
924 void saveImmediately();
925 closeChartEditor();
926 };
927
928 const applyChartVariant = useCallback(
929 (chartVariant: ChartVariant) => {
930 if (applyChartVariantTimeoutRef.current) {
931 clearTimeout(applyChartVariantTimeoutRef.current);
932 applyChartVariantTimeoutRef.current = null;
933 }
934
935 const nextOptions = chartVariant.options ?? {};
936 const sankeySanitization =
937 chartVariant.type === SANKEY_CHART_ELEMENT
938 ? sanitizeSankeyCycleData(chartData)
939 : null;
940 const nextChartData = sankeySanitization?.data ?? chartData;
941
942 if (sankeySanitization && sankeySanitization.removedLinks.length > 0) {
943 setRemovedSankeyLinks(sankeySanitization.removedLinks);
944 }
945
946 setPaletteDropTarget(null);
947
948 if (isInlineChartEditing && boundUpdateElement) {
949 boundUpdateElement(
950 chartVariant.type !== chartType
951 ? {
952 type: chartVariant.type,
953 data: nextChartData,
954 ...nextOptions,
955 [PALETTE_DROP_MUTABLE_KEY]: false,
956 }
957 : {
958 data: nextChartData,
959 ...nextOptions,
960 [PALETTE_DROP_MUTABLE_KEY]: false,
961 },
962 );
963 updateInlineChartEditorData({
964 chartType: chartVariant.type,
965 chartData: nextChartData,
966 chartOptions: {
967 ...chartOptions,
968 ...nextOptions,
969 },
970 });
971 return;
972 }
973
974 if (!currentSlide) return;
975
976 setSlides(
977 slides.map((slide) =>
978 slide.id === slideId
979 ? {
980 ...slide,
981 rootImage: {
982 ...slide.rootImage!,
983 ...(chartVariant.type !== chartType
984 ? { chartType: chartVariant.type }
985 : {}),
986 chartData: nextChartData,
987 chartOptions: {
988 ...slide.rootImage?.chartOptions,
989 ...nextOptions,
990 },
991 paletteDropMutable: false,
992 },
993 }
994 : slide,
995 ),
996 );
997 void saveImmediately();
998 },
999 [
1000 boundUpdateElement,
1001 chartData,
1002 chartOptions,
1003 chartType,
1004 currentSlide,
1005 isInlineChartEditing,
1006 saveImmediately,
1007 setSlides,
1008 setPaletteDropTarget,
1009 slideId,
1010 slides,
1011 updateInlineChartEditorData,
1012 ],
1013 );
1014
1015 const scheduleChartVariantApply = useCallback(
1016 (chartVariant: ChartVariant, index: number) => {
1017 if (applyChartVariantTimeoutRef.current) {
1018 clearTimeout(applyChartVariantTimeoutRef.current);
1019 }
1020
1021 applyChartVariantTimeoutRef.current = setTimeout(() => {
1022 applyChartVariantTimeoutRef.current = null;
1023 applyChartVariant(chartVariant);
1024
1025 window.requestAnimationFrame(() => {
1026 chartVariantRefs.current[index]?.focus();
1027 });
1028 }, KEYBOARD_APPLY_DELAY_MS);
1029 },
1030 [applyChartVariant],
1031 );
1032
1033 const focusChartVariant = useCallback(
1034 (nextIndex: number, shouldApply = false) => {
1035 if (filteredChartVariants.length === 0) return;
1036
1037 const boundedIndex =
1038 (nextIndex + filteredChartVariants.length) %
1039 filteredChartVariants.length;
1040 const chartVariant = filteredChartVariants[boundedIndex];
1041
1042 setFocusedChartVariantIndex(boundedIndex);
1043
1044 if (shouldApply && chartVariant) {
1045 scheduleChartVariantApply(chartVariant, boundedIndex);
1046 }
1047
1048 window.requestAnimationFrame(() => {
1049 chartVariantRefs.current[boundedIndex]?.scrollIntoView({
1050 block: "nearest",
1051 behavior: "smooth",
1052 });
1053 chartVariantRefs.current[boundedIndex]?.focus();
1054 });
1055 },
1056 [filteredChartVariants, scheduleChartVariantApply],
1057 );
1058
1059 const handleChartVariantKeyDown = useCallback(
1060 (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
1061 switch (event.key) {
1062 case "ArrowLeft":
1063 case "ArrowUp":
1064 event.preventDefault();
1065 event.stopPropagation();
1066 focusChartVariant(index - 1, true);
1067 break;
1068 case "ArrowRight":
1069 case "ArrowDown":
1070 event.preventDefault();
1071 event.stopPropagation();
1072 focusChartVariant(index + 1, true);
1073 break;
1074 case "Home":
1075 event.preventDefault();
1076 event.stopPropagation();
1077 focusChartVariant(0, true);
1078 break;
1079 case "End":
1080 event.preventDefault();
1081 event.stopPropagation();
1082 focusChartVariant(filteredChartVariants.length - 1, true);
1083 break;
1084 case "Enter": {
1085 event.preventDefault();
1086 event.stopPropagation();
1087 const chartVariant = filteredChartVariants[index];
1088 if (chartVariant) {
1089 applyChartVariant(chartVariant);
1090 }
1091 break;
1092 }
1093 }
1094 },
1095 [applyChartVariant, filteredChartVariants, focusChartVariant],
1096 );
1097
1098 // If no chart data, show empty state
1099 if (!chartType || !chartData) {
1100 return (
1101 <div className="flex h-full flex-col items-center justify-center p-6 text-center">
1102 <p className="text-sm text-muted-foreground">
1103 No chart data available. Drop a chart element onto the image area to
1104 add one.
1105 </p>
1106 </div>
1107 );
1108 }
1109
1110 const chartDisplayName = CHART_TYPE_NAMES[chartType] ?? "Chart";
1111
1112 return (
1113 <>
1114 <Tabs defaultValue="conversion" className="flex h-full flex-col">
1115 <div className="border-b px-2 py-1">
1116 <TabsList className="grid w-full grid-cols-3">
1117 <TabsTrigger value="conversion">Conversion</TabsTrigger>
1118 <TabsTrigger value="data">Data</TabsTrigger>
1119 <TabsTrigger value="customize">Customize</TabsTrigger>
1120 </TabsList>
1121 </div>
1122
1123 <ScrollArea className="flex-1">
1124 {/* CONVERSION TAB */}
1125 <TabsContent value="conversion" className="m-0">
1126 <div className="space-y-6 p-6">
1127 {/* Chart Conversion Options */}
1128 <div className="space-y-2">
1129 <Label className="text-sm font-medium">Transform Chart</Label>
1130 <p className="text-xs text-muted-foreground">
1131 Click a chart variant to transform your data
1132 </p>
1133 </div>
1134
1135 <PanelSearchFilter
1136 className="-mx-6 border-y"
1137 onQueryChange={setChartSearchQuery}
1138 placeholder="Search chart types..."
1139 query={chartSearchQuery}
1140 />
1141
1142 {filteredChartVariants.length > 0 ? (
1143 Object.entries(groupedChartVariants).map(
1144 ([groupName, groupChartVariants]) => (
1145 <div key={groupName} className="space-y-3">
1146 {/* Show group header */}
1147 <h3 className="text-xs font-medium text-muted-foreground">
1148 {groupName}
1149 </h3>
1150 <div className="grid grid-cols-2 gap-3">
1151 {groupChartVariants.map((variant) => {
1152 const absoluteIndex =
1153 filteredChartVariants.indexOf(variant);
1154 // Check if this variant matches current chart type AND options
1155 const isCurrentVariant =
1156 chartType === variant.type &&
1157 (variant.options?.orientation === undefined ||
1158 variant.options?.orientation ===
1159 (chartOptions?.orientation ?? "vertical")) &&
1160 (variant.options?.interpolation === undefined ||
1161 variant.options?.interpolation ===
1162 (chartOptions?.interpolation ?? "smooth")) &&
1163 (variant.options?.variant === undefined ||
1164 variant.options?.variant ===
1165 (chartOptions?.variant ?? "default")) &&
1166 (variant.options?.needle === undefined ||
1167 JSON.stringify(variant.options?.needle) ===
1168 JSON.stringify(chartOptions?.needle)) &&
1169 (variant.options?.bar === undefined ||
1170 JSON.stringify(variant.options?.bar) ===
1171 JSON.stringify(chartOptions?.bar));
1172
1173 return (
1174 <button
1175 key={`${variant.type}-${variant.name}-${absoluteIndex}`}
1176 ref={(node) => {
1177 if (absoluteIndex >= 0) {
1178 chartVariantRefs.current[absoluteIndex] =
1179 node;
1180 }
1181 }}
1182 type="button"
1183 aria-pressed={isCurrentVariant}
1184 tabIndex={
1185 absoluteIndex === focusedChartVariantIndex
1186 ? 0
1187 : -1
1188 }
1189 data-panel-arrow-target="true"
1190 onClick={() => applyChartVariant(variant)}
1191 onFocus={() =>
1192 setFocusedChartVariantIndex(absoluteIndex)
1193 }
1194 onKeyDown={(event) =>
1195 handleChartVariantKeyDown(event, absoluteIndex)
1196 }
1197 className={cn(
1198 "group relative flex flex-col items-center justify-center gap-2 rounded-lg border-2 p-3 transition-all hover:border-primary/50 hover:bg-accent focus-visible:ring-2 focus-visible:ring-primary focus-visible:outline-none",
1199 isCurrentVariant
1200 ? "border-primary bg-primary/5"
1201 : "border-border",
1202 absoluteIndex === focusedChartVariantIndex &&
1203 "ring-1 ring-primary",
1204 )}
1205 >
1206 {isCurrentVariant && (
1207 <div className="absolute top-2 right-2 z-10 rounded-full bg-primary p-1">
1208 <Check className="h-3 w-3 text-primary-foreground" />
1209 </div>
1210 )}
1211 <div className="w-full overflow-hidden rounded-md bg-background/50">
1212 <InteractiveChartPreview
1213 chartType={variant.type}
1214 variantOptions={variant.options}
1215 className="h-full w-full"
1216 />
1217 </div>
1218 <span className="text-center text-xs font-medium">
1219 {variant.name}
1220 </span>
1221 </button>
1222 );
1223 })}
1224 </div>
1225 </div>
1226 ),
1227 )
1228 ) : (
1229 <div className="rounded-md border border-dashed p-6 text-center text-sm text-muted-foreground">
1230 No chart variants match your search.
1231 </div>
1232 )}
1233 </div>
1234 </TabsContent>
1235
1236 {/* DATA TAB */}
1237 <TabsContent value="data" className="m-0">
1238 <div className="space-y-6 p-6">
1239 {/* Edit Data Button */}
1240 <div className="space-y-2">
1241 <Label className="text-sm font-medium">Edit Chart Data</Label>
1242 <Button
1243 onClick={() => setChartDataEditorOpen(true)}
1244 className="w-full justify-start gap-2"
1245 variant="outline"
1246 >
1247 <Edit3 className="h-4 w-4" />
1248 Edit Chart Data
1249 </Button>
1250 </div>
1251
1252 {/* Gauge Value Input (for gauge charts) */}
1253 {isGaugeChart && (
1254 <div className="space-y-2">
1255 <Label className="text-xs text-muted-foreground">
1256 Gauge Value
1257 </Label>
1258 <BlurInput
1259 type="number"
1260 value={getGaugeValue()}
1261 onChange={(value) =>
1262 handleGaugeValueChange(Number(value) || 0)
1263 }
1264 min={0}
1265 max={100}
1266 className="[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
1267 />
1268 <p className="text-xs text-muted-foreground">
1269 Value between 0-100
1270 </p>
1271 </div>
1272 )}
1273 </div>
1274 </TabsContent>
1275
1276 {/* CUSTOMIZE TAB */}
1277 <TabsContent value="customize" className="m-0">
1278 <div className="space-y-6 p-6">
1279 {/* Chart Variant (for supported types) */}
1280 {supportsVariant && (
1281 <div className="space-y-2">
1282 <Label className="text-xs text-muted-foreground">
1283 Variant
1284 </Label>
1285 <Select
1286 value={variant}
1287 onValueChange={(v) => updateChartOption("variant", v)}
1288 >
1289 <SelectTrigger>
1290 <SelectValue placeholder="Select variant" />
1291 </SelectTrigger>
1292 <SelectContent>
1293 <SelectItem value="default">Default</SelectItem>
1294 {chartType === "chart-bar" && (
1295 <SelectItem value="stacked">Stacked</SelectItem>
1296 )}
1297 {chartType === "chart-area" && (
1298 <SelectItem value="stacked">Stacked</SelectItem>
1299 )}
1300 {chartType === "chart-pie" && (
1301 <SelectItem value="donut">Donut</SelectItem>
1302 )}
1303 {chartType === "chart-radar" && (
1304 <SelectItem value="outline">Outline</SelectItem>
1305 )}
1306 </SelectContent>
1307 </Select>
1308 </div>
1309 )}
1310
1311 {/* Interpolation (for line/area charts) */}
1312 {supportsCurveType && (
1313 <div className="space-y-2">
1314 <Label className="text-xs text-muted-foreground">
1315 Interpolation
1316 </Label>
1317 <Select
1318 value={interpolation}
1319 onValueChange={(v) => updateChartOption("interpolation", v)}
1320 >
1321 <SelectTrigger>
1322 <SelectValue placeholder="Select interpolation" />
1323 </SelectTrigger>
1324 <SelectContent>
1325 <SelectItem value="linear">Linear</SelectItem>
1326 <SelectItem value="smooth">Smooth</SelectItem>
1327 <SelectItem value="step">Step</SelectItem>
1328 <SelectItem value="step-start">Step Start</SelectItem>
1329 <SelectItem value="step-end">Step End</SelectItem>
1330 </SelectContent>
1331 </Select>
1332 </div>
1333 )}
1334
1335 {/* Scatter Shape (for scatter charts) */}
1336 {supportsScatterShape && (
1337 <div className="space-y-2">
1338 <Label className="text-xs text-muted-foreground">
1339 Point Shape
1340 </Label>
1341 <Select
1342 value={scatterShape}
1343 onValueChange={(v) => updateChartOption("scatterShape", v)}
1344 >
1345 <SelectTrigger>
1346 <SelectValue placeholder="Select shape" />
1347 </SelectTrigger>
1348 <SelectContent>
1349 <SelectItem value="circle">Circle</SelectItem>
1350 <SelectItem value="cross">Cross</SelectItem>
1351 <SelectItem value="diamond">Diamond</SelectItem>
1352 <SelectItem value="heart">Heart</SelectItem>
1353 <SelectItem value="pin">Pin</SelectItem>
1354 <SelectItem value="plus">Plus</SelectItem>
1355 <SelectItem value="square">Square</SelectItem>
1356 <SelectItem value="star">Star</SelectItem>
1357 <SelectItem value="triangle">Triangle</SelectItem>
1358 </SelectContent>
1359 </Select>
1360 </div>
1361 )}
1362
1363 {/* Orientation (for supported chart types) */}
1364 {supportsOrientation && (
1365 <div className="space-y-2">
1366 <Label className="text-xs text-muted-foreground">
1367 Orientation
1368 </Label>
1369 <Select
1370 value={orientation}
1371 onValueChange={(v) => updateChartOption("orientation", v)}
1372 >
1373 <SelectTrigger>
1374 <SelectValue placeholder="Select orientation" />
1375 </SelectTrigger>
1376 <SelectContent>
1377 <SelectItem value="vertical">Vertical</SelectItem>
1378 <SelectItem value="horizontal">Horizontal</SelectItem>
1379 </SelectContent>
1380 </Select>
1381 </div>
1382 )}
1383
1384 {/* Radial Gauge specific controls */}
1385 {isRadialGauge && (
1386 <div className="space-y-4 rounded-lg border p-4">
1387 <Label className="text-sm font-medium">Gauge Options</Label>
1388 <div className="flex items-center justify-between">
1389 <Label htmlFor="needle-enabled" className="text-sm">
1390 Show Needle
1391 </Label>
1392 <Switch
1393 id="needle-enabled"
1394 checked={needleEnabled}
1395 onCheckedChange={(checked) =>
1396 updateChartOption("needle", { enabled: checked })
1397 }
1398 />
1399 </div>
1400 <div className="flex items-center justify-between">
1401 <Label htmlFor="bar-enabled" className="text-sm">
1402 Show Bar
1403 </Label>
1404 <Switch
1405 id="bar-enabled"
1406 checked={barEnabled}
1407 onCheckedChange={(checked) =>
1408 updateChartOption("bar", { enabled: checked })
1409 }
1410 />
1411 </div>
1412 </div>
1413 )}
1414
1415 {/* Chart Title & Subtitle */}
1416 <div className="space-y-3">
1417 <Label className="text-xs text-muted-foreground">
1418 Chart Labels
1419 </Label>
1420 <div className="space-y-2">
1421 <BlurInput
1422 id="chart-title"
1423 placeholder="Chart title"
1424 value={chartTitle}
1425 onChange={(value) =>
1426 updateChartOption("title", { text: String(value) })
1427 }
1428 />
1429 <BlurInput
1430 id="chart-subtitle"
1431 placeholder="Chart subtitle"
1432 value={chartSubtitle}
1433 onChange={(value) =>
1434 updateChartOption("subtitle", { text: String(value) })
1435 }
1436 />
1437 </div>
1438 </div>
1439
1440 {/* Legend Settings */}
1441 <div className="space-y-2">
1442 <div className="flex items-center justify-between">
1443 <Label htmlFor="legend-toggle">Legend</Label>
1444 <Switch
1445 id="legend-toggle"
1446 checked={showLegend}
1447 onCheckedChange={(checked) => {
1448 updateChartOptions({
1449 showLegend: checked,
1450 legend: {
1451 enabled: checked,
1452 position: legendPosition,
1453 },
1454 });
1455 }}
1456 />
1457 </div>
1458 <div
1459 className={cn(
1460 "rounded-lg border p-3 transition-opacity",
1461 !showLegend && "opacity-50",
1462 )}
1463 >
1464 <Label htmlFor="legend-position" className="text-xs">
1465 Position
1466 </Label>
1467 <Select
1468 value={legendPosition}
1469 disabled={!showLegend}
1470 onValueChange={(value) => {
1471 updateChartOptions({
1472 showLegend: showLegend,
1473 legend: {
1474 enabled: showLegend,
1475 position: value,
1476 },
1477 });
1478 }}
1479 >
1480 <SelectTrigger id="legend-position" className="mt-1.5">
1481 <SelectValue placeholder="Position" />
1482 </SelectTrigger>
1483 <SelectContent>
1484 <SelectItem value="top">Top</SelectItem>
1485 <SelectItem value="right">Right</SelectItem>
1486 <SelectItem value="bottom">Bottom</SelectItem>
1487 <SelectItem value="left">Left</SelectItem>
1488 </SelectContent>
1489 </Select>
1490 </div>
1491 </div>
1492
1493 {/* Animation Settings */}
1494 <div className="space-y-2">
1495 <div className="flex items-center justify-between">
1496 <Label htmlFor="animation-toggle">Animation</Label>
1497 <Switch
1498 id="animation-toggle"
1499 checked={animationEnabled}
1500 onCheckedChange={(checked) => {
1501 updateChartOptions({
1502 disableAnimation: !checked,
1503 animation: {
1504 enabled: checked,
1505 duration: animationDuration,
1506 },
1507 });
1508 }}
1509 />
1510 </div>
1511 <div
1512 className={cn(
1513 "rounded-lg border p-3 transition-opacity",
1514 !animationEnabled && "opacity-50",
1515 )}
1516 >
1517 <div className="flex items-center justify-between">
1518 <Label htmlFor="animation-duration" className="text-xs">
1519 Duration
1520 </Label>
1521 <span className="text-xs text-muted-foreground">
1522 {animationDuration}ms
1523 </span>
1524 </div>
1525 <Slider
1526 id="animation-duration"
1527 min={100}
1528 max={2000}
1529 step={100}
1530 value={[animationDuration]}
1531 disabled={!animationEnabled}
1532 onValueChange={([value]) => {
1533 updateChartOption("animation", {
1534 enabled: animationEnabled,
1535 duration: value,
1536 });
1537 }}
1538 className="mt-2"
1539 />
1540 </div>
1541 </div>
1542
1543 {/* Background Settings */}
1544 <div className="space-y-2">
1545 <div className="flex items-center justify-between">
1546 <Label htmlFor="background-toggle">Background</Label>
1547 <Switch
1548 id="background-toggle"
1549 checked={backgroundVisible}
1550 onCheckedChange={(checked) => {
1551 updateChartOptions({
1552 background: {
1553 fill: backgroundFill,
1554 visible: checked,
1555 },
1556 });
1557 }}
1558 />
1559 </div>
1560 <div
1561 className={cn(
1562 "rounded-lg border p-3 transition-opacity",
1563 !backgroundVisible && "opacity-50",
1564 )}
1565 >
1566 <Label htmlFor="background-fill" className="text-xs">
1567 Color
1568 </Label>
1569 <div className="mt-1.5 flex gap-2">
1570 <Input
1571 id="background-fill"
1572 type="color"
1573 value={backgroundFill || "#ffffff"}
1574 disabled={!backgroundVisible}
1575 onChange={(e) => {
1576 updateChartOption("background", {
1577 fill: e.target.value,
1578 visible: backgroundVisible,
1579 });
1580 }}
1581 className="h-8 w-12 p-1"
1582 />
1583 <BlurInput
1584 value={backgroundFill}
1585 disabled={!backgroundVisible}
1586 onChange={(value) => {
1587 updateChartOption("background", {
1588 fill: String(value),
1589 visible: backgroundVisible,
1590 });
1591 }}
1592 placeholder="#ffffff"
1593 className="flex-1"
1594 />
1595 </div>
1596 </div>
1597 </div>
1598
1599 {/* Donut Inner Labels - Only for Donut Charts */}
1600 {isDonutChart && (
1601 <div className="space-y-3 rounded-lg border p-4">
1602 <h5 className="text-sm font-medium">Inner Labels</h5>
1603 <div className="space-y-2">
1604 <BlurInput
1605 id="inner-label-title"
1606 placeholder="Title (e.g. Total)"
1607 value={innerLabelTitle}
1608 onChange={(value) => {
1609 const newLabels: InnerLabelConfig[] = [];
1610 if (value) {
1611 newLabels.push({
1612 text: String(value),
1613 fontWeight: "bold",
1614 });
1615 }
1616 if (innerLabelValue) {
1617 newLabels.push({
1618 text: innerLabelValue,
1619 spacing: 4,
1620 fontSize: 32,
1621 });
1622 }
1623 updateChartOption("innerLabels", newLabels);
1624 }}
1625 />
1626 <BlurInput
1627 id="inner-label-value"
1628 placeholder="Value (e.g. $100,000)"
1629 value={innerLabelValue}
1630 onChange={(value) => {
1631 const newLabels: InnerLabelConfig[] = [];
1632 if (innerLabelTitle) {
1633 newLabels.push({
1634 text: innerLabelTitle,
1635 fontWeight: "bold",
1636 });
1637 }
1638 if (value) {
1639 newLabels.push({
1640 text: String(value),
1641 spacing: 4,
1642 fontSize: 32,
1643 });
1644 }
1645 updateChartOption("innerLabels", newLabels);
1646 }}
1647 />
1648 </div>
1649 <div className="space-y-2">
1650 <Label htmlFor="inner-circle-fill" className="text-xs">
1651 Background Color
1652 </Label>
1653 <div className="flex gap-2">
1654 <Input
1655 id="inner-circle-fill"
1656 type="color"
1657 value={innerCircleFill || "#f0f0f0"}
1658 onChange={(e) =>
1659 updateChartOption("innerCircle", {
1660 fill: e.target.value,
1661 })
1662 }
1663 className="h-8 w-12 p-1"
1664 />
1665 <BlurInput
1666 value={innerCircleFill}
1667 onChange={(value) =>
1668 updateChartOption("innerCircle", {
1669 fill: String(value),
1670 })
1671 }
1672 placeholder="#f0f0f0"
1673 className="flex-1"
1674 />
1675 </div>
1676 </div>
1677 <div className="space-y-2">
1678 <div className="flex items-center justify-between">
1679 <Label htmlFor="inner-radius-ratio" className="text-xs">
1680 Inner Radius: {Math.round(innerRadiusRatio * 100)}%
1681 </Label>
1682 </div>
1683 <Slider
1684 id="inner-radius-ratio"
1685 min={0}
1686 max={100}
1687 step={5}
1688 value={[Math.round(innerRadiusRatio * 100)]}
1689 onValueChange={([value]) => {
1690 if (value !== undefined) {
1691 updateChartOption("innerRadiusRatio", value / 100);
1692 }
1693 }}
1694 />
1695 </div>
1696 </div>
1697 )}
1698
1699 {/* Axis controls - only for charts with axes */}
1700 {supportsAxes && (
1701 <div className="space-y-4 rounded-lg border p-4">
1702 <div className="flex items-center justify-between">
1703 <Label className="text-sm font-medium">X-Axis</Label>
1704 <div className="flex gap-3">
1705 <div className="flex items-center gap-2">
1706 <Label htmlFor="x-axis-labels" className="text-xs">
1707 Labels
1708 </Label>
1709 <Switch
1710 id="x-axis-labels"
1711 checked={xAxisLabelEnabled}
1712 onCheckedChange={(checked) =>
1713 updateAxisOption("xAxis", {
1714 label: { enabled: checked },
1715 })
1716 }
1717 />
1718 </div>
1719 <div className="flex items-center gap-2">
1720 <Label htmlFor="x-axis-grid" className="text-xs">
1721 Grid
1722 </Label>
1723 <Switch
1724 id="x-axis-grid"
1725 checked={xAxisGridEnabled}
1726 onCheckedChange={(checked) =>
1727 updateAxisOption("xAxis", {
1728 gridLine: { enabled: checked },
1729 })
1730 }
1731 />
1732 </div>
1733 </div>
1734 </div>
1735 <div className="flex items-center justify-between">
1736 <Label htmlFor="x-axis-title-toggle" className="text-xs">
1737 Title
1738 </Label>
1739 <Switch
1740 id="x-axis-title-toggle"
1741 checked={xAxisTitleEnabled}
1742 onCheckedChange={(checked) =>
1743 updateAxisOption("xAxis", {
1744 title: { ...xAxisTitleConfig, enabled: checked },
1745 })
1746 }
1747 />
1748 </div>
1749 <BlurInput
1750 id="x-axis-title"
1751 placeholder="X-axis title"
1752 value={xAxisTitleText}
1753 disabled={!xAxisTitleEnabled}
1754 onChange={(value) =>
1755 updateAxisOption("xAxis", {
1756 title: { ...xAxisTitleConfig, text: String(value) },
1757 })
1758 }
1759 />
1760
1761 <div className="h-px bg-border" />
1762
1763 <div className="flex items-center justify-between">
1764 <Label className="text-sm font-medium">Y-Axis</Label>
1765 <div className="flex gap-3">
1766 <div className="flex items-center gap-2">
1767 <Label htmlFor="y-axis-labels" className="text-xs">
1768 Labels
1769 </Label>
1770 <Switch
1771 id="y-axis-labels"
1772 checked={yAxisLabelEnabled}
1773 onCheckedChange={(checked) =>
1774 updateAxisOption("yAxis", {
1775 label: { enabled: checked },
1776 })
1777 }
1778 />
1779 </div>
1780 <div className="flex items-center gap-2">
1781 <Label htmlFor="y-axis-grid" className="text-xs">
1782 Grid
1783 </Label>
1784 <Switch
1785 id="y-axis-grid"
1786 checked={yAxisGridEnabled}
1787 onCheckedChange={(checked) =>
1788 updateAxisOption("yAxis", {
1789 gridLine: { enabled: checked },
1790 })
1791 }
1792 />
1793 </div>
1794 </div>
1795 </div>
1796 <div className="flex items-center justify-between">
1797 <Label htmlFor="y-axis-title-toggle" className="text-xs">
1798 Title
1799 </Label>
1800 <Switch
1801 id="y-axis-title-toggle"
1802 checked={yAxisTitleEnabled}
1803 onCheckedChange={(checked) =>
1804 updateAxisOption("yAxis", {
1805 title: { ...yAxisTitleConfig, enabled: checked },
1806 })
1807 }
1808 />
1809 </div>
1810 <BlurInput
1811 id="y-axis-title"
1812 placeholder="Y-axis title"
1813 value={yAxisTitleText}
1814 disabled={!yAxisTitleEnabled}
1815 onChange={(value) =>
1816 updateAxisOption("yAxis", {
1817 title: { ...yAxisTitleConfig, text: String(value) },
1818 })
1819 }
1820 />
1821 </div>
1822 )}
1823
1824 {/* Remove Chart */}
1825 <div className="border-t pt-4">
1826 <Button
1827 variant="destructive"
1828 className="w-full gap-2"
1829 onClick={handleRemoveChart}
1830 >
1831 <Trash2 className="h-4 w-4" />
1832 Remove Chart
1833 </Button>
1834 </div>
1835 </div>
1836 </TabsContent>
1837 </ScrollArea>
1838 </Tabs>
1839
1840 <Dialog
1841 open={removedSankeyLinks.length > 0}
1842 onOpenChange={(open) => {
1843 if (!open) {
1844 setRemovedSankeyLinks([]);
1845 }
1846 }}
1847 >
1848 <DialogContent>
1849 <DialogHeader>
1850 <DialogTitle>Sankey cycle removed</DialogTitle>
1851 <DialogDescription>
1852 Sankey charts cannot contain circular flow paths. The conversion
1853 was applied after removing{" "}
1854 {removedSankeyLinks.length === 1
1855 ? "1 link"
1856 : `${removedSankeyLinks.length} links`}{" "}
1857 that would create a cycle.
1858 </DialogDescription>
1859 </DialogHeader>
1860 <div className="rounded-md border bg-muted/30 p-3 text-sm">
1861 <div className="space-y-1">
1862 {removedSankeyLinks.slice(0, 4).map((link) => (
1863 <div
1864 key={`${link.index}-${link.source}-${link.target}`}
1865 className="text-muted-foreground"
1866 >
1867 {link.source} {"->"} {link.target}
1868 </div>
1869 ))}
1870 </div>
1871 {removedSankeyLinks.length > 4 && (
1872 <p className="mt-2 text-xs text-muted-foreground">
1873 {removedSankeyLinks.length - 4} more removed.
1874 </p>
1875 )}
1876 </div>
1877 <DialogFooter>
1878 <Button onClick={() => setRemovedSankeyLinks([])}>
1879 Close warning
1880 </Button>
1881 </DialogFooter>
1882 </DialogContent>
1883 </Dialog>
1884
1885 {/* Chart Data Editor Dialog */}
1886 <ChartDataEditorDialog
1887 open={chartDataEditorOpen}
1888 onOpenChange={setChartDataEditorOpen}
1889 data={chartData || []}
1890 onDataChange={handleChartDataUpdate}
1891 chartType={chartDataMode}
1892 title={`Edit ${chartDisplayName} Data`}
1893 isComposedChart={isComposedChart}
1894 seriesChartTypes={seriesChartTypes}
1895 onSeriesChartTypesChange={handleSeriesChartTypesUpdate}
1896 previewChartType={chartType}
1897 chartOptions={chartOptions}
1898 />
1899 </>
1900 );
1901 }
1902
1902 lines Plain Text