From 1e77033a184a6b7a47ecb04c7677a83cfe5e8bbe Mon Sep 17 00:00:00 2001 From: keppere <50957820+keppere@users.noreply.github.com> Date: Tue, 4 Feb 2025 12:56:27 +0700 Subject: [PATCH 001/106] chart beta (#338) * feat: chart beta * refact: change theme provider --- src/app/storybook/chart-editor/page.tsx | 104 ++++++ src/app/storybook/chart/page.tsx | 7 +- src/app/storybook/layout.tsx | 5 +- src/components/chart/chart-type-button.tsx | 34 ++ src/components/chart/chartTypes.ts | 87 +++++ src/components/chart/echartOptionsBuilder.ts | 370 +++++++++++++++++++ src/components/chart/edit-chart-menu.tsx | 116 ++++++ src/components/chart/index.tsx | 330 +++++++++-------- 8 files changed, 902 insertions(+), 151 deletions(-) create mode 100644 src/app/storybook/chart-editor/page.tsx create mode 100644 src/components/chart/chart-type-button.tsx create mode 100644 src/components/chart/chartTypes.ts create mode 100644 src/components/chart/echartOptionsBuilder.ts create mode 100644 src/components/chart/edit-chart-menu.tsx diff --git a/src/app/storybook/chart-editor/page.tsx b/src/app/storybook/chart-editor/page.tsx new file mode 100644 index 00000000..cf9d48ee --- /dev/null +++ b/src/app/storybook/chart-editor/page.tsx @@ -0,0 +1,104 @@ +"use client"; +import Chart from "@/components/chart"; +import { ChartData, ChartValue } from "@/components/chart/chartTypes"; +import EditChartMenu from "@/components/chart/edit-chart-menu"; +import { useState } from "react"; + +const data: ChartData[] = [ + { + seller: 49, + seller2: 1049, + _year: 2019, + }, + { + seller: 590, + seller2: 1590, + _year: 2020, + }, + { + seller: 7908, + seller2: 8908, + _year: 2021, + }, + { + seller: 4985, + seller2: 5985, + _year: 2022, + }, + { + seller: 2638, + seller2: 3638, + _year: 2023, + }, + { + seller: 4, + seller2: 1004, + _year: 2024, + }, +]; + +const lineChartValue: ChartValue = { + connection_id: null, + created_at: "2025-01-31T08:44:04.868Z", + id: "94019f17-a1e0-4db2-b89e-4457cbed3ce4", + model: "chart", + name: "New Chart", + params: { + id: "94019f17-a1e0-4db2-b89e-4457cbed3ce4", + name: "New Chart", + type: "line", + model: "chart", + apiKey: "", + layers: [ + { + sql: "select count(*) as seller,count(*) + 1000 as seller2, year(l192_new.supplier.registered_date) as _year\nFROM\n supplier\nGROUP BY _year\nhaving _year is not null\nORDER BY _year\n", + type: "line", + }, + ], + options: { + text: "Hello world", + theme: "afterburn", + xAxisKey: "_year", + yAxisKeys: ["seller", "seller2"], + foreground: "#ffffff", + xAxisLabel: "cust year", + yAxisLabel: "cust sellers", + gradientStop: "#42788F", + gradientStart: "#2C4F5E", + backgroundType: "gradient", + yAxisKeyColors: { + seller: "#E75F98", + seller2: "#FFA285", + }, + xAxisLabelHidden: false, + yAxisLabelHidden: false, + yAxisLabelDisplay: "right", + }, + source_id: "856a1855-2bee-4d87-9756-a783088c0568", + created_at: "2025-01-31T08:44:04.868Z", + updated_at: "2025-01-31T08:44:04.868Z", + workspace_id: "3db2e96f-ee43-412d-be09-25fc02d3a463", + connection_id: null, + }, + source_id: "856a1855-2bee-4d87-9756-a783088c0568", + type: "line", + updated_at: "2025-02-02T13:44:55.532Z", + workspace_id: "3db2e96f-ee43-412d-be09-25fc02d3a463", +}; + +export default function StorybookChartEditorPage() { + const [chartValue, setChartValue] = useState(lineChartValue); + const [items, setItems] = useState(data); + const [modifier, setModifier] = useState({}); + return ( +
+
+ +
+
+ {/* render chart editor menu here */} + +
+
+ ); +} diff --git a/src/app/storybook/chart/page.tsx b/src/app/storybook/chart/page.tsx index 4c2e5917..03c7d192 100644 --- a/src/app/storybook/chart/page.tsx +++ b/src/app/storybook/chart/page.tsx @@ -1,6 +1,7 @@ "use client"; -import Chart, { ChartData, ChartValue } from "@/components/chart"; +import Chart from "@/components/chart"; +import { ChartData, ChartValue } from "@/components/chart/chartTypes"; import { Button } from "@/components/ui/button"; import { useState } from "react"; @@ -158,7 +159,7 @@ export default function StorybookChartPage() { const [modifier, setModifier] = useState({}); return ( -
+
-
+
diff --git a/src/app/storybook/layout.tsx b/src/app/storybook/layout.tsx index 1cc1c998..cfaeee6f 100644 --- a/src/app/storybook/layout.tsx +++ b/src/app/storybook/layout.tsx @@ -59,9 +59,10 @@ export default function StorybookRootLayout({ /> +
{children} diff --git a/src/components/chart/chart-type-button.tsx b/src/components/chart/chart-type-button.tsx new file mode 100644 index 00000000..3e72ad9e --- /dev/null +++ b/src/components/chart/chart-type-button.tsx @@ -0,0 +1,34 @@ +interface ChartTypeButtonProps { + icon: React.ReactNode; + isActive: boolean; + onClick: () => void; + tooltipText: string; +} + +export function ChartTypeButton({ + tooltipText, + onClick, + isActive, + icon, +}: ChartTypeButtonProps) { + return ( + + ); +} diff --git a/src/components/chart/chartTypes.ts b/src/components/chart/chartTypes.ts new file mode 100644 index 00000000..a58bd84d --- /dev/null +++ b/src/components/chart/chartTypes.ts @@ -0,0 +1,87 @@ +interface ChartLayer { + sql: string; + type: string; +} + +export type ChartLegend = "none" | "top" | "bottom" | "left" | "right"; +export type ChartLabelDisplayX = "auto" | "0" | "45" | "90" | "hidden"; +export type ChartLabelDisplayY = "left" | "right" | "hidden"; +export type ChartSortOrder = "default" | "asc" | "desc"; + +interface ChartOptions { + foreground?: string; + gradientStop?: string; + gradientStart?: string; + backgroundType?: string; + xAxisLabelHidden?: boolean; + yAxisLabelHidden?: boolean; + legend?: ChartLegend; + xAxisLabel?: string; + yAxisLabel?: string; + xAxisKey: string; + yAxisKeys: string[]; + yAxisKeyColors?: Record; + xAxisLabelDisplay?: ChartLabelDisplayX; + yAxisLabelDisplay?: ChartLabelDisplayY; + sortOrder?: ChartSortOrder; + groupBy?: string; + percentage?: boolean; + text?: string; + format?: + | "percent" + | "number" + | "decimal" + | "date" + | "time" + | "dollar" + | "euro" + | "pound" + | "yen"; + theme?: string; +} + +interface ChartParams { + id: string; + name: string; + type: ChartType; + model: string; + apiKey: string; + layers: ChartLayer[]; + options: ChartOptions; + source_id: string; + created_at: string; + updated_at: string; + workspace_id: string; + connection_id: string | null; +} + +export interface ChartValue { + connection_id: string | null; + created_at: string; + id: string; + model: string; + name: string; + params: ChartParams; + source_id: string; + type: ChartType; + updated_at: string; + workspace_id: string; + description?: string; +} + +export interface ChartData { + [key: string]: any; +} + +export type ChartType = + | "line" + | "bar" + | "pie" + | "radar" + | "funnel" + | "area" + | "column" + | "scatter" + | "text" + | "table" + | "single_value"; diff --git a/src/components/chart/echartOptionsBuilder.ts b/src/components/chart/echartOptionsBuilder.ts new file mode 100644 index 00000000..a35b587a --- /dev/null +++ b/src/components/chart/echartOptionsBuilder.ts @@ -0,0 +1,370 @@ +import { + BarSeriesOption, + EChartsOption, + FunnelSeriesOption, + LineSeriesOption, + PieSeriesOption, + ScatterSeriesOption, + SeriesOption, +} from "echarts"; +import { ChartData, ChartValue } from "./chartTypes"; + +export default class EchartOptionsBuilder { + private chartValue: ChartValue; + private chartData: ChartData[]; + private theme: string = "light"; + private columns: string[] = []; + private chartHeight: number = 0; + private chartWidth: number = 0; + + constructor(value: ChartValue, data: ChartData[]) { + this.chartValue = value; + this.chartData = data; + } + + setChartSize(width: number, height: number) { + this.chartWidth = width; + this.chartHeight = height; + } + + setTheme(theme: string) { + this.theme = theme; + } + + private getColorValues(): string[] { + return ["#5B8FF9", "#5AD8A6", "#5D7092", "#F6BD16", "#6F5EF9"]; + } + + private getTextColor(): string { + return ( + this.chartValue.params.options?.foreground ?? + (this.theme === "dark" ? "#FFFFFF" : "#000000") + ); + } + + getChartOptions(): EChartsOption { + const colorValues = this.getColorValues(); + + const formattedSource = this.chartData; + if (this.chartValue.params.options?.xAxisKey) { + this.columns.push(this.chartValue.params.options.xAxisKey); + } + for (const key of this.chartValue.params.options.yAxisKeys) { + this.columns.push(key); + } + + const isTall = this.chartHeight > 150; + const gridLineColors = this.theme === "dark" ? "#FFFFFF08" : "#00000010"; + const axisLineColors = this.theme === "dark" ? "#FFFFFF15" : "#00000020"; + + // Determine if the X axis data is a date + const isXAxisDate = !!( + this.columns[0] && + this.chartData.some((item) => isDate(item[this.columns[0]] as string)) + ); + const isYAxisDate = !!( + this.columns[1] && + formattedSource.some((item) => isDate(item[this.columns[1]] as string)) + ); + + if (this.chartValue.type === "radar") { + return { + radar: { + shape: "polygon", + indicator: this.columns.map((name) => ({ name })), + }, + series: this.columns.map((col, index) => ({ + type: "radar", + data: [ + { + value: formattedSource.map((item) => Number(item[col])), // throws away precision of bigint?! + name: col, + itemStyle: { + color: + this.chartValue.params.options.yAxisKeyColors?.[col] || + colorValues[index % colorValues.length], + }, + }, + ], + })), + tooltip: { + trigger: "item", + borderColor: gridLineColors, // fix issue where 'item' tooltips were a different color than the rest (maybe it matched the series color) + }, + }; + } + + const options: EChartsOption = { + // backgroundColor: this.getBackgroundColor(), + dataset: { + dimensions: this.columns, + source: formattedSource, + }, + tooltip: { + trigger: this.chartValue.type === "scatter" ? "item" : "axis", + borderColor: gridLineColors, // fix issue where 'item' tooltips were a different color than the rest (maybe it matched the series color) + }, + legend: { + show: isTall, + data: this.columns.slice(1), + textStyle: { + color: this.getTextColor(), + }, + top: 8, + orient: "horizontal", + type: "scroll", // Enable scrolling if too many items + }, + grid: { + left: "0", // this.type === 'bar' ? '100' : '0', + right: "6", + bottom: + this.chartValue.params.options.xAxisLabel && isTall ? "23" : "0", // isTall ? '15%' : '15%', + top: this.chartValue.params.options.yAxisLabel && isTall ? "26" : "8", + containLabel: true, + }, + xAxis: { + show: !this.chartValue.params.options.xAxisLabelHidden, + type: + this.chartValue.type === "bar" + ? "value" + : isXAxisDate + ? "time" + : "category", + name: isTall ? this.chartValue.params.options.xAxisLabel : "", + nameLocation: "middle", + nameGap: 30, + nameTextStyle: { + color: this.getTextColor(), + }, + axisLine: { + show: false, + lineStyle: { + color: axisLineColors, + }, + }, + axisLabel: { + // @ts-ignore bug in echarts? this definitely exists + formatter: isXAxisDate ? undefined : this.labelFormatter, + color: this.getTextColor(), + hideOverlap: true, + rotate: + this.chartValue.params.options.xAxisLabelDisplay === "auto" + ? -45 + : 0, + align: "center", + }, + splitLine: { + show: false, + lineStyle: { + color: gridLineColors, + }, + }, + }, + yAxis: { + type: + this.chartValue.type === "bar" + ? isXAxisDate + ? "time" + : "category" + : isYAxisDate + ? "time" + : "value", + name: isTall ? this.chartValue.params.options.yAxisLabel : "", + show: this.chartValue.params.options.yAxisLabelDisplay !== "hidden", + position: + (this.chartValue.params.options.yAxisLabelDisplay !== "hidden" && + this.chartValue.params.options.yAxisLabelDisplay) || + undefined, // exclude `hidden`, pass left/right + nameTextStyle: { + color: this.getTextColor(), + align: "left", + padding: [0, 0, 0, 0], + }, + axisLine: { + show: false, + lineStyle: { + color: axisLineColors, + }, + }, + axisLabel: { + // @ts-ignore bug in echarts? this definitely exists + formatter: isXAxisDate ? undefined : this.labelFormatter, // `isXAxisDate` is not a typo + color: this.getTextColor(), + align: "right", + inside: false, + }, + axisTick: { + inside: false, + }, + splitLine: { + show: true, + lineStyle: { + color: gridLineColors, + }, + }, + }, + }; + + return this.addSeries(options); // Pass the source dataset when adding series + } + + private addSeries(_options: EChartsOption) { + const options = { ..._options }; + + switch (this.chartValue.type) { + case "column": + options.series = this.constructSeries("bar", { + animationDelay: (idx) => idx * 0.8, + }); + break; + case "line": + options.series = this.constructSeries("line", { + showSymbol: false, + animationDuration: 1000, + animationEasing: "cubicOut", + }); + break; + case "scatter": + options.series = this.constructSeries("scatter", { + symbolSize: 8, + itemStyle: { + borderWidth: 2, + borderColor: this.getTextColor(), + color: "transparent", // Make the fill transparent + }, + }); + break; + case "area": + options.series = this.constructSeries("line", { + areaStyle: {}, + smooth: true, + }); + break; + case "bar": + options.series = this.constructSeries("bar", { + animationDelay: (idx) => idx * 0.8, + barWidth: "40%", + coordinateSystem: "cartesian2d", + }); + options.xAxis = { + ...options.xAxis, + + // Add split line style here for x-axis + splitLine: { + ...(options.xAxis as any).splitLine, + show: true, + }, + }; + break; + case "funnel": + options.series = this.constructSeries("funnel", { + left: "10%", + top: 26, + bottom: 0, + width: "80%", + minSize: "0%", + maxSize: "100%", + sort: "descending", + label: { + show: true, + position: "inside", + formatter: "{b}: {c}", + color: "#fff", // label color + }, + gap: 2, + itemStyle: { + borderColor: "rgba(0, 0, 0, 0.2)", + borderWidth: 1, + }, + data: this.chartData?.map((item) => ({ + name: item[this.columns[0]] as string, + value: item[this.columns[1]] as number, + })), + color: this.getColorValues(), + }); + break; + case "pie": + options.series = this.constructSeries("pie", { + data: + this.chartData?.map((item) => ({ + name: item[this.columns[0]] as string, + value: item[this.columns[1]] as number, + })) ?? [], + radius: ["40%", "70%"], + center: ["50%", "50%"], + avoidLabelOverlap: true, + itemStyle: { + borderRadius: 10, + borderColor: "rgba(0, 0, 0, 0.2)", + borderWidth: 2, + }, + label: { + show: this.chartValue.params?.options?.xAxisLabelHidden !== true, + formatter: "{b}: {c} ({d}%)", + color: this.theme === "dark" ? "#fff" : "#000", // Set label text color to white + textBorderColor: "transparent", // Remove text border + }, + color: this.getColorValues(), + tooltip: { + trigger: "item", + borderColor: this.theme === "dark" ? "#FFFFFF08" : "#00000010", // fix issue where 'item' tooltips were a different color than the rest (maybe it matched the series color) + }, + }); + + break; + default: + break; + } + + return options; + } + + private constructSeries( + seriesType: T["type"], + additionalOptions: Partial> = {} + ): T[] { + return this.columns.slice(1).map((col) => { + const baseSeries = { + name: col, + type: seriesType, + encode: + this.chartValue.type === "bar" + ? { x: col, y: this.columns[0] } // For bar charts + : { x: this.columns[0], y: col }, // For other chart types + itemStyle: { + color: this.chartValue.params.options.yAxisKeyColors?.[col], // does NOT impact pie charts + }, + symbol: "circle", + ...additionalOptions, + }; + + if (this.isValidSeriesOption(baseSeries)) { + return baseSeries as unknown as T; + } else { + throw new Error( + `The series option is invalid for series type "${seriesType}".` + ); + } + }); + } + private isValidSeriesOption( + series: any + ): series is T { + return ( + series && + typeof series === "object" && + typeof series.name === "string" && + typeof series.type === "string" + ); + } +} + +function isDate(dateString: string): boolean { + if (!isNaN(Number(dateString))) { + // this is number + return false; + } else { + const date = new Date(dateString); + return !isNaN(date.getTime()); + } +} diff --git a/src/components/chart/edit-chart-menu.tsx b/src/components/chart/edit-chart-menu.tsx new file mode 100644 index 00000000..bd02da39 --- /dev/null +++ b/src/components/chart/edit-chart-menu.tsx @@ -0,0 +1,116 @@ +import { ChartBar } from "@phosphor-icons/react/dist/icons/ChartBar"; +import { ChartLine } from "@phosphor-icons/react/dist/icons/ChartLine"; +import { ChartPieSlice } from "@phosphor-icons/react/dist/icons/ChartPieSlice"; +import { ChartPolar } from "@phosphor-icons/react/dist/icons/ChartPolar"; +import { ChartScatter } from "@phosphor-icons/react/dist/icons/ChartScatter"; +import { Funnel } from "@phosphor-icons/react/dist/icons/Funnel"; +import { NumberCircleOne } from "@phosphor-icons/react/dist/icons/NumberCircleOne"; +import { Table } from "@phosphor-icons/react/dist/icons/Table"; +import { TextT } from "@phosphor-icons/react/dist/icons/TextT"; +import { ChartBarHorizontal } from "@phosphor-icons/react/dist/ssr"; +import { ChartTypeButton } from "./chart-type-button"; +import { ChartValue } from "./chartTypes"; + +interface EditChartMenuProps { + value: ChartValue; + setValue: (value: ChartValue) => void; +} + +export default function EditChartMenu({ value, setValue }: EditChartMenuProps) { + return ( +
+
+

Type

+
+ } + isActive={value.type === "line"} + onClick={() => { + setValue({ ...value, type: "line" }); + }} + tooltipText="Line" + /> + } + isActive={value.type === "column"} + onClick={() => { + setValue({ ...value, type: "column" }); + }} + tooltipText="Column" + /> + } + isActive={value.type === "bar"} + onClick={() => { + setValue({ ...value, type: "bar" }); + }} + tooltipText="Bar" + /> + } + isActive={value.type === "scatter"} + onClick={() => { + setValue({ ...value, type: "scatter" }); + }} + tooltipText="scatter" + /> + } + isActive={value.type === "text"} + onClick={() => { + setValue({ ...value, type: "text" }); + }} + tooltipText="Text" + /> + } + isActive={value.type === "single_value"} + onClick={() => { + setValue({ ...value, type: "single_value" }); + }} + tooltipText="Single Value" + /> + } + isActive={value.type === "table"} + onClick={() => { + setValue({ ...value, type: "table" }); + }} + tooltipText="Table" + /> + } + isActive={value.type === "pie"} + onClick={() => { + setValue({ ...value, type: "pie" }); + }} + tooltipText="Pie" + /> + } + isActive={value.type === "radar"} + onClick={() => { + setValue({ ...value, type: "radar" }); + }} + tooltipText="Radar" + /> + } + isActive={value.type === "funnel"} + onClick={() => { + setValue({ ...value, type: "funnel" }); + }} + tooltipText="Funnel" + /> +
+
+ + {value.type === "text" && ( +
+

Text

+