返回 presentation-ai
ChartRenderer.tsx
1 "use client";
2
3 import * as React from "react";
4
5 import { cn } from "@/lib/utils";
6 import { usePresentationState } from "@/states/presentation-state";
7 import {
8 AREA_CHART_ELEMENT,
9 BAR_CHART_ELEMENT,
10 BOX_PLOT_CHART_ELEMENT,
11 BUBBLE_CHART_ELEMENT,
12 CANDLESTICK_CHART_ELEMENT,
13 CHORD_CHART_ELEMENT,
14 COMPOSED_CHART_ELEMENT,
15 CONE_FUNNEL_CHART_ELEMENT,
16 DONUT_CHART_ELEMENT,
17 FUNNEL_CHART_ELEMENT,
18 HEATMAP_CHART_ELEMENT,
19 HISTOGRAM_CHART_ELEMENT,
20 LINE_CHART_ELEMENT,
21 LINEAR_GAUGE_ELEMENT,
22 NIGHTINGALE_CHART_ELEMENT,
23 OHLC_CHART_ELEMENT,
24 PIE_CHART_ELEMENT,
25 PYRAMID_CHART_ELEMENT,
26 RADAR_CHART_ELEMENT,
27 RADIAL_BAR_CHART_ELEMENT,
28 RADIAL_COLUMN_CHART_ELEMENT,
29 RADIAL_GAUGE_ELEMENT,
30 RANGE_AREA_CHART_ELEMENT,
31 RANGE_BAR_CHART_ELEMENT,
32 SANKEY_CHART_ELEMENT,
33 SCATTER_CHART_ELEMENT,
34 SUNBURST_CHART_ELEMENT,
35 TREEMAP_CHART_ELEMENT,
36 WATERFALL_CHART_ELEMENT,
37 } from "../../lib";
38 import { type TChartNode } from "../../plugins/chart-plugin";
39 import {
40 buildChartConfigOptions,
41 getLabelKey,
42 getValueKey,
43 getValueKeys,
44 getXKey,
45 getYKey,
46 getZKey,
47 keyToLabel,
48 sanitizeSankeyCycleData,
49 } from "../chart-utils";
50 import { EChartWrapper } from "./echart-wrapper";
51 import {
52 getChartColor,
53 getChartColors,
54 type PresentationEChartsOption,
55 } from "./echart-wrapper";
56
57 type AnyRecord = Record<string, unknown>;
58 type SeriesChartType = "bar" | "line" | "area" | "scatter";
59 type EChartSeries = NonNullable<PresentationEChartsOption["series"]>;
60 type EChartSeriesItem = Record<string, unknown>;
61 type EChartAxis = Record<string, unknown>;
62 type ChartAnimationSettings = {
63 duration?: number;
64 enabled: boolean;
65 };
66 type ChartLegendEntry = {
67 color: string;
68 label: string;
69 };
70 type ChordNode = {
71 color: string;
72 endAngle: number;
73 name: string;
74 startAngle: number;
75 value: number;
76 };
77 type ChordLink = {
78 color: string;
79 source: ChordNode;
80 target: ChordNode;
81 value: number;
82 };
83 type BinnedHistogramData = {
84 labels: string[];
85 valueKey: string;
86 values: number[];
87 };
88
89 export interface ChartRendererProps {
90 chartType: string;
91 chartData: unknown;
92 chartOptions?: Record<string, unknown>;
93 className?: string;
94 style?: React.CSSProperties;
95 }
96
97 const DEFAULT_CONTAINER_CLASS =
98 "flex w-full flex-col rounded-lg border bg-card p-2 shadow-xs";
99 const EMPTY_CHART_OPTIONS: Record<string, unknown> = {};
100 const CHORD_LABEL_FONT_SIZE = 12;
101 const CHORD_VIEWBOX_SIZE = 400;
102 const CHORD_LABEL_PADDING = 8;
103 const CHORD_LABEL_BLEED = 24;
104 const SVG_LABEL_AVERAGE_CHAR_WIDTH = 0.56;
105 const PRESENT_MODE_ANIMATION_REPLAY_DELAY_MS = 360;
106
107 function toRecordArray(value: unknown): AnyRecord[] {
108 return Array.isArray(value) ? (value as AnyRecord[]) : [];
109 }
110
111 function toNumber(value: unknown, fallback = 0): number {
112 return typeof value === "number" && Number.isFinite(value) ? value : fallback;
113 }
114
115 function toText(value: unknown, fallback = ""): string {
116 if (typeof value === "string") return value;
117 if (typeof value === "number") return String(value);
118 return fallback;
119 }
120
121 function truncateSvgLabel(
122 label: string,
123 maxWidth: number,
124 fontSize: number,
125 ): string {
126 const ellipsis = "...";
127 const characterWidth = fontSize * SVG_LABEL_AVERAGE_CHAR_WIDTH;
128 const maxCharacters = Math.floor(maxWidth / characterWidth);
129
130 if (label.length <= maxCharacters) return label;
131 if (maxCharacters <= ellipsis.length) return ellipsis;
132
133 return `${label.slice(0, maxCharacters - ellipsis.length)}${ellipsis}`;
134 }
135
136 function getAnchoredSvgLabelWidth(
137 anchor: "end" | "middle" | "start",
138 x: number,
139 ): number {
140 const leftSpace = x - CHORD_LABEL_PADDING + CHORD_LABEL_BLEED;
141 const rightSpace =
142 CHORD_VIEWBOX_SIZE - x - CHORD_LABEL_PADDING + CHORD_LABEL_BLEED;
143
144 if (anchor === "end") return Math.max(0, leftSpace);
145 if (anchor === "start") return Math.max(0, rightSpace);
146
147 return Math.max(0, Math.min(leftSpace, rightSpace) * 2);
148 }
149
150 function isRecord(value: unknown): value is AnyRecord {
151 return typeof value === "object" && value !== null && !Array.isArray(value);
152 }
153
154 function getSeriesArray(option: PresentationEChartsOption): EChartSeriesItem[] {
155 const series = option.series;
156 if (!series) return [];
157 return Array.isArray(series)
158 ? (series as EChartSeriesItem[])
159 : [series as EChartSeriesItem];
160 }
161
162 function getLegendColor(
163 seriesItem: EChartSeriesItem,
164 index: number,
165 element: TChartNode,
166 ): string {
167 const itemStyle = seriesItem.itemStyle;
168 const itemStyleColor = isRecord(itemStyle) ? itemStyle.color : undefined;
169
170 if (typeof itemStyleColor === "string") return itemStyleColor;
171 return element.colors?.[index] ?? getChartColor(index);
172 }
173
174 function buildLegendEntries(
175 option: PresentationEChartsOption,
176 element: TChartNode,
177 ): ChartLegendEntry[] {
178 const entries = getSeriesArray(option).flatMap((seriesItem, seriesIndex) => {
179 const seriesData = seriesItem.data;
180
181 if (Array.isArray(seriesData)) {
182 const namedData = seriesData.filter(
183 (item): item is AnyRecord => isRecord(item) && "name" in item,
184 );
185
186 if (namedData.length > 0) {
187 return namedData.map((item, itemIndex) => ({
188 color: getLegendColor(item, itemIndex, element),
189 label: toText(item.name, `Item ${itemIndex + 1}`),
190 }));
191 }
192 }
193
194 const name = seriesItem.name;
195 return typeof name === "string" && name.length > 0
196 ? [
197 {
198 color: getLegendColor(seriesItem, seriesIndex, element),
199 label: name,
200 },
201 ]
202 : [];
203 });
204
205 const seenLabels = new Set<string>();
206 return entries.filter((entry) => {
207 if (seenLabels.has(entry.label)) return false;
208 seenLabels.add(entry.label);
209 return true;
210 });
211 }
212
213 function isLegendEnabled(option: PresentationEChartsOption): boolean {
214 const legend = option.legend;
215 if (Array.isArray(legend)) {
216 return legend.some((item) => !isRecord(item) || item.show !== false);
217 }
218
219 return !isRecord(legend) || legend.show !== false;
220 }
221
222 function disableBuiltInLegend(
223 option: PresentationEChartsOption,
224 ): PresentationEChartsOption {
225 const legend = option.legend;
226
227 if (Array.isArray(legend)) {
228 return {
229 ...option,
230 legend: legend.map((item) =>
231 isRecord(item) ? { ...item, show: false } : item,
232 ),
233 };
234 }
235
236 return {
237 ...option,
238 legend: isRecord(legend) ? { ...legend, show: false } : { show: false },
239 };
240 }
241
242 function getChartAnimationSettings(
243 element: TChartNode,
244 ): ChartAnimationSettings {
245 const config = buildChartConfigOptions(element);
246
247 return config.animation;
248 }
249
250 function withSeriesAnimation(
251 option: PresentationEChartsOption,
252 animation: ChartAnimationSettings,
253 ): PresentationEChartsOption {
254 const series = option.series;
255 if (!series) return option;
256
257 const applyAnimation = (seriesItem: EChartSeriesItem): EChartSeriesItem => ({
258 ...seriesItem,
259 animation: animation.enabled,
260 animationDuration: animation.duration,
261 animationDurationUpdate: animation.duration,
262 });
263
264 return {
265 ...option,
266 series: Array.isArray(series)
267 ? (series as EChartSeriesItem[]).map(applyAnimation)
268 : applyAnimation(series as EChartSeriesItem),
269 };
270 }
271
272 function ChartLegend({ entries }: { entries: ChartLegendEntry[] }) {
273 if (entries.length === 0) return null;
274
275 return (
276 <div className="flex max-w-full flex-wrap items-center justify-center gap-x-3 gap-y-1 px-2 pt-1 text-[12px] leading-snug">
277 {entries.map((entry) => (
278 <div
279 key={entry.label}
280 className="flex max-w-full min-w-0 items-start gap-1.5"
281 >
282 <span
283 className="mt-[0.3em] h-2.5 w-5 shrink-0 rounded-sm"
284 style={{ backgroundColor: entry.color }}
285 />
286 <span className="max-w-full min-w-0 wrap-break-word whitespace-normal">
287 {entry.label}
288 </span>
289 </div>
290 ))}
291 </div>
292 );
293 }
294
295 function buildLegendPosition(position?: string): Record<string, unknown> {
296 switch (position) {
297 case "left":
298 return { left: 8, top: "middle", orient: "vertical" };
299 case "right":
300 return { right: 8, top: "middle", orient: "vertical" };
301 case "top":
302 return { top: 8, left: "center", orient: "horizontal" };
303 default:
304 return { bottom: 8, left: "center", orient: "horizontal" };
305 }
306 }
307
308 function buildBaseOption(
309 element: TChartNode,
310 previewMode: boolean,
311 ): PresentationEChartsOption {
312 const config = buildChartConfigOptions(element);
313 const textColor = "var(--presentation-text)";
314 const showTooltip = !previewMode;
315
316 return {
317 color: element.colors ?? getChartColors(),
318 backgroundColor:
319 config.background.visible && config.background.fill
320 ? config.background.fill
321 : "transparent",
322 animation: config.animation.enabled,
323 animationDuration: config.animation.duration,
324 animationDurationUpdate: config.animation.duration,
325 animationEasing: "cubicOut",
326 animationEasingUpdate: "cubicOut",
327 animationThreshold: 10000,
328 textStyle: {
329 color: textColor,
330 fontFamily: "inherit",
331 },
332 title: config.title
333 ? {
334 text: config.title.text,
335 subtext: config.subtitle?.text,
336 left: "center",
337 top: 4,
338 textStyle: {
339 color: config.title.color ?? textColor,
340 fontSize: config.title.fontSize,
341 fontWeight: 600,
342 },
343 subtextStyle: {
344 color: config.subtitle?.color ?? textColor,
345 fontSize: config.subtitle?.fontSize,
346 },
347 }
348 : config.subtitle
349 ? {
350 subtext: config.subtitle.text,
351 left: "center",
352 top: 4,
353 subtextStyle: {
354 color: config.subtitle.color ?? textColor,
355 fontSize: config.subtitle.fontSize,
356 },
357 }
358 : undefined,
359 legend: {
360 show: config.legend.enabled,
361 textStyle: { color: textColor },
362 ...buildLegendPosition(config.legend.position),
363 },
364 tooltip: {
365 show: showTooltip,
366 trigger: "item",
367 confine: true,
368 },
369 };
370 }
371
372 function buildAxisOptions(
373 element: TChartNode,
374 axis: "x" | "y",
375 type: "category" | "value" = "value",
376 data?: string[],
377 ): EChartAxis {
378 const config = buildChartConfigOptions(element);
379 const axisConfig = axis === "x" ? config.xAxis : config.yAxis;
380 const name = axisConfig.title?.text;
381
382 return {
383 type,
384 data,
385 name,
386 nameTextStyle: { color: "var(--presentation-text)" },
387 axisLabel: {
388 show: axisConfig.showLabel,
389 color: "var(--presentation-text)",
390 },
391 axisLine: { lineStyle: { color: "rgba(148, 163, 184, 0.6)" } },
392 splitLine: {
393 show: axisConfig.showGrid,
394 lineStyle: { color: "rgba(148, 163, 184, 0.24)" },
395 },
396 };
397 }
398
399 function labels(dataArray: AnyRecord[], labelKey: string): string[] {
400 return dataArray.map((item, index) =>
401 toText(item[labelKey], `Item ${index + 1}`),
402 );
403 }
404
405 function numericValues(dataArray: AnyRecord[], key: string): number[] {
406 return dataArray.map((item) => toNumber(item[key]));
407 }
408
409 function isHistogramBinLabel(value: unknown): value is string {
410 if (typeof value !== "string") return false;
411
412 const normalizedValue = value.trim();
413
414 return (
415 /^\d+(?:\.\d+)?\s*(?:-|–|—|to)\s*\d+(?:\.\d+)?$/i.test(normalizedValue) ||
416 /^(?:<|>|<=|>=)\s*\d+(?:\.\d+)?$/.test(normalizedValue)
417 );
418 }
419
420 function getBinnedHistogramData(
421 dataArray: AnyRecord[],
422 ): BinnedHistogramData | null {
423 if (dataArray.length === 0) return null;
424
425 const first = dataArray[0];
426 if (!first) return null;
427
428 const explicitLabelKey = Object.keys(first).find((key) =>
429 ["bin", "bucket", "interval", "range", "label", "name"].includes(
430 key.toLowerCase(),
431 ),
432 );
433 const labelKey = explicitLabelKey ?? getLabelKey(dataArray);
434 const valueKey =
435 Object.keys(first).find(
436 (key) =>
437 ["value", "count", "frequency", "freq"].includes(key.toLowerCase()) &&
438 typeof first[key] === "number",
439 ) ?? getValueKey(dataArray);
440
441 const labelsAndValues = dataArray.map((item, index) => ({
442 label: toText(item[labelKey], `Bin ${index + 1}`),
443 value: item[valueKey],
444 }));
445 const hasValidValues = labelsAndValues.every(
446 ({ value }) => typeof value === "number" && Number.isFinite(value),
447 );
448
449 if (!hasValidValues) return null;
450
451 const hasExplicitBinKey = Boolean(explicitLabelKey);
452 const hasRangeLabels = labelsAndValues.every(({ label }) =>
453 isHistogramBinLabel(label),
454 );
455
456 if (!hasExplicitBinKey && !hasRangeLabels) return null;
457
458 return {
459 labels: labelsAndValues.map(({ label }) => label),
460 valueKey,
461 values: labelsAndValues.map(({ value }) => value as number),
462 };
463 }
464
465 function buildPieSeries(
466 dataArray: AnyRecord[],
467 labelKey: string,
468 valueKey: string,
469 radius: string | [string, string],
470 roseType?: "radius" | "area",
471 ): EChartSeriesItem {
472 return {
473 type: "pie",
474 radius,
475 roseType,
476 avoidLabelOverlap: true,
477 itemStyle: { borderWidth: 1, borderColor: "rgba(255,255,255,0.28)" },
478 label: { color: "var(--presentation-text)" },
479 data: dataArray.map((item, index) => ({
480 name: toText(item[labelKey], `Item ${index + 1}`),
481 value: toNumber(item[valueKey]),
482 })),
483 };
484 }
485
486 function buildCartesianOption(
487 element: TChartNode,
488 chartType: "bar" | "line" | "scatter",
489 dataArray: AnyRecord[],
490 labelKey: string,
491 valueKeys: string[],
492 horizontal = false,
493 ): Pick<PresentationEChartsOption, "grid" | "xAxis" | "yAxis" | "series"> {
494 const categoryLabels = labels(dataArray, labelKey);
495 const xAxis = horizontal
496 ? buildAxisOptions(element, "x", "value")
497 : buildAxisOptions(element, "x", "category", categoryLabels);
498 const yAxis = horizontal
499 ? buildAxisOptions(element, "y", "category", categoryLabels)
500 : buildAxisOptions(element, "y", "value");
501
502 const series = valueKeys.map((key, index) => {
503 const color =
504 index === 0 ? (element.color ?? getChartColor(0)) : getChartColor(index);
505 const values = numericValues(dataArray, key);
506 const seriesData = horizontal
507 ? values.map((value, pointIndex) => [value, categoryLabels[pointIndex]])
508 : values;
509
510 return {
511 type: chartType,
512 name: keyToLabel(key),
513 data: seriesData,
514 smooth: chartType === "line" && element.interpolation !== "linear",
515 step:
516 chartType === "line" && element.interpolation?.startsWith("step")
517 ? element.interpolation === "step-start"
518 ? "start"
519 : element.interpolation === "step-end"
520 ? "end"
521 : "middle"
522 : false,
523 itemStyle: { color },
524 lineStyle: { color, width: 2 },
525 areaStyle:
526 chartType === "line" && element.curveType === "natural"
527 ? { color, opacity: 0.18 }
528 : undefined,
529 symbolSize:
530 chartType === "scatter" ? (element.marker?.size ?? 10) : undefined,
531 };
532 });
533
534 return {
535 grid: { top: 52, right: 28, bottom: 48, left: 54, containLabel: true },
536 xAxis,
537 yAxis,
538 series: series as EChartSeries,
539 };
540 }
541
542 function buildAreaOption(
543 element: TChartNode,
544 dataArray: AnyRecord[],
545 labelKey: string,
546 valueKeys: string[],
547 ): Pick<PresentationEChartsOption, "grid" | "xAxis" | "yAxis" | "series"> {
548 const base = buildCartesianOption(
549 element,
550 "line",
551 dataArray,
552 labelKey,
553 valueKeys,
554 );
555 const series = valueKeys.map((key, index) => {
556 const color =
557 index === 0 ? (element.color ?? getChartColor(0)) : getChartColor(index);
558 return {
559 type: "line",
560 name: keyToLabel(key),
561 data: numericValues(dataArray, key),
562 smooth: element.interpolation !== "linear",
563 areaStyle: { color, opacity: 0.28 },
564 itemStyle: { color },
565 lineStyle: { color, width: 2 },
566 symbol: "none",
567 };
568 });
569
570 return { ...base, series: series as EChartSeries };
571 }
572
573 function buildRadarOption(
574 element: TChartNode,
575 dataArray: AnyRecord[],
576 labelKey: string,
577 valueKeys: string[],
578 ): Pick<PresentationEChartsOption, "radar" | "series"> {
579 const maxValue = Math.max(
580 1,
581 ...dataArray.flatMap((item) => valueKeys.map((key) => toNumber(item[key]))),
582 );
583
584 return {
585 radar: {
586 indicator: labels(dataArray, labelKey).map((name) => ({
587 name,
588 max: maxValue * 1.2,
589 })),
590 axisName: { color: "var(--presentation-text)" },
591 splitLine: { lineStyle: { color: "rgba(148, 163, 184, 0.28)" } },
592 splitArea: { show: false },
593 },
594 series: [
595 {
596 type: "radar",
597 data: valueKeys.map((key, index) => ({
598 name: keyToLabel(key),
599 value: numericValues(dataArray, key),
600 itemStyle: { color: getChartColor(index) },
601 areaStyle:
602 element.variant === "outline" ? undefined : { opacity: 0.18 },
603 lineStyle: { width: element.variant === "outline" ? 3 : 2 },
604 })),
605 },
606 ] as EChartSeries,
607 };
608 }
609
610 function buildScatterOption(
611 element: TChartNode,
612 dataArray: AnyRecord[],
613 bubble: boolean,
614 ): Pick<PresentationEChartsOption, "grid" | "xAxis" | "yAxis" | "series"> {
615 const xKey = getXKey(dataArray);
616 const yKey = getYKey(dataArray);
617 const zKey = getZKey(dataArray);
618 const primaryColor = element.color ?? getChartColor(0);
619
620 return {
621 grid: { top: 44, right: 28, bottom: 48, left: 54, containLabel: true },
622 xAxis: buildAxisOptions(element, "x", "value"),
623 yAxis: buildAxisOptions(element, "y", "value"),
624 series: [
625 {
626 type: "scatter",
627 name: bubble ? "Bubble" : "Scatter",
628 data: dataArray.map((item) => [
629 toNumber(item[xKey]),
630 toNumber(item[yKey]),
631 toNumber(item[zKey], 10),
632 ]),
633 symbolSize: bubble
634 ? (value: unknown) => {
635 const tuple = Array.isArray(value) ? value : [];
636 return Math.max(8, Math.min(44, toNumber(tuple[2], 10)));
637 }
638 : (element.marker?.size ?? 10),
639 itemStyle: { color: primaryColor, opacity: bubble ? 0.72 : 1 },
640 },
641 ] as EChartSeries,
642 };
643 }
644
645 function buildTreemapData(
646 dataArray: AnyRecord[],
647 labelKey: string,
648 valueKey: string,
649 ): EChartSeriesItem[] {
650 return dataArray.map((item, index) => ({
651 name: toText(item[labelKey], `Item ${index + 1}`),
652 value: toNumber(item[valueKey], 1),
653 }));
654 }
655
656 function normalizeHierarchy(dataArray: AnyRecord[]): EChartSeriesItem[] {
657 const rowsWithParents = dataArray.filter(
658 (item) => typeof item.parent === "string" && item.parent.trim().length > 0,
659 );
660
661 if (rowsWithParents.length > 0) {
662 const nodeMap = new Map<string, EChartSeriesItem>();
663 const childNames = new Set<string>();
664
665 dataArray.forEach((item, index) => {
666 const name = toText(item.name, `Item ${index + 1}`);
667 nodeMap.set(name, {
668 name,
669 value: toNumber(item.value, 1),
670 children: [],
671 });
672 });
673
674 dataArray.forEach((item, index) => {
675 const name = toText(item.name, `Item ${index + 1}`);
676 const parent = toText(item.parent).trim();
677 const node = nodeMap.get(name);
678 const parentNode = parent.length > 0 ? nodeMap.get(parent) : undefined;
679
680 if (node && parentNode) {
681 const currentChildren = Array.isArray(parentNode.children)
682 ? (parentNode.children as EChartSeriesItem[])
683 : [];
684 parentNode.children = [...currentChildren, node];
685 childNames.add(name);
686 }
687 });
688
689 return Array.from(nodeMap.entries())
690 .filter(([name]) => !childNames.has(name))
691 .map(([, node]) => {
692 if (Array.isArray(node.children) && node.children.length === 0) {
693 const { children: _children, ...leafNode } = node;
694 return leafNode;
695 }
696 return node;
697 });
698 }
699
700 return dataArray.map((item, index) => ({
701 name: toText(item.name, `Item ${index + 1}`),
702 value: toNumber(item.value, 1),
703 children: Array.isArray(item.children)
704 ? normalizeHierarchy(item.children as AnyRecord[])
705 : undefined,
706 }));
707 }
708
709 function buildFlowNodes(dataArray: AnyRecord[]): string[] {
710 return Array.from(
711 new Set(
712 dataArray.flatMap((item) => [
713 toText(item.from ?? item.source, "Source"),
714 toText(item.to ?? item.target, "Target"),
715 ]),
716 ),
717 );
718 }
719
720 function buildFlowLinks(dataArray: AnyRecord[]): EChartSeriesItem[] {
721 return dataArray.map((item) => ({
722 source: toText(item.from ?? item.source, "Source"),
723 target: toText(item.to ?? item.target, "Target"),
724 value: toNumber(item.size ?? item.value, 1),
725 }));
726 }
727
728 function polarToCartesian(
729 center: number,
730 radius: number,
731 angle: number,
732 ): { x: number; y: number } {
733 return {
734 x: center + radius * Math.cos(angle),
735 y: center + radius * Math.sin(angle),
736 };
737 }
738
739 function describeArc(
740 center: number,
741 radius: number,
742 startAngle: number,
743 endAngle: number,
744 ): string {
745 const start = polarToCartesian(center, radius, startAngle);
746 const end = polarToCartesian(center, radius, endAngle);
747 const largeArcFlag = endAngle - startAngle > Math.PI ? 1 : 0;
748
749 return `M ${start.x} ${start.y} A ${radius} ${radius} 0 ${largeArcFlag} 1 ${end.x} ${end.y}`;
750 }
751
752 function buildRibbonPath(
753 center: number,
754 radius: number,
755 sourceStartAngle: number,
756 sourceEndAngle: number,
757 targetStartAngle: number,
758 targetEndAngle: number,
759 ): string {
760 const sourceStart = polarToCartesian(center, radius, sourceStartAngle);
761 const sourceEnd = polarToCartesian(center, radius, sourceEndAngle);
762 const targetStart = polarToCartesian(center, radius, targetStartAngle);
763 const targetEnd = polarToCartesian(center, radius, targetEndAngle);
764 const sourceControl = polarToCartesian(
765 center,
766 radius * 0.08,
767 (sourceStartAngle + sourceEndAngle) / 2,
768 );
769 const targetControl = polarToCartesian(
770 center,
771 radius * 0.08,
772 (targetStartAngle + targetEndAngle) / 2,
773 );
774 const sourceLargeArc = sourceEndAngle - sourceStartAngle > Math.PI ? 1 : 0;
775 const targetLargeArc = targetEndAngle - targetStartAngle > Math.PI ? 1 : 0;
776
777 return [
778 `M ${sourceStart.x} ${sourceStart.y}`,
779 `A ${radius} ${radius} 0 ${sourceLargeArc} 1 ${sourceEnd.x} ${sourceEnd.y}`,
780 `C ${sourceControl.x} ${sourceControl.y} ${targetControl.x} ${targetControl.y} ${targetStart.x} ${targetStart.y}`,
781 `A ${radius} ${radius} 0 ${targetLargeArc} 1 ${targetEnd.x} ${targetEnd.y}`,
782 `C ${targetControl.x} ${targetControl.y} ${sourceControl.x} ${sourceControl.y} ${sourceStart.x} ${sourceStart.y}`,
783 "Z",
784 ].join(" ");
785 }
786
787 function buildChordData(
788 dataArray: AnyRecord[],
789 element: TChartNode,
790 ): { links: ChordLink[]; nodes: ChordNode[] } {
791 const names = buildFlowNodes(dataArray);
792 const nodeValues = new Map(names.map((name) => [name, 0]));
793
794 const rawLinks = dataArray.map((item) => {
795 const source = toText(item.from ?? item.source, "Source");
796 const target = toText(item.to ?? item.target, "Target");
797 const value = toNumber(item.size ?? item.value, 1);
798
799 nodeValues.set(source, (nodeValues.get(source) ?? 0) + value);
800 nodeValues.set(target, (nodeValues.get(target) ?? 0) + value);
801
802 return { source, target, value };
803 });
804
805 const totalValue = Array.from(nodeValues.values()).reduce(
806 (total, value) => total + value,
807 0,
808 );
809 const gap = Math.PI / 60;
810 const drawableAngle = Math.PI * 2 - gap * names.length;
811 let cursor = -Math.PI / 2;
812
813 const nodes = names.map((name, index) => {
814 const value = nodeValues.get(name) ?? 0;
815 const angle = totalValue > 0 ? (value / totalValue) * drawableAngle : 0;
816 const startAngle = cursor;
817 const endAngle = cursor + angle;
818 cursor = endAngle + gap;
819
820 return {
821 color: element.colors?.[index] ?? getChartColor(index),
822 endAngle,
823 name,
824 startAngle,
825 value,
826 };
827 });
828 const nodesByName = new Map(nodes.map((node) => [node.name, node]));
829
830 return {
831 nodes,
832 links: rawLinks.flatMap((link) => {
833 const source = nodesByName.get(link.source);
834 const target = nodesByName.get(link.target);
835
836 return source && target
837 ? [
838 {
839 color: target.color,
840 source,
841 target,
842 value: link.value,
843 },
844 ]
845 : [];
846 }),
847 };
848 }
849
850 function CustomChordChart({
851 dataArray,
852 element,
853 }: {
854 dataArray: AnyRecord[];
855 element: TChartNode;
856 }) {
857 const { links, nodes } = React.useMemo(
858 () => buildChordData(dataArray, element),
859 [dataArray, element],
860 );
861 const center = 200;
862 const outerRadius = 150;
863 const ribbonRadius = 126;
864
865 return (
866 <svg
867 className="h-full min-h-[inherit] w-full overflow-visible"
868 role="img"
869 viewBox="0 0 400 400"
870 >
871 <title>Chord chart</title>
872 <g>
873 {links.map((link) => {
874 const sourceAngle =
875 (link.source.startAngle + link.source.endAngle) / 2;
876 const targetAngle =
877 (link.target.startAngle + link.target.endAngle) / 2;
878 const sourceShare =
879 link.source.value > 0 ? link.value / link.source.value : 0;
880 const targetShare =
881 link.target.value > 0 ? link.value / link.target.value : 0;
882 const sourceHalfSpan =
883 ((link.source.endAngle - link.source.startAngle) * sourceShare) / 2;
884 const targetHalfSpan =
885 ((link.target.endAngle - link.target.startAngle) * targetShare) / 2;
886
887 return (
888 <path
889 d={buildRibbonPath(
890 center,
891 ribbonRadius,
892 sourceAngle - sourceHalfSpan,
893 sourceAngle + sourceHalfSpan,
894 targetAngle - targetHalfSpan,
895 targetAngle + targetHalfSpan,
896 )}
897 fill={link.color}
898 fillOpacity={Math.min(0.42, 0.18 + link.value / 180)}
899 key={`${link.source.name}-${link.target.name}-${link.value}-${link.source.startAngle}-${link.target.startAngle}`}
900 stroke="rgba(255,255,255,0.48)"
901 strokeWidth={1}
902 />
903 );
904 })}
905 </g>
906 <g>
907 {nodes.map((node) => {
908 const labelAngle = (node.startAngle + node.endAngle) / 2;
909 const labelPoint = polarToCartesian(
910 center,
911 outerRadius + 18,
912 labelAngle,
913 );
914 const anchor =
915 Math.cos(labelAngle) > 0.2
916 ? "start"
917 : Math.cos(labelAngle) < -0.2
918 ? "end"
919 : "middle";
920 const label = truncateSvgLabel(
921 node.name,
922 getAnchoredSvgLabelWidth(anchor, labelPoint.x),
923 CHORD_LABEL_FONT_SIZE,
924 );
925
926 return (
927 <g key={node.name}>
928 <path
929 d={describeArc(
930 center,
931 outerRadius,
932 node.startAngle,
933 node.endAngle,
934 )}
935 fill="none"
936 stroke={node.color}
937 strokeLinecap="round"
938 strokeWidth={26}
939 />
940 <text
941 fill={node.color}
942 fontSize={CHORD_LABEL_FONT_SIZE}
943 textAnchor={anchor}
944 x={labelPoint.x}
945 y={labelPoint.y}
946 >
947 <title>{node.name}</title>
948 {label}
949 </text>
950 </g>
951 );
952 })}
953 </g>
954 </svg>
955 );
956 }
957
958 function buildFlowSeries(dataArray: AnyRecord[]): EChartSeriesItem {
959 const names = buildFlowNodes(dataArray);
960
961 return {
962 type: "sankey",
963 data: names.map((name) => ({ name })),
964 links: buildFlowLinks(dataArray),
965 lineStyle: { color: "gradient", curveness: 0.5 },
966 label: { color: "var(--presentation-text)" },
967 };
968 }
969
970 function buildFunnelSeries(
971 dataArray: AnyRecord[],
972 labelKey: string,
973 valueKey: string,
974 variant: "funnel" | "cone" | "pyramid",
975 ): EChartSeriesItem {
976 return {
977 type: "funnel",
978 sort: variant === "pyramid" ? "ascending" : "descending",
979 funnelAlign: "center",
980 width: variant === "cone" ? "55%" : "72%",
981 minSize: variant === "cone" ? "12%" : "0%",
982 maxSize: "100%",
983 label: { color: "var(--presentation-text)" },
984 data: dataArray.map((item, index) => ({
985 name: toText(item[labelKey], `Stage ${index + 1}`),
986 value: toNumber(item[valueKey]),
987 })),
988 };
989 }
990
991 function buildGaugeOption(
992 element: TChartNode,
993 chartData: unknown,
994 dataArray: AnyRecord[],
995 linear: boolean,
996 ): Pick<PresentationEChartsOption, "series"> {
997 const first = dataArray[0];
998 const numericKey = first
999 ? Object.keys(first).find((key) => typeof first[key] === "number")
1000 : undefined;
1001 const value =
1002 typeof chartData === "number"
1003 ? chartData
1004 : numericKey && first
1005 ? toNumber(first[numericKey], 50)
1006 : 50;
1007 const color = element.color ?? element.bar?.fill ?? getChartColor(0);
1008
1009 if (linear) {
1010 return {
1011 series: [
1012 {
1013 type: "custom",
1014 coordinateSystem: "none",
1015 renderItem: (
1016 _params: unknown,
1017 api: {
1018 getWidth: () => number;
1019 getHeight: () => number;
1020 style: (
1021 style: Record<string, unknown>,
1022 ) => Record<string, unknown>;
1023 },
1024 ) => {
1025 const width = api.getWidth();
1026 const height = api.getHeight();
1027 const horizontal = element.orientation !== "vertical";
1028 const trackWidth = horizontal ? width * 0.74 : 18;
1029 const trackHeight = horizontal ? 18 : height * 0.68;
1030 const x = (width - trackWidth) / 2;
1031 const y = (height - trackHeight) / 2;
1032 const ratio = Math.max(0, Math.min(1, value / 100));
1033 const fillWidth = horizontal ? trackWidth * ratio : trackWidth;
1034 const fillHeight = horizontal ? trackHeight : trackHeight * ratio;
1035 const fillX = x;
1036 const fillY = horizontal ? y : y + trackHeight - fillHeight;
1037
1038 return {
1039 type: "group",
1040 children: [
1041 {
1042 type: "rect",
1043 shape: { x, y, width: trackWidth, height: trackHeight, r: 9 },
1044 style: api.style({
1045 fill: "rgba(148, 163, 184, 0.22)",
1046 stroke: "rgba(148, 163, 184, 0.34)",
1047 }),
1048 },
1049 {
1050 type: "rect",
1051 shape: {
1052 x: fillX,
1053 y: fillY,
1054 width: fillWidth,
1055 height: fillHeight,
1056 r: 9,
1057 },
1058 style: api.style({ fill: color }),
1059 },
1060 {
1061 type: "text",
1062 style: {
1063 text: `${Math.round(value)}`,
1064 x: width / 2,
1065 y: horizontal ? y - 14 : y + trackHeight + 22,
1066 textAlign: "center",
1067 textVerticalAlign: "middle",
1068 fill: "var(--presentation-text)",
1069 fontSize: 18,
1070 fontWeight: 600,
1071 },
1072 },
1073 ],
1074 };
1075 },
1076 data: [value],
1077 },
1078 ] as EChartSeries,
1079 };
1080 }
1081
1082 return {
1083 series: [
1084 {
1085 type: "gauge",
1086 radius: linear ? "70%" : "88%",
1087 startAngle: linear ? 180 : 220,
1088 endAngle: linear ? 0 : -40,
1089 progress: {
1090 show: true,
1091 width: linear ? 14 : 18,
1092 itemStyle: { color },
1093 },
1094 axisLine: {
1095 lineStyle: {
1096 width: linear ? 14 : 18,
1097 color: [[1, "rgba(148, 163, 184, 0.22)"]],
1098 },
1099 },
1100 pointer: {
1101 show: element.needle?.enabled ?? !linear,
1102 },
1103 axisTick: { show: false },
1104 splitLine: { show: false },
1105 axisLabel: { color: "var(--presentation-text)" },
1106 detail: {
1107 valueAnimation: true,
1108 color: "var(--presentation-text)",
1109 formatter: "{value}",
1110 },
1111 data: [{ value }],
1112 },
1113 ] as EChartSeries,
1114 };
1115 }
1116
1117 function buildHistogram(
1118 element: TChartNode,
1119 dataArray: AnyRecord[],
1120 ): Pick<PresentationEChartsOption, "grid" | "xAxis" | "yAxis" | "series"> {
1121 const binnedData = getBinnedHistogramData(dataArray);
1122
1123 if (binnedData) {
1124 return {
1125 grid: { top: 44, right: 28, bottom: 48, left: 54, containLabel: true },
1126 xAxis: {
1127 ...buildAxisOptions(element, "x", "category", binnedData.labels),
1128 boundaryGap: true,
1129 },
1130 yAxis: buildAxisOptions(element, "y", "value"),
1131 series: [
1132 {
1133 type: "bar",
1134 name: keyToLabel(binnedData.valueKey),
1135 data: binnedData.values,
1136 barCategoryGap: "0%",
1137 barGap: "0%",
1138 itemStyle: { color: element.color ?? getChartColor(0) },
1139 },
1140 ] as EChartSeries,
1141 };
1142 }
1143
1144 const first = dataArray[0];
1145 const xKey =
1146 first && Object.keys(first).find((key) => typeof first[key] === "number")
1147 ? Object.keys(first).find((key) => typeof first[key] === "number")!
1148 : "value";
1149 const values = numericValues(dataArray, xKey);
1150 const histogramOptions = element.options as AnyRecord | undefined;
1151 const nestedHistogramOptions = histogramOptions?.options as
1152 | AnyRecord
1153 | undefined;
1154 const binCount = toNumber(
1155 histogramOptions?.binCount ?? nestedHistogramOptions?.binCount,
1156 7,
1157 );
1158 const min = values.length > 0 ? Math.min(...values) : 0;
1159 const max = values.length > 0 ? Math.max(...values) : 1;
1160 const range = max - min;
1161 const width = range > 0 ? range / binCount : 1;
1162 const bins = Array.from({ length: binCount }, (_, index) => ({
1163 label: `${Math.round(min + index * width)}-${Math.round(min + (index + 1) * width)}`,
1164 count: 0,
1165 }));
1166
1167 for (const value of values) {
1168 const index = Math.min(
1169 binCount - 1,
1170 Math.max(0, Math.floor((value - min) / width)),
1171 );
1172 const bin = bins[index];
1173 if (bin) bin.count += 1;
1174 }
1175
1176 return {
1177 grid: { top: 44, right: 28, bottom: 48, left: 54, containLabel: true },
1178 xAxis: {
1179 ...buildAxisOptions(
1180 element,
1181 "x",
1182 "category",
1183 bins.map((bin) => bin.label),
1184 ),
1185 boundaryGap: true,
1186 },
1187 yAxis: buildAxisOptions(element, "y", "value"),
1188 series: [
1189 {
1190 type: "bar",
1191 name: keyToLabel(xKey),
1192 data: bins.map((bin) => bin.count),
1193 barCategoryGap: "0%",
1194 barGap: "0%",
1195 itemStyle: { color: element.color ?? getChartColor(0) },
1196 },
1197 ] as EChartSeries,
1198 };
1199 }
1200
1201 function buildHeatmap(
1202 element: TChartNode,
1203 dataArray: AnyRecord[],
1204 ): Pick<
1205 PresentationEChartsOption,
1206 "grid" | "xAxis" | "yAxis" | "visualMap" | "series"
1207 > {
1208 const xValues = Array.from(
1209 new Set(dataArray.map((item) => toText(item.x, "X"))),
1210 );
1211 const yValues = Array.from(
1212 new Set(dataArray.map((item) => toText(item.y, "Y"))),
1213 );
1214 const values = dataArray.map((item) => toNumber(item.value));
1215
1216 return {
1217 grid: { top: 44, right: 28, bottom: 48, left: 54, containLabel: true },
1218 xAxis: buildAxisOptions(element, "x", "category", xValues),
1219 yAxis: buildAxisOptions(element, "y", "category", yValues),
1220 visualMap: {
1221 min: Math.min(...values, 0),
1222 max: Math.max(...values, 1),
1223 calculable: true,
1224 orient: "horizontal",
1225 left: "center",
1226 bottom: 0,
1227 inRange: { color: ["#dbeafe", "#2563eb"] },
1228 textStyle: { color: "var(--presentation-text)" },
1229 },
1230 series: [
1231 {
1232 type: "heatmap",
1233 data: dataArray.map((item) => [
1234 xValues.indexOf(toText(item.x, "X")),
1235 yValues.indexOf(toText(item.y, "Y")),
1236 toNumber(item.value),
1237 ]),
1238 label: { show: false },
1239 },
1240 ] as EChartSeries,
1241 };
1242 }
1243
1244 function buildRangeBar(
1245 element: TChartNode,
1246 dataArray: AnyRecord[],
1247 ): Pick<PresentationEChartsOption, "grid" | "xAxis" | "yAxis" | "series"> {
1248 const categoryLabels = dataArray.map((item, index) =>
1249 toText(item.category, `Item ${index + 1}`),
1250 );
1251 const lowValues = numericValues(dataArray, "low");
1252 const ranges = dataArray.map(
1253 (item) => toNumber(item.high) - toNumber(item.low),
1254 );
1255 const horizontal = element.orientation === "horizontal";
1256
1257 return {
1258 grid: { top: 44, right: 28, bottom: 48, left: 54, containLabel: true },
1259 xAxis: horizontal
1260 ? buildAxisOptions(element, "x", "value")
1261 : buildAxisOptions(element, "x", "category", categoryLabels),
1262 yAxis: horizontal
1263 ? buildAxisOptions(element, "y", "category", categoryLabels)
1264 : buildAxisOptions(element, "y", "value"),
1265 series: [
1266 {
1267 type: "bar",
1268 stack: "range",
1269 data: lowValues,
1270 itemStyle: { color: "transparent" },
1271 emphasis: { disabled: true },
1272 },
1273 {
1274 type: "bar",
1275 stack: "range",
1276 name: "Range",
1277 data: ranges,
1278 itemStyle: { color: element.color ?? getChartColor(0) },
1279 },
1280 ] as EChartSeries,
1281 };
1282 }
1283
1284 function buildRangeArea(
1285 element: TChartNode,
1286 dataArray: AnyRecord[],
1287 ): Pick<PresentationEChartsOption, "grid" | "xAxis" | "yAxis" | "series"> {
1288 const categoryLabels = dataArray.map((item, index) =>
1289 toText(item.date ?? item.category, `Item ${index + 1}`),
1290 );
1291 const color = element.color ?? getChartColor(0);
1292 const lowValues = numericValues(dataArray, "low");
1293 const rangeValues = dataArray.map(
1294 (item) => toNumber(item.high) - toNumber(item.low),
1295 );
1296
1297 return {
1298 grid: { top: 44, right: 28, bottom: 48, left: 54, containLabel: true },
1299 xAxis: buildAxisOptions(element, "x", "category", categoryLabels),
1300 yAxis: buildAxisOptions(element, "y", "value"),
1301 series: [
1302 {
1303 type: "line",
1304 name: "Low",
1305 stack: "range-band",
1306 data: lowValues,
1307 lineStyle: { color: "transparent", width: 0 },
1308 itemStyle: { color: "transparent" },
1309 areaStyle: { color: "transparent" },
1310 symbol: "none",
1311 tooltip: { show: false },
1312 },
1313 {
1314 type: "line",
1315 name: "High",
1316 stack: "range-band",
1317 data: rangeValues,
1318 lineStyle: { color, width: 2 },
1319 itemStyle: { color },
1320 areaStyle: { color, opacity: 0.22 },
1321 symbol: "none",
1322 },
1323 {
1324 type: "line",
1325 name: "Low",
1326 data: lowValues,
1327 lineStyle: { color, width: 1, type: "dashed" },
1328 itemStyle: { color },
1329 symbol: "none",
1330 },
1331 ] as EChartSeries,
1332 };
1333 }
1334
1335 function buildWaterfall(
1336 element: TChartNode,
1337 dataArray: AnyRecord[],
1338 ): Pick<PresentationEChartsOption, "grid" | "xAxis" | "yAxis" | "series"> {
1339 const categoryLabels = dataArray.map((item, index) =>
1340 toText(item.category, `Item ${index + 1}`),
1341 );
1342 let runningTotal = 0;
1343 const offsets: number[] = [];
1344 const values: number[] = [];
1345
1346 for (const item of dataArray) {
1347 const amount = toNumber(item.amount);
1348 offsets.push(Math.min(runningTotal, runningTotal + amount));
1349 values.push(Math.abs(amount));
1350 runningTotal += amount;
1351 }
1352
1353 return {
1354 grid: { top: 44, right: 28, bottom: 48, left: 54, containLabel: true },
1355 xAxis: buildAxisOptions(element, "x", "category", categoryLabels),
1356 yAxis: buildAxisOptions(element, "y", "value"),
1357 series: [
1358 {
1359 type: "bar",
1360 stack: "waterfall",
1361 data: offsets,
1362 itemStyle: { color: "transparent" },
1363 emphasis: { disabled: true },
1364 },
1365 {
1366 type: "bar",
1367 stack: "waterfall",
1368 name: "Amount",
1369 data: values,
1370 itemStyle: {
1371 color: (params: { dataIndex: number }) =>
1372 toNumber(dataArray[params.dataIndex]?.amount) >= 0
1373 ? (element.color ?? getChartColor(0))
1374 : "#ef4444",
1375 },
1376 },
1377 ] as EChartSeries,
1378 };
1379 }
1380
1381 function buildFinancial(
1382 element: TChartNode,
1383 dataArray: AnyRecord[],
1384 boxPlot = false,
1385 ): Pick<PresentationEChartsOption, "grid" | "xAxis" | "yAxis" | "series"> {
1386 const categoryLabels = dataArray.map((item, index) =>
1387 toText(item.date ?? item.category, `Item ${index + 1}`),
1388 );
1389 const data = boxPlot
1390 ? dataArray.map((item) => [
1391 toNumber(item.min),
1392 toNumber(item.q1),
1393 toNumber(item.median),
1394 toNumber(item.q3),
1395 toNumber(item.max),
1396 ])
1397 : dataArray.map((item) => [
1398 toNumber(item.open),
1399 toNumber(item.close),
1400 toNumber(item.low),
1401 toNumber(item.high),
1402 ]);
1403
1404 return {
1405 grid: { top: 44, right: 28, bottom: 48, left: 54, containLabel: true },
1406 xAxis: buildAxisOptions(element, "x", "category", categoryLabels),
1407 yAxis: buildAxisOptions(element, "y", "value"),
1408 series: [
1409 {
1410 type: boxPlot ? "boxplot" : "candlestick",
1411 name: boxPlot ? "Distribution" : "OHLC",
1412 data,
1413 itemStyle: {
1414 color: "#16a34a",
1415 color0: "#ef4444",
1416 borderColor: "#16a34a",
1417 borderColor0: "#ef4444",
1418 },
1419 },
1420 ] as EChartSeries,
1421 };
1422 }
1423
1424 function buildOhlcOption(
1425 element: TChartNode,
1426 dataArray: AnyRecord[],
1427 ): Pick<
1428 PresentationEChartsOption,
1429 "grid" | "xAxis" | "yAxis" | "tooltip" | "axisPointer" | "series"
1430 > {
1431 const categoryLabels = dataArray.map((item, index) =>
1432 toText(item.date ?? item.category, `Item ${index + 1}`),
1433 );
1434 const values = dataArray.map((item, index) => [
1435 index,
1436 toNumber(item.open),
1437 toNumber(item.close),
1438 toNumber(item.low),
1439 toNumber(item.high),
1440 ]);
1441
1442 return {
1443 tooltip: {
1444 trigger: "axis",
1445 axisPointer: { type: "cross" },
1446 confine: true,
1447 },
1448 axisPointer: { link: [{ xAxisIndex: "all" }] },
1449 grid: { top: 44, right: 28, bottom: 48, left: 54, containLabel: true },
1450 xAxis: {
1451 ...buildAxisOptions(element, "x", "category", categoryLabels),
1452 boundaryGap: false,
1453 axisLine: { onZero: false },
1454 min: "dataMin",
1455 max: "dataMax",
1456 axisPointer: { z: 100 },
1457 },
1458 yAxis: {
1459 ...buildAxisOptions(element, "y", "value"),
1460 scale: true,
1461 splitArea: { show: true },
1462 },
1463 series: [
1464 {
1465 name: "OHLC",
1466 type: "custom",
1467 dimensions: ["-", "open", "close", "lowest", "highest"],
1468 encode: {
1469 x: 0,
1470 y: [1, 2, 3, 4],
1471 tooltip: [1, 2, 3, 4],
1472 },
1473 renderItem: (
1474 _params: unknown,
1475 api: {
1476 value: (dimension: number) => number;
1477 coord: (data: [number, number]) => [number, number];
1478 size: (data: [number, number]) => [number, number];
1479 style: (style: Record<string, unknown>) => Record<string, unknown>;
1480 visual: (key: string) => unknown;
1481 },
1482 ) => {
1483 const xValue = api.value(0);
1484 const openPoint = api.coord([xValue, api.value(1)]);
1485 const closePoint = api.coord([xValue, api.value(2)]);
1486 const lowPoint = api.coord([xValue, api.value(3)]);
1487 const highPoint = api.coord([xValue, api.value(4)]);
1488 const halfWidth = api.size([1, 0])[0] * 0.35;
1489 const style = api.style({ stroke: api.visual("color") });
1490
1491 return {
1492 type: "group",
1493 children: [
1494 {
1495 type: "line",
1496 shape: {
1497 x1: lowPoint[0],
1498 y1: lowPoint[1],
1499 x2: highPoint[0],
1500 y2: highPoint[1],
1501 },
1502 style,
1503 },
1504 {
1505 type: "line",
1506 shape: {
1507 x1: openPoint[0],
1508 y1: openPoint[1],
1509 x2: openPoint[0] - halfWidth,
1510 y2: openPoint[1],
1511 },
1512 style,
1513 },
1514 {
1515 type: "line",
1516 shape: {
1517 x1: closePoint[0],
1518 y1: closePoint[1],
1519 x2: closePoint[0] + halfWidth,
1520 y2: closePoint[1],
1521 },
1522 style,
1523 },
1524 ],
1525 };
1526 },
1527 data: values,
1528 },
1529 ] as EChartSeries,
1530 };
1531 }
1532
1533 function buildComposed(
1534 element: TChartNode,
1535 dataArray: AnyRecord[],
1536 labelKey: string,
1537 valueKeys: string[],
1538 ): Pick<PresentationEChartsOption, "grid" | "xAxis" | "yAxis" | "series"> {
1539 const categoryLabels = labels(dataArray, labelKey);
1540 const seriesChartTypes = element.seriesChartTypes ?? {};
1541 const fallbackTypes: SeriesChartType[] = ["bar", "line", "area"];
1542
1543 return {
1544 grid: { top: 52, right: 28, bottom: 48, left: 54, containLabel: true },
1545 xAxis: buildAxisOptions(element, "x", "category", categoryLabels),
1546 yAxis: buildAxisOptions(element, "y", "value"),
1547 series: valueKeys.map((key, index) => {
1548 const selectedType =
1549 seriesChartTypes[key] ?? fallbackTypes[index % fallbackTypes.length]!;
1550 const color = getChartColor(index);
1551 const type = selectedType === "area" ? "line" : selectedType;
1552
1553 return {
1554 type,
1555 name: keyToLabel(key),
1556 data: numericValues(dataArray, key),
1557 smooth: selectedType !== "bar",
1558 areaStyle:
1559 selectedType === "area" ? { color, opacity: 0.24 } : undefined,
1560 itemStyle: { color },
1561 lineStyle: { color, width: 2 },
1562 symbolSize: selectedType === "scatter" ? 8 : undefined,
1563 };
1564 }) as EChartSeries,
1565 };
1566 }
1567
1568 function buildChartOption(
1569 chartType: string,
1570 chartData: unknown,
1571 element: TChartNode,
1572 ): PresentationEChartsOption | null {
1573 const dataArray = toRecordArray(chartData);
1574 const labelKey = getLabelKey(dataArray);
1575 const valueKey = getValueKey(dataArray);
1576 const valueKeys = getValueKeys(dataArray);
1577 const primaryColor = element.color ?? getChartColor(0);
1578 const previewMode = element.previewMode === true;
1579 const base = buildBaseOption(element, previewMode);
1580
1581 switch (chartType) {
1582 case PIE_CHART_ELEMENT:
1583 return {
1584 ...base,
1585 series: [
1586 buildPieSeries(dataArray, labelKey, valueKey, "68%"),
1587 ] as EChartSeries,
1588 };
1589 case DONUT_CHART_ELEMENT:
1590 return {
1591 ...base,
1592 series: [
1593 buildPieSeries(dataArray, labelKey, valueKey, [
1594 `${Math.round((element.innerRadiusRatio ?? 0.58) * 55)}%`,
1595 "70%",
1596 ]),
1597 ] as EChartSeries,
1598 graphic: element.innerLabels?.map((label, index) => ({
1599 type: "text",
1600 left: "center",
1601 top: `${47 + index * 7}%`,
1602 style: {
1603 text: label.text,
1604 fill: label.color ?? "var(--presentation-text)",
1605 fontSize: label.fontSize ?? 13,
1606 fontWeight: label.fontWeight ?? "normal",
1607 textAlign: "center",
1608 },
1609 })),
1610 };
1611 case BAR_CHART_ELEMENT:
1612 return {
1613 ...base,
1614 ...buildCartesianOption(
1615 element,
1616 "bar",
1617 dataArray,
1618 labelKey,
1619 [valueKey],
1620 element.orientation === "horizontal",
1621 ),
1622 };
1623 case LINE_CHART_ELEMENT:
1624 return {
1625 ...base,
1626 ...buildCartesianOption(
1627 element,
1628 "line",
1629 dataArray,
1630 labelKey,
1631 valueKeys,
1632 ),
1633 };
1634 case AREA_CHART_ELEMENT:
1635 return {
1636 ...base,
1637 ...buildAreaOption(element, dataArray, labelKey, valueKeys),
1638 };
1639 case RADAR_CHART_ELEMENT:
1640 return {
1641 ...base,
1642 ...buildRadarOption(element, dataArray, labelKey, valueKeys),
1643 };
1644 case SCATTER_CHART_ELEMENT:
1645 return { ...base, ...buildScatterOption(element, dataArray, false) };
1646 case BUBBLE_CHART_ELEMENT:
1647 return { ...base, ...buildScatterOption(element, dataArray, true) };
1648 case RADIAL_BAR_CHART_ELEMENT:
1649 return {
1650 ...base,
1651 angleAxis: {
1652 max: Math.max(...numericValues(dataArray, valueKey), 1) * 1.15,
1653 startAngle: 30,
1654 splitLine: { show: false },
1655 axisLabel: { color: "var(--presentation-text)" },
1656 },
1657 radiusAxis: {
1658 type: "category",
1659 data: labels(dataArray, labelKey),
1660 z: 10,
1661 axisLabel: { color: "var(--presentation-text)" },
1662 },
1663 polar: {},
1664 series: [
1665 {
1666 type: "bar",
1667 data: numericValues(dataArray, valueKey),
1668 coordinateSystem: "polar",
1669 name: keyToLabel(valueKey),
1670 roundCap: true,
1671 itemStyle: {
1672 color: primaryColor,
1673 opacity: 0.84,
1674 borderColor: primaryColor,
1675 borderWidth: 1,
1676 },
1677 },
1678 ] as EChartSeries,
1679 };
1680 case RADIAL_COLUMN_CHART_ELEMENT:
1681 return {
1682 ...base,
1683 polar: { radius: "72%" },
1684 angleAxis: {
1685 type: "category",
1686 data: labels(dataArray, labelKey),
1687 axisLabel: { color: "var(--presentation-text)" },
1688 },
1689 radiusAxis: {
1690 axisLabel: { color: "var(--presentation-text)" },
1691 splitLine: { lineStyle: { color: "rgba(148, 163, 184, 0.24)" } },
1692 },
1693 series: [
1694 {
1695 type: "bar",
1696 coordinateSystem: "polar",
1697 data: numericValues(dataArray, valueKey),
1698 itemStyle: { color: primaryColor },
1699 },
1700 ] as EChartSeries,
1701 };
1702 case TREEMAP_CHART_ELEMENT:
1703 return {
1704 ...base,
1705 series: [
1706 {
1707 type: "treemap",
1708 roam: false,
1709 data: buildTreemapData(dataArray, labelKey, valueKey),
1710 label: { color: "#fff" },
1711 },
1712 ] as EChartSeries,
1713 };
1714 case SUNBURST_CHART_ELEMENT:
1715 return {
1716 ...base,
1717 series: [
1718 {
1719 type: "sunburst",
1720 radius: [0, "78%"],
1721 data: normalizeHierarchy(dataArray),
1722 label: { color: "var(--presentation-text)" },
1723 },
1724 ] as EChartSeries,
1725 };
1726 case SANKEY_CHART_ELEMENT:
1727 return {
1728 ...base,
1729 series: [
1730 buildFlowSeries(
1731 toRecordArray(sanitizeSankeyCycleData(chartData).data),
1732 ),
1733 ] as EChartSeries,
1734 };
1735 case CHORD_CHART_ELEMENT:
1736 return null;
1737 case FUNNEL_CHART_ELEMENT:
1738 return {
1739 ...base,
1740 series: [
1741 buildFunnelSeries(dataArray, labelKey, valueKey, "funnel"),
1742 ] as EChartSeries,
1743 };
1744 case CONE_FUNNEL_CHART_ELEMENT:
1745 return {
1746 ...base,
1747 series: [
1748 buildFunnelSeries(dataArray, labelKey, valueKey, "cone"),
1749 ] as EChartSeries,
1750 };
1751 case PYRAMID_CHART_ELEMENT:
1752 return {
1753 ...base,
1754 series: [
1755 buildFunnelSeries(dataArray, labelKey, valueKey, "pyramid"),
1756 ] as EChartSeries,
1757 };
1758 case NIGHTINGALE_CHART_ELEMENT:
1759 return {
1760 ...base,
1761 series: [
1762 buildPieSeries(dataArray, labelKey, valueKey, "70%", "radius"),
1763 ] as EChartSeries,
1764 };
1765 case HISTOGRAM_CHART_ELEMENT:
1766 return { ...base, ...buildHistogram(element, dataArray) };
1767 case HEATMAP_CHART_ELEMENT:
1768 return { ...base, ...buildHeatmap(element, dataArray) };
1769 case RANGE_BAR_CHART_ELEMENT:
1770 return { ...base, ...buildRangeBar(element, dataArray) };
1771 case RANGE_AREA_CHART_ELEMENT:
1772 return { ...base, ...buildRangeArea(element, dataArray) };
1773 case WATERFALL_CHART_ELEMENT:
1774 return { ...base, ...buildWaterfall(element, dataArray) };
1775 case CANDLESTICK_CHART_ELEMENT:
1776 return { ...base, ...buildFinancial(element, dataArray) };
1777 case OHLC_CHART_ELEMENT:
1778 return { ...base, ...buildOhlcOption(element, dataArray) };
1779 case BOX_PLOT_CHART_ELEMENT:
1780 return { ...base, ...buildFinancial(element, dataArray, true) };
1781 case COMPOSED_CHART_ELEMENT:
1782 return {
1783 ...base,
1784 ...buildComposed(element, dataArray, labelKey, valueKeys),
1785 };
1786 case RADIAL_GAUGE_ELEMENT:
1787 return {
1788 ...base,
1789 ...buildGaugeOption(element, chartData, dataArray, false),
1790 };
1791 case LINEAR_GAUGE_ELEMENT:
1792 return {
1793 ...base,
1794 ...buildGaugeOption(element, chartData, dataArray, true),
1795 };
1796 default:
1797 return null;
1798 }
1799 }
1800
1801 export function ChartRenderer({
1802 chartType,
1803 chartData,
1804 chartOptions = EMPTY_CHART_OPTIONS,
1805 className,
1806 style,
1807 }: ChartRendererProps) {
1808 const element = React.useMemo(
1809 () =>
1810 ({
1811 type: chartType,
1812 ...chartOptions,
1813 }) as TChartNode,
1814 [chartOptions, chartType],
1815 );
1816 const previewMode = chartOptions.previewMode === true;
1817 const isPresenting = usePresentationState((state) => state.isPresenting);
1818 const dataArray = React.useMemo(() => toRecordArray(chartData), [chartData]);
1819 const isChordChart = chartType === CHORD_CHART_ELEMENT;
1820 const option = React.useMemo(
1821 () =>
1822 isChordChart ? null : buildChartOption(chartType, chartData, element),
1823 [chartData, chartType, element, isChordChart],
1824 );
1825 const legendEntries = React.useMemo(() => {
1826 if (isChordChart) {
1827 return buildChordData(dataArray, element).nodes.map((node) => ({
1828 color: node.color,
1829 label: node.name,
1830 }));
1831 }
1832
1833 return option && isLegendEnabled(option)
1834 ? buildLegendEntries(option, element)
1835 : [];
1836 }, [dataArray, element, isChordChart, option]);
1837 const chartOption = React.useMemo(() => {
1838 if (!option) return option;
1839
1840 const optionWithLegend =
1841 legendEntries.length > 0 ? disableBuiltInLegend(option) : option;
1842
1843 return withSeriesAnimation(
1844 optionWithLegend,
1845 getChartAnimationSettings(element),
1846 );
1847 }, [element, legendEntries.length, option]);
1848
1849 if (isChordChart) {
1850 return (
1851 <div
1852 className={cn(DEFAULT_CONTAINER_CLASS, className)}
1853 data-presentation-chart={chartType}
1854 style={{
1855 backgroundColor: "var(--presentation-background)",
1856 color: "var(--presentation-text)",
1857 borderColor: "hsl(var(--border))",
1858 ...style,
1859 }}
1860 >
1861 <div className="flex min-h-[inherit] w-full flex-1 flex-col">
1862 <div className="min-h-[inherit] flex-1">
1863 <CustomChordChart dataArray={dataArray} element={element} />
1864 </div>
1865 <ChartLegend entries={legendEntries} />
1866 </div>
1867 </div>
1868 );
1869 }
1870
1871 if (!chartOption) {
1872 return (
1873 <div
1874 className={cn(
1875 "flex h-full items-center justify-center bg-muted/30 p-4",
1876 className,
1877 )}
1878 style={style}
1879 >
1880 <p className="text-sm text-muted-foreground">
1881 Unsupported chart type: {chartType}
1882 </p>
1883 </div>
1884 );
1885 }
1886
1887 return (
1888 <div
1889 className={cn(DEFAULT_CONTAINER_CLASS, className)}
1890 data-presentation-chart={chartType}
1891 style={{
1892 backgroundColor: "var(--presentation-background)",
1893 color: "var(--presentation-text)",
1894 borderColor: "hsl(var(--border))",
1895 ...style,
1896 }}
1897 >
1898 <div className="flex min-h-[inherit] w-full flex-1 flex-col">
1899 <EChartWrapper
1900 animationReplayDelayMs={
1901 isPresenting ? PRESENT_MODE_ANIMATION_REPLAY_DELAY_MS : 0
1902 }
1903 className="min-h-[inherit] flex-1"
1904 isPresenting={isPresenting}
1905 options={chartOption}
1906 previewMode={previewMode}
1907 />
1908 <ChartLegend entries={legendEntries} />
1909 </div>
1910 </div>
1911 );
1912 }
1913
1913 lines Plain Text