diff --git a/client/packages/lowcoder-comps/package.json b/client/packages/lowcoder-comps/package.json index 7cf6fc1af..15d96d145 100644 --- a/client/packages/lowcoder-comps/package.json +++ b/client/packages/lowcoder-comps/package.json @@ -58,6 +58,38 @@ "h": 40 } }, + "barChart": { + "name": "Bar Chart", + "icon": "./icons/icon-chart.svg", + "layoutInfo": { + "w": 12, + "h": 40 + } + }, + "lineChart": { + "name": "Line Chart", + "icon": "./icons/icon-chart.svg", + "layoutInfo": { + "w": 12, + "h": 40 + } + }, + "pieChart": { + "name": "Pie Chart", + "icon": "./icons/icon-chart.svg", + "layoutInfo": { + "w": 12, + "h": 40 + } + }, + "scatterChart": { + "name": "Scatter Chart", + "icon": "./icons/icon-chart.svg", + "layoutInfo": { + "w": 12, + "h": 40 + } + }, "imageEditor": { "name": "Image Editor", "icon": "./icons/icon-chart.svg", diff --git a/client/packages/lowcoder-comps/src/comps/barChartComp/barChartComp.tsx b/client/packages/lowcoder-comps/src/comps/barChartComp/barChartComp.tsx new file mode 100644 index 000000000..e13818586 --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/barChartComp/barChartComp.tsx @@ -0,0 +1,320 @@ +import { + changeChildAction, + changeValueAction, + CompAction, + CompActionTypes, + wrapChildAction, +} from "lowcoder-core"; +import { AxisFormatterComp, EchartsAxisType } from "../basicChartComp/chartConfigs/cartesianAxisConfig"; +import { barChartChildrenMap, ChartSize, getDataKeys } from "./barChartConstants"; +import { barChartPropertyView } from "./barChartPropertyView"; +import _ from "lodash"; +import { useContext, useEffect, useMemo, useRef, useState } from "react"; +import ReactResizeDetector from "react-resize-detector"; +import ReactECharts from "../basicChartComp/reactEcharts"; +import { + childrenToProps, + depsConfig, + genRandomKey, + NameConfig, + UICompBuilder, + withDefault, + withExposingConfigs, + withViewFn, + ThemeContext, + chartColorPalette, + getPromiseAfterDispatch, + dropdownControl, + JSONObject, +} from "lowcoder-sdk"; +import { getEchartsLocale, trans } from "i18n/comps"; +import { ItemColorComp } from "comps/basicChartComp/chartConfigs/lineChartConfig"; +import { + echartsConfigOmitChildren, + getEchartsConfig, + getSelectedPoints, +} from "./barChartUtils"; +import 'echarts-extension-gmap'; +import log from "loglevel"; + +let clickEventCallback = () => {}; + +const chartModeOptions = [ + { + label: "ECharts JSON", + value: "json", + } +] as const; + +let BarChartTmpComp = (function () { + return new UICompBuilder({mode:dropdownControl(chartModeOptions,'ui'),...barChartChildrenMap}, () => null) + .setPropertyViewFn(barChartPropertyView) + .build(); +})(); + +BarChartTmpComp = withViewFn(BarChartTmpComp, (comp) => { + const mode = comp.children.mode.getView(); + const onUIEvent = comp.children.onUIEvent.getView(); + const onEvent = comp.children.onEvent.getView(); + const echartsCompRef = useRef(); + const [chartSize, setChartSize] = useState(); + const firstResize = useRef(true); + const theme = useContext(ThemeContext); + const defaultChartTheme = { + color: chartColorPalette, + backgroundColor: "#fff", + }; + + let themeConfig = defaultChartTheme; + try { + themeConfig = theme?.theme.chart ? JSON.parse(theme?.theme.chart) : defaultChartTheme; + } catch (error) { + log.error('theme chart error: ', error); + } + + const triggerClickEvent = async (dispatch: any, action: CompAction) => { + await getPromiseAfterDispatch( + dispatch, + action, + { autoHandleAfterReduce: true } + ); + onEvent('click'); + } + + useEffect(() => { + const echartsCompInstance = echartsCompRef?.current?.getEchartsInstance(); + if (!echartsCompInstance) { + return _.noop; + } + echartsCompInstance?.on("click", (param: any) => { + document.dispatchEvent(new CustomEvent("clickEvent", { + bubbles: true, + detail: { + action: 'click', + data: param.data, + } + })); + triggerClickEvent( + comp.dispatch, + changeChildAction("lastInteractionData", param.data, false) + ); + }); + return () => { + echartsCompInstance?.off("click"); + document.removeEventListener('clickEvent', clickEventCallback) + }; + }, []); + + useEffect(() => { + // bind events + const echartsCompInstance = echartsCompRef?.current?.getEchartsInstance(); + if (!echartsCompInstance) { + return _.noop; + } + echartsCompInstance?.on("selectchanged", (param: any) => { + const option: any = echartsCompInstance?.getOption(); + document.dispatchEvent(new CustomEvent("clickEvent", { + bubbles: true, + detail: { + action: param.fromAction, + data: getSelectedPoints(param, option) + } + })); + + if (param.fromAction === "select") { + comp.dispatch(changeChildAction("selectedPoints", getSelectedPoints(param, option), false)); + onUIEvent("select"); + } else if (param.fromAction === "unselect") { + comp.dispatch(changeChildAction("selectedPoints", getSelectedPoints(param, option), false)); + onUIEvent("unselect"); + } + + triggerClickEvent( + comp.dispatch, + changeChildAction("lastInteractionData", getSelectedPoints(param, option), false) + ); + }); + // unbind + return () => { + echartsCompInstance?.off("selectchanged"); + document.removeEventListener('clickEvent', clickEventCallback) + }; + }, [onUIEvent]); + + const echartsConfigChildren = _.omit(comp.children, echartsConfigOmitChildren); + const childrenProps = childrenToProps(echartsConfigChildren); + const option = useMemo(() => { + return getEchartsConfig( + childrenProps as ToViewReturn, + chartSize, + themeConfig + ); + }, [theme, childrenProps, chartSize, ...Object.values(echartsConfigChildren)]); + + useEffect(() => { + comp.children.mapInstance.dispatch(changeValueAction(null, false)) + if(comp.children.mapInstance.value) return; + }, [option]) + + return ( + { + if (w && h) { + setChartSize({ w: w, h: h }); + } + if (!firstResize.current) { + // ignore the first resize, which will impact the loading animation + echartsCompRef.current?.getEchartsInstance().resize(); + } else { + firstResize.current = false; + } + }} + > + (echartsCompRef.current = e)} + style={{ height: "100%" }} + notMerge + lazyUpdate + opts={{ locale: getEchartsLocale() }} + option={option} + mode={mode} + /> + + ); +}); + +function getYAxisFormatContextValue( + data: Array, + yAxisType: EchartsAxisType, + yAxisName?: string +) { + const dataSample = yAxisName && data.length > 0 && data[0][yAxisName]; + let contextValue = dataSample; + if (yAxisType === "time") { + // to timestamp + const time = + typeof dataSample === "number" || typeof dataSample === "string" + ? new Date(dataSample).getTime() + : null; + if (time) contextValue = time; + } + return contextValue; +} + +BarChartTmpComp = class extends BarChartTmpComp { + private lastYAxisFormatContextVal?: JSONValue; + private lastColorContext?: JSONObject; + + updateContext(comp: this) { + // the context value of axis format + let resultComp = comp; + const data = comp.children.data.getView(); + const sampleSeries = comp.children.series.getView().find((s) => !s.getView().hide); + const yAxisContextValue = getYAxisFormatContextValue( + data, + comp.children.yConfig.children.yAxisType.getView(), + sampleSeries?.children.columnName.getView() + ); + if (yAxisContextValue !== comp.lastYAxisFormatContextVal) { + comp.lastYAxisFormatContextVal = yAxisContextValue; + resultComp = comp.setChild( + "yConfig", + comp.children.yConfig.reduce( + wrapChildAction( + "formatter", + AxisFormatterComp.changeContextDataAction({ value: yAxisContextValue }) + ) + ) + ); + } + // item color context + const colorContextVal = { + seriesName: sampleSeries?.children.seriesName.getView(), + value: yAxisContextValue, + }; + if ( + comp.children.chartConfig.children.comp.children.hasOwnProperty("itemColor") && + !_.isEqual(colorContextVal, comp.lastColorContext) + ) { + comp.lastColorContext = colorContextVal; + resultComp = resultComp.setChild( + "chartConfig", + comp.children.chartConfig.reduce( + wrapChildAction( + "comp", + wrapChildAction("itemColor", ItemColorComp.changeContextDataAction(colorContextVal)) + ) + ) + ); + } + return resultComp; + } + + override reduce(action: CompAction): this { + const comp = super.reduce(action); + if (action.type === CompActionTypes.UPDATE_NODES_V2) { + const newData = comp.children.data.getView(); + // data changes + if (comp.children.data !== this.children.data) { + setTimeout(() => { + // update x-axis value + const keys = getDataKeys(newData); + if (keys.length > 0 && !keys.includes(comp.children.xAxisKey.getView())) { + comp.children.xAxisKey.dispatch(changeValueAction(keys[0] || "")); + } + // pass to child series comp + comp.children.series.dispatchDataChanged(newData); + }, 0); + } + return this.updateContext(comp); + } + return comp; + } + + override autoHeight(): boolean { + return false; + } +}; + +let BarChartComp = withExposingConfigs(BarChartTmpComp, [ + depsConfig({ + name: "selectedPoints", + desc: trans("chart.selectedPointsDesc"), + depKeys: ["selectedPoints"], + func: (input) => { + return input.selectedPoints; + }, + }), + depsConfig({ + name: "lastInteractionData", + desc: trans("chart.lastInteractionDataDesc"), + depKeys: ["lastInteractionData"], + func: (input) => { + return input.lastInteractionData; + }, + }), + depsConfig({ + name: "data", + desc: trans("chart.dataDesc"), + depKeys: ["data", "mode"], + func: (input) =>[] , + }), + new NameConfig("title", trans("chart.titleDesc")), +]); + + +export const BarChartCompWithDefault = withDefault(BarChartComp, { + xAxisKey: "date", + series: [ + { + dataIndex: genRandomKey(), + seriesName: trans("chart.spending"), + columnName: "spending", + }, + { + dataIndex: genRandomKey(), + seriesName: trans("chart.budget"), + columnName: "budget", + }, + ], +}); diff --git a/client/packages/lowcoder-comps/src/comps/barChartComp/barChartConstants.tsx b/client/packages/lowcoder-comps/src/comps/barChartComp/barChartConstants.tsx new file mode 100644 index 000000000..668b569be --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/barChartComp/barChartConstants.tsx @@ -0,0 +1,323 @@ +import { + jsonControl, + JSONObject, + stateComp, + toJSONObjectArray, + toObject, + BoolControl, + withDefault, + StringControl, + NumberControl, + FunctionControl, + dropdownControl, + eventHandlerControl, + valueComp, + withType, + uiChildren, + clickEvent, + styleControl, + EchartDefaultTextStyle, + EchartDefaultChartStyle, + toArray +} from "lowcoder-sdk"; +import { RecordConstructorToComp, RecordConstructorToView } from "lowcoder-core"; +import { BarChartConfig } from "../basicChartComp/chartConfigs/barChartConfig"; +import { XAxisConfig, YAxisConfig } from "../basicChartComp/chartConfigs/cartesianAxisConfig"; +import { LegendConfig } from "../basicChartComp/chartConfigs/legendConfig"; +import { EchartsLegendConfig } from "../basicChartComp/chartConfigs/echartsLegendConfig"; +import { EchartsLabelConfig } from "../basicChartComp/chartConfigs/echartsLabelConfig"; +import { LineChartConfig } from "../basicChartComp/chartConfigs/lineChartConfig"; +import { PieChartConfig } from "../basicChartComp/chartConfigs/pieChartConfig"; +import { ScatterChartConfig } from "../basicChartComp/chartConfigs/scatterChartConfig"; +import { SeriesListComp } from "./seriesComp"; +import { EChartsOption } from "echarts"; +import { i18nObjs, trans } from "i18n/comps"; +import { GaugeChartConfig } from "../basicChartComp/chartConfigs/gaugeChartConfig"; +import { FunnelChartConfig } from "../basicChartComp/chartConfigs/funnelChartConfig"; +import {EchartsTitleVerticalConfig} from "../chartComp/chartConfigs/echartsTitleVerticalConfig"; +import {EchartsTitleConfig} from "../basicChartComp/chartConfigs/echartsTitleConfig"; + +export const ChartTypeOptions = [ + { + label: trans("chart.bar"), + value: "bar", + }, + { + label: trans("chart.line"), + value: "line", + }, + { + label: trans("chart.scatter"), + value: "scatter", + }, + { + label: trans("chart.pie"), + value: "pie", + }, +] as const; + +export const UIEventOptions = [ + { + label: trans("chart.select"), + value: "select", + description: trans("chart.selectDesc"), + }, + { + label: trans("chart.unSelect"), + value: "unselect", + description: trans("chart.unselectDesc"), + }, +] as const; + +export const MapEventOptions = [ + { + label: trans("chart.mapReady"), + value: "mapReady", + description: trans("chart.mapReadyDesc"), + }, + { + label: trans("chart.zoomLevelChange"), + value: "zoomLevelChange", + description: trans("chart.zoomLevelChangeDesc"), + }, + { + label: trans("chart.centerPositionChange"), + value: "centerPositionChange", + description: trans("chart.centerPositionChangeDesc"), + }, +] as const; + +export const XAxisDirectionOptions = [ + { + label: trans("chart.horizontal"), + value: "horizontal", + }, + { + label: trans("chart.vertical"), + value: "vertical", + }, +] as const; + +export type XAxisDirectionType = ValueFromOption; + +export const noDataAxisConfig = { + animation: false, + xAxis: { + type: "category", + name: trans("chart.noData"), + nameLocation: "middle", + data: [], + axisLine: { + lineStyle: { + color: "#8B8FA3", + }, + }, + }, + yAxis: { + type: "value", + axisLabel: { + color: "#8B8FA3", + }, + splitLine: { + lineStyle: { + color: "#F0F0F0", + }, + }, + }, + tooltip: { + show: false, + }, + series: [ + { + data: [700], + type: "line", + itemStyle: { + opacity: 0, + }, + }, + ], +} as EChartsOption; + +export const noDataPieChartConfig = { + animation: false, + tooltip: { + show: false, + }, + legend: { + formatter: trans("chart.unknown"), + top: "bottom", + selectedMode: false, + }, + color: ["#B8BBCC", "#CED0D9", "#DCDEE6", "#E6E6EB"], + series: [ + { + type: "pie", + radius: "35%", + center: ["25%", "50%"], + silent: true, + label: { + show: false, + }, + data: [ + { + name: "1", + value: 70, + }, + { + name: "2", + value: 68, + }, + { + name: "3", + value: 48, + }, + { + name: "4", + value: 40, + }, + ], + }, + { + type: "pie", + radius: "35%", + center: ["75%", "50%"], + silent: true, + label: { + show: false, + }, + data: [ + { + name: "1", + value: 70, + }, + { + name: "2", + value: 68, + }, + { + name: "3", + value: 48, + }, + { + name: "4", + value: 40, + }, + ], + }, + ], +} as EChartsOption; + +export type ChartSize = { w: number; h: number }; + +export const getDataKeys = (data: Array) => { + if (!data) { + return []; + } + const dataKeys: Array = []; + data.slice(0, 50).forEach((d) => { + Object.keys(d).forEach((key) => { + if (!dataKeys.includes(key)) { + dataKeys.push(key); + } + }); + }); + return dataKeys; +}; + +const ChartOptionMap = { + bar: BarChartConfig, + line: LineChartConfig, + pie: PieChartConfig, + scatter: ScatterChartConfig, +}; + +const EchartsOptionMap = { + funnel: FunnelChartConfig, + gauge: GaugeChartConfig, +}; + +const ChartOptionComp = withType(ChartOptionMap, "bar"); +const EchartsOptionComp = withType(EchartsOptionMap, "funnel"); +export type CharOptionCompType = keyof typeof ChartOptionMap; + +export const chartUiModeChildren = { + title: withDefault(StringControl, trans("echarts.defaultTitle")), + data: jsonControl(toJSONObjectArray, i18nObjs.defaultDataSource), + xAxisKey: valueComp(""), // x-axis, key from data + xAxisDirection: dropdownControl(XAxisDirectionOptions, "horizontal"), + xAxisData: jsonControl(toArray, []), + series: SeriesListComp, + xConfig: XAxisConfig, + yConfig: YAxisConfig, + legendConfig: LegendConfig, + chartConfig: ChartOptionComp, + onUIEvent: eventHandlerControl(UIEventOptions), +}; + +let chartJsonModeChildren: any = { + echartsOption: jsonControl(toObject, i18nObjs.defaultEchartsJsonOption), + echartsTitle: withDefault(StringControl, trans("echarts.defaultTitle")), + echartsLegendConfig: EchartsLegendConfig, + echartsLabelConfig: EchartsLabelConfig, + echartsConfig: EchartsOptionComp, + echartsTitleVerticalConfig: EchartsTitleVerticalConfig, + echartsTitleConfig:EchartsTitleConfig, + + left:withDefault(NumberControl,trans('chart.defaultLeft')), + right:withDefault(NumberControl,trans('chart.defaultRight')), + top:withDefault(NumberControl,trans('chart.defaultTop')), + bottom:withDefault(NumberControl,trans('chart.defaultBottom')), + + tooltip: withDefault(BoolControl, true), + legendVisibility: withDefault(BoolControl, true), +} +if (EchartDefaultChartStyle && EchartDefaultTextStyle) { + chartJsonModeChildren = { + ...chartJsonModeChildren, + chartStyle: styleControl(EchartDefaultChartStyle, 'chartStyle'), + titleStyle: styleControl(EchartDefaultTextStyle, 'titleStyle'), + xAxisStyle: styleControl(EchartDefaultTextStyle, 'xAxis'), + yAxisStyle: styleControl(EchartDefaultTextStyle, 'yAxisStyle'), + legendStyle: styleControl(EchartDefaultTextStyle, 'legendStyle'), + } +} + +const chartMapModeChildren = { + mapInstance: stateComp(), + getMapInstance: FunctionControl, + mapApiKey: withDefault(StringControl, ''), + mapZoomLevel: withDefault(NumberControl, 3), + mapCenterLng: withDefault(NumberControl, 15.932644), + mapCenterLat: withDefault(NumberControl, 50.942063), + mapOptions: jsonControl(toObject, i18nObjs.defaultMapJsonOption), + onMapEvent: eventHandlerControl(MapEventOptions), + showCharts: withDefault(BoolControl, true), +} + +export type UIChartDataType = { + seriesName: string; + // coordinate chart + x?: any; + y?: any; + // pie or funnel + itemName?: any; + value?: any; +}; + +export type NonUIChartDataType = { + name: string; + value: any; +} + +export const barChartChildrenMap = { + selectedPoints: stateComp>([]), + lastInteractionData: stateComp | NonUIChartDataType>({}), + onEvent: eventHandlerControl([clickEvent] as const), + ...chartUiModeChildren, + ...chartJsonModeChildren, + ...chartMapModeChildren, +}; + +const chartUiChildrenMap = uiChildren(barChartChildrenMap); +export type ChartCompPropsType = RecordConstructorToView; +export type ChartCompChildrenType = RecordConstructorToComp; diff --git a/client/packages/lowcoder-comps/src/comps/barChartComp/barChartPropertyView.tsx b/client/packages/lowcoder-comps/src/comps/barChartComp/barChartPropertyView.tsx new file mode 100644 index 000000000..5f3d41879 --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/barChartComp/barChartPropertyView.tsx @@ -0,0 +1,150 @@ +import { changeChildAction, CompAction } from "lowcoder-core"; +import { ChartCompChildrenType, ChartTypeOptions,getDataKeys } from "./barChartConstants"; +import { newSeries } from "./seriesComp"; +import { + CustomModal, + Dropdown, + hiddenPropertyView, + Option, + RedButton, + Section, + sectionNames, + controlItem, +} from "lowcoder-sdk"; +import { trans } from "i18n/comps"; + +export function barChartPropertyView( + children: ChartCompChildrenType, + dispatch: (action: CompAction) => void +) { + const series = children.series.getView(); + const columnOptions = getDataKeys(children.data.getView()).map((key) => ({ + label: key, + value: key, + })); + + const uiModePropertyView = ( + <> +
+ {children.chartConfig.getPropertyView()} + { + dispatch(changeChildAction("xAxisKey", value)); + }} + /> + {children.chartConfig.getView().subtype === "waterfall" && children.xAxisData.propertyView({ + label: "X-Label-Data" + })} +
+
+
+ {children.onUIEvent.propertyView({title: trans("chart.chartEventHandlers")})} +
+
+ {children.onEvent.propertyView()} +
+
+
+ {children.echartsTitleConfig.getPropertyView()} + {children.echartsTitleVerticalConfig.getPropertyView()} + {children.legendConfig.getPropertyView()} + {children.title.propertyView({ label: trans("chart.title") })} + {children.left.propertyView({ label: trans("chart.left"), tooltip: trans("echarts.leftTooltip") })} + {children.right.propertyView({ label: trans("chart.right"), tooltip: trans("echarts.rightTooltip") })} + {children.top.propertyView({ label: trans("chart.top"), tooltip: trans("echarts.topTooltip") })} + {children.bottom.propertyView({ label: trans("chart.bottom"), tooltip: trans("echarts.bottomTooltip") })} + {children.chartConfig.children.compType.getView() !== "pie" && ( + <> + {children.xAxisDirection.propertyView({ + label: trans("chart.xAxisDirection"), + radioButton: true, + })} + {children.xConfig.getPropertyView()} + {children.yConfig.getPropertyView()} + + )} + {hiddenPropertyView(children)} + {children.tooltip.propertyView({label: trans("echarts.tooltip"), tooltip: trans("echarts.tooltipTooltip")})} +
+
+ {children.chartStyle?.getPropertyView()} +
+
+ {children.titleStyle?.getPropertyView()} +
+
+ {children.xAxisStyle?.getPropertyView()} +
+
+ {children.yAxisStyle?.getPropertyView()} +
+
+ {children.legendStyle?.getPropertyView()} +
+
+ {children.data.propertyView({ + label: trans("chart.data"), + })} +
+ + ); + + const getChatConfigByMode = (mode: string) => { + switch(mode) { + case "ui": + return uiModePropertyView; + } + } + return ( + <> + {getChatConfigByMode(children.mode.getView())} + + ); +} diff --git a/client/packages/lowcoder-comps/src/comps/barChartComp/barChartUtils.ts b/client/packages/lowcoder-comps/src/comps/barChartComp/barChartUtils.ts new file mode 100644 index 000000000..60b2a9031 --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/barChartComp/barChartUtils.ts @@ -0,0 +1,395 @@ +import { + CharOptionCompType, + ChartCompPropsType, + ChartSize, + noDataAxisConfig, + noDataPieChartConfig, +} from "comps/barChartComp/barChartConstants"; +import { getPieRadiusAndCenter } from "comps/basicChartComp/chartConfigs/pieChartConfig"; +import { EChartsOptionWithMap } from "../basicChartComp/reactEcharts/types"; +import _ from "lodash"; +import { chartColorPalette, isNumeric, JSONObject, loadScript } from "lowcoder-sdk"; +import { calcXYConfig } from "comps/basicChartComp/chartConfigs/cartesianAxisConfig"; +import Big from "big.js"; +import { googleMapsApiUrl } from "../basicChartComp/chartConfigs/chartUrls"; +import opacityToHex from "../../util/opacityToHex"; +import parseBackground from "../../util/gradientBackgroundColor"; +import {ba} from "@fullcalendar/core/internal-common"; +import {chartStyleWrapper, styleWrapper} from "../../util/styleWrapper"; + +export function transformData( + originData: JSONObject[], + xAxis: string, + seriesColumnNames: string[] +) { + // aggregate data by x-axis + const transformedData: JSONObject[] = []; + originData.reduce((prev, cur) => { + if (cur === null || cur === undefined) { + return prev; + } + const groupValue = cur[xAxis] as string; + if (!prev[groupValue]) { + // init as 0 + const initValue: any = {}; + seriesColumnNames.forEach((name) => { + initValue[name] = 0; + }); + prev[groupValue] = initValue; + transformedData.push(prev[groupValue]); + } + // remain the x-axis data + prev[groupValue][xAxis] = groupValue; + seriesColumnNames.forEach((key) => { + if (key === xAxis) { + return; + } else if (isNumeric(cur[key])) { + const bigNum = Big(cur[key]); + prev[groupValue][key] = bigNum.add(prev[groupValue][key]).toNumber(); + } else { + prev[groupValue][key] += 1; + } + }); + return prev; + }, {} as any); + return transformedData; +} + +const notAxisChartSet: Set = new Set(["pie"] as const); +const notAxisChartSubtypeSet: Set = new Set(["polar"] as const); +export const echartsConfigOmitChildren = [ + "hidden", + "selectedPoints", + "onUIEvent", + "mapInstance" +] as const; +type EchartsConfigProps = Omit; + + +export function isAxisChart(type: CharOptionCompType, subtype: string) { + return !notAxisChartSet.has(type) && !notAxisChartSubtypeSet.has(subtype); +} + +export function getSeriesConfig(props: EchartsConfigProps) { + let visibleSeries = props.series.filter((s) => !s.getView().hide); + if(props.chartConfig.subtype === "waterfall") { + const seriesOn = visibleSeries[0]; + const seriesPlaceholder = visibleSeries[0]; + visibleSeries = [seriesPlaceholder, seriesOn]; + } + const seriesLength = visibleSeries.length; + return visibleSeries.map((s, index) => { + if (isAxisChart(props.chartConfig.type, props.chartConfig.subtype)) { + let encodeX: string, encodeY: string; + const horizontalX = props.xAxisDirection === "horizontal"; + let itemStyle = props.chartConfig.itemStyle; + // FIXME: need refactor... chartConfig returns a function with paramters + if (props.chartConfig.type === "bar") { + // barChart's border radius, depend on x-axis direction and stack state + const borderRadius = horizontalX ? [2, 2, 0, 0] : [0, 2, 2, 0]; + if (props.chartConfig.stack && index === visibleSeries.length - 1) { + itemStyle = { ...itemStyle, borderRadius: borderRadius }; + } else if (!props.chartConfig.stack) { + itemStyle = { ...itemStyle, borderRadius: borderRadius }; + } + + if(props.chartConfig.subtype === "waterfall" && index === 0) { + itemStyle = { + borderColor: 'transparent', + color: 'transparent' + } + } + } + if (horizontalX) { + encodeX = props.xAxisKey; + encodeY = s.getView().columnName; + } else { + encodeX = s.getView().columnName; + encodeY = props.xAxisKey; + } + return { + name: props.chartConfig.subtype === "waterfall" && index === 0?" ":s.getView().seriesName, + selectedMode: "single", + select: { + itemStyle: { + borderColor: "#000", + }, + }, + encode: { + x: encodeX, + y: encodeY, + }, + // each type of chart's config + ...props.chartConfig, + itemStyle: itemStyle, + label: { + ...props.chartConfig.label, + ...(!horizontalX && { position: "outside" }), + }, + }; + } else { + // pie + const radiusAndCenter = getPieRadiusAndCenter(seriesLength, index, props.chartConfig); + return { + ...props.chartConfig, + radius: radiusAndCenter.radius, + center: radiusAndCenter.center, + name: s.getView().seriesName, + selectedMode: "single", + encode: { + itemName: props.xAxisKey, + value: s.getView().columnName, + }, + }; + } + }); +} + +// https://echarts.apache.org/en/option.html +export function getEchartsConfig( + props: EchartsConfigProps, + chartSize?: ChartSize, + theme?: any, +): EChartsOptionWithMap { + // axisChart + const axisChart = isAxisChart(props.chartConfig.type, props.chartConfig.subtype); + const gridPos = { + left: `${props?.left}%`, + right: `${props?.right}%`, + bottom: `${props?.bottom}%`, + top: `${props?.top}%`, + }; + let config: any = { + title: { + text: props.title, + top: props.echartsTitleVerticalConfig.top, + left:props.echartsTitleConfig.top, + textStyle: { + ...styleWrapper(props?.titleStyle, theme?.titleStyle) + } + }, + backgroundColor: parseBackground( props?.chartStyle?.background || theme?.chartStyle?.backgroundColor || "#FFFFFF"), + legend: { + ...props.legendConfig, + textStyle: { + ...styleWrapper(props?.legendStyle, theme?.legendStyle, 15) + } + }, + tooltip: props.tooltip && { + trigger: "axis", + axisPointer: { + type: "line", + lineStyle: { + color: "rgba(0,0,0,0.2)", + width: 2, + type: "solid" + } + } + }, + grid: { + ...gridPos, + containLabel: true, + }, + }; + if(props.chartConfig.race) { + config = { + ...config, + // Disable init animation. + animationDuration: 0, + animationDurationUpdate: 2000, + animationEasing: 'linear', + animationEasingUpdate: 'linear', + } + } + if (props.data.length <= 0) { + // no data + return { + ...config, + ...(axisChart ? noDataAxisConfig : noDataPieChartConfig), + }; + } + const yAxisConfig = props.yConfig(); + const seriesColumnNames = props.series + .filter((s) => !s.getView().hide) + .map((s) => s.getView().columnName); + // y-axis is category and time, data doesn't need to aggregate + let transformedData = + yAxisConfig.type === "category" || yAxisConfig.type === "time" ? props.echartsOption.length && props.echartsOption || props.data : transformData(props.echartsOption.length && props.echartsOption || props.data, props.xAxisKey, seriesColumnNames); + + if(props.chartConfig.subtype === "waterfall") { + config.legend = undefined; + let sum = transformedData.reduce((acc, item) => { + if(typeof item[seriesColumnNames[0]] === 'number') return acc + item[seriesColumnNames[0]]; + else return acc; + }, 0) + const total = sum; + transformedData.map(d => { + d[` `] = sum - d[seriesColumnNames[0]]; + sum = d[` `]; + }) + transformedData = [{[seriesColumnNames[0] + "_placeholder"]: 0, [seriesColumnNames[0]]: total, [props.xAxisKey]: "Total"}, ...transformedData] + } + + if(props.chartConfig.subtype === "polar") { + config = { + ...config, + polar: { + radius: [props.chartConfig.polarData.polarRadiusStart, props.chartConfig.polarData.polarRadiusEnd], + }, + radiusAxis: { + type: props.chartConfig.polarData.polarIsTangent?'category':undefined, + data: props.chartConfig.polarData.polarIsTangent && props.chartConfig.polarData.labelData.length!==0?props.chartConfig.polarData.labelData:undefined, + max: props.chartConfig.polarData.polarIsTangent?undefined:props.chartConfig.polarData.radiusAxisMax || undefined, + }, + angleAxis: { + type: props.chartConfig.polarData.polarIsTangent?undefined:'category', + data: !props.chartConfig.polarData.polarIsTangent && props.chartConfig.polarData.labelData.length!==0?props.chartConfig.polarData.labelData:undefined, + max: props.chartConfig.polarData.polarIsTangent?props.chartConfig.polarData.radiusAxisMax || undefined:undefined, + startAngle: props.chartConfig.polarData.polarStartAngle, + endAngle: props.chartConfig.polarData.polarEndAngle, + }, + } + } + + config = { + ...config, + dataset: [ + { + source: transformedData, + sourceHeader: false, + }, + ], + series: getSeriesConfig(props).map(series => ({ + ...series, + encode: { + ...series.encode, + y: series.name, + }, + itemStyle: { + ...series.itemStyle, + ...chartStyleWrapper(props?.chartStyle, theme?.chartStyle) + }, + lineStyle: { + ...chartStyleWrapper(props?.chartStyle, theme?.chartStyle) + }, + data: transformedData.map((i: any) => i[series.name]) + })), + }; + if (axisChart) { + // pure chart's size except the margin around + let chartRealSize; + if (chartSize) { + const rightSize = + typeof gridPos.right === "number" + ? gridPos.right + : (chartSize.w * parseFloat(gridPos.right)) / 100.0; + chartRealSize = { + // actually it's self-adaptive with the x-axis label on the left, not that accurate but work + w: chartSize.w - gridPos.left - rightSize, + // also self-adaptive on the bottom + h: chartSize.h - gridPos.top - gridPos.bottom, + right: rightSize, + }; + } + const finalXyConfig = calcXYConfig( + props.xConfig, + yAxisConfig, + props.xAxisDirection, + transformedData.map((d) => d[props.xAxisKey]), + chartRealSize + ); + config = { + ...config, + // @ts-ignore + xAxis: { + ...finalXyConfig.xConfig, + axisLabel: { + ...styleWrapper(props?.xAxisStyle, theme?.xAxisStyle, 11) + }, + data: finalXyConfig.xConfig.type === "category" && (props.xAxisData as []).length!==0?props?.xAxisData:transformedData.map((i: any) => i[props.xAxisKey]), + }, + // @ts-ignore + yAxis: { + ...finalXyConfig.yConfig, + axisLabel: { + ...styleWrapper(props?.yAxisStyle, theme?.yAxisStyle, 11) + }, + data: finalXyConfig.yConfig.type === "category" && (props.xAxisData as []).length!==0?props?.xAxisData:transformedData.map((i: any) => i[props.xAxisKey]), + }, + }; + + if(props.chartConfig.race) { + config = { + ...config, + xAxis: { + ...config.xAxis, + animationDuration: 300, + animationDurationUpdate: 300 + }, + yAxis: { + ...config.yAxis, + animationDuration: 300, + animationDurationUpdate: 300 + }, + } + } + } + // log.log("Echarts transformedData and config", transformedData, config); + return config; +} + +export function getSelectedPoints(param: any, option: any) { + const series = option.series; + const dataSource = _.isArray(option.dataset) && option.dataset[0]?.source; + if (series && dataSource) { + return param.selected.flatMap((selectInfo: any) => { + const seriesInfo = series[selectInfo.seriesIndex]; + if (!seriesInfo || !seriesInfo.encode) { + return []; + } + return selectInfo.dataIndex.map((index: any) => { + const commonResult = { + seriesName: seriesInfo.name, + }; + if (seriesInfo.encode.itemName && seriesInfo.encode.value) { + return { + ...commonResult, + itemName: dataSource[index][seriesInfo.encode.itemName], + value: dataSource[index][seriesInfo.encode.value], + }; + } else { + return { + ...commonResult, + x: dataSource[index][seriesInfo.encode.x], + y: dataSource[index][seriesInfo.encode.y], + }; + } + }); + }); + } + return []; +} + +export function loadGoogleMapsScript(apiKey: string) { + const mapsUrl = `${googleMapsApiUrl}?key=${apiKey}`; + const scripts = document.getElementsByTagName('script'); + // is script already loaded + let scriptIndex = _.findIndex(scripts, (script) => script.src.endsWith(mapsUrl)); + if(scriptIndex > -1) { + return scripts[scriptIndex]; + } + // is script loaded with diff api_key, remove the script and load again + scriptIndex = _.findIndex(scripts, (script) => script.src.startsWith(googleMapsApiUrl)); + if(scriptIndex > -1) { + scripts[scriptIndex].remove(); + } + + const script = document.createElement("script"); + script.type = "text/javascript"; + script.src = mapsUrl; + script.async = true; + script.defer = true; + window.document.body.appendChild(script); + + return script; +} diff --git a/client/packages/lowcoder-comps/src/comps/barChartComp/seriesComp.tsx b/client/packages/lowcoder-comps/src/comps/barChartComp/seriesComp.tsx new file mode 100644 index 000000000..9ded885b5 --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/barChartComp/seriesComp.tsx @@ -0,0 +1,119 @@ +import { + BoolControl, + StringControl, + list, + JSONObject, + isNumeric, + genRandomKey, + Dropdown, + OptionsType, + MultiCompBuilder, + valueComp, +} from "lowcoder-sdk"; +import { trans } from "i18n/comps"; + +import { ConstructorToComp, ConstructorToDataType, ConstructorToView } from "lowcoder-core"; +import { CompAction, CustomAction, customAction, isMyCustomAction } from "lowcoder-core"; + +export type SeriesCompType = ConstructorToComp; +export type RawSeriesCompType = ConstructorToView; +type SeriesDataType = ConstructorToDataType; + +type ActionDataType = { + type: "chartDataChanged"; + chartData: Array; +}; + +export function newSeries(name: string, columnName: string): SeriesDataType { + return { + seriesName: name, + columnName: columnName, + dataIndex: genRandomKey(), + }; +} + +const seriesChildrenMap = { + columnName: StringControl, + seriesName: StringControl, + hide: BoolControl, + // unique key, for sort + dataIndex: valueComp(""), +}; + +const SeriesTmpComp = new MultiCompBuilder(seriesChildrenMap, (props) => { + return props; +}) + .setPropertyViewFn(() => { + return <>; + }) + .build(); + +class SeriesComp extends SeriesTmpComp { + getPropertyViewWithData(columnOptions: OptionsType): React.ReactNode { + return ( + <> + {this.children.seriesName.propertyView({ + label: trans("chart.seriesName"), + })} + { + this.children.columnName.dispatchChangeValueAction(value); + }} + /> + + ); + } +} + +const SeriesListTmpComp = list(SeriesComp); + +export class SeriesListComp extends SeriesListTmpComp { + override reduce(action: CompAction): this { + if (isMyCustomAction(action, "chartDataChanged")) { + // auto generate series + const actions = this.genExampleSeriesActions(action.value.chartData); + return this.reduce(this.multiAction(actions)); + } + return super.reduce(action); + } + + private genExampleSeriesActions(chartData: Array) { + const actions: CustomAction[] = []; + if (!chartData || chartData.length <= 0 || !chartData[0]) { + return actions; + } + let delCnt = 0; + const existColumns = this.getView().map((s) => s.getView().columnName); + // delete series not in data + existColumns.forEach((columnName) => { + if (chartData[0]?.[columnName] === undefined) { + actions.push(this.deleteAction(0)); + delCnt++; + } + }); + if (existColumns.length > delCnt) { + // don't generate example if exists + return actions; + } + // generate example series + const exampleKeys = Object.keys(chartData[0]) + .filter((key) => { + return !existColumns.includes(key) && isNumeric(chartData[0][key]); + }) + .slice(0, 3); + exampleKeys.forEach((key) => actions.push(this.pushAction(newSeries(key, key)))); + return actions; + } + + dispatchDataChanged(chartData: Array): void { + this.dispatch( + customAction({ + type: "chartDataChanged", + chartData: chartData, + }) + ); + } +} diff --git a/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/barChartConfig.tsx b/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/barChartConfig.tsx index 6c91fe252..ee1188335 100644 --- a/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/barChartConfig.tsx +++ b/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/barChartConfig.tsx @@ -1,11 +1,19 @@ import { BoolControl, + NumberControl, + StringControl, + withDefault, dropdownControl, MultiCompBuilder, showLabelPropertyView, + ColorControl, + Dropdown, + toArray, + jsonControl, } from "lowcoder-sdk"; +import { changeChildAction, CompAction } from "lowcoder-core"; import { BarSeriesOption } from "echarts"; -import { trans } from "i18n/comps"; +import { i18nObjs, trans } from "i18n/comps"; const BarTypeOptions = [ { @@ -13,8 +21,12 @@ const BarTypeOptions = [ value: "basicBar", }, { - label: trans("chart.stackedBar"), - value: "stackedBar", + label: trans("chart.waterfallBar"), + value: "waterfall", + }, + { + label: trans("chart.polar"), + value: "polar", }, ] as const; @@ -23,27 +35,105 @@ export const BarChartConfig = (function () { { showLabel: BoolControl, type: dropdownControl(BarTypeOptions, "basicBar"), + barWidth: withDefault(NumberControl, i18nObjs.defaultBarChartOption.barWidth), + showBackground: BoolControl, + backgroundColor: withDefault(ColorControl, i18nObjs.defaultBarChartOption.barBg), + radiusAxisMax: NumberControl, + polarRadiusStart: withDefault(StringControl, '30'), + polarRadiusEnd: withDefault(StringControl, '80%'), + polarStartAngle: withDefault(NumberControl, 90), + polarEndAngle: withDefault(NumberControl, -180), + polarIsTangent: withDefault(BoolControl, false), + stack: withDefault(BoolControl, false), + race: withDefault(BoolControl, false), + labelData: jsonControl(toArray, []), }, (props): BarSeriesOption => { const config: BarSeriesOption = { type: "bar", + subtype: props.type, + realtimeSort: props.race, + seriesLayoutBy: props.race?'column':undefined, label: { show: props.showLabel, position: "top", + valueAnimation: props.race, + }, + barWidth: `${props.barWidth}%`, + showBackground: props.showBackground, + backgroundStyle: { + color: props.backgroundColor, }, + polarData: { + radiusAxisMax: props.radiusAxisMax, + polarRadiusStart: props.polarRadiusStart, + polarRadiusEnd: props.polarRadiusEnd, + polarStartAngle: props.polarStartAngle, + polarEndAngle: props.polarEndAngle, + labelData: props.labelData, + polarIsTangent: props.polarIsTangent, + }, + race: props.race, }; - if (props.type === "stackedBar") { + if (props.stack) { config.stack = "stackValue"; } + if (props.type === "waterfall") { + config.label = undefined; + config.stack = "stackValue"; + } + if (props.type === "polar") { + config.coordinateSystem = 'polar'; + } return config; } ) - .setPropertyViewFn((children) => ( + .setPropertyViewFn((children, dispatch: (action: CompAction) => void) => ( <> + { + dispatch(changeChildAction("type", value)); + }} + /> {showLabelPropertyView(children)} - {children.type.propertyView({ - label: trans("chart.barType"), - radioButton: true, + {children.barWidth.propertyView({ + label: trans("barChart.barWidth"), + })} + {children.type.getView() !== "waterfall" && children.race.propertyView({ + label: trans("barChart.race"), + })} + {children.type.getView() !== "waterfall" && children.stack.propertyView({ + label: trans("barChart.stack"), + })} + {children.showBackground.propertyView({ + label: trans("barChart.showBg"), + })} + {children.showBackground.getView() && children.backgroundColor.propertyView({ + label: trans("barChart.bgColor"), + })} + {children.type.getView() === "polar" && children.polarIsTangent.propertyView({ + label: trans("barChart.polarIsTangent"), + })} + {children.type.getView() === "polar" && children.polarStartAngle.propertyView({ + label: trans("barChart.polarStartAngle"), + })} + {children.type.getView() === "polar" && children.polarEndAngle.propertyView({ + label: trans("barChart.polarEndAngle"), + })} + {children.type.getView() === "polar" && children.radiusAxisMax.propertyView({ + label: trans("barChart.radiusAxisMax"), + })} + {children.type.getView() === "polar" && children.polarRadiusStart.propertyView({ + label: trans("barChart.polarRadiusStart"), + })} + {children.type.getView() === "polar" && children.polarRadiusEnd.propertyView({ + label: trans("barChart.polarRadiusEnd"), + })} + {children.type.getView() === "polar" && children.labelData.propertyView({ + label: trans("barChart.polarLabelData"), })} )) diff --git a/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/lineChartConfig.tsx b/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/lineChartConfig.tsx index 266e5fbf7..a021639e3 100644 --- a/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/lineChartConfig.tsx +++ b/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/lineChartConfig.tsx @@ -3,28 +3,18 @@ import { MultiCompBuilder, BoolControl, dropdownControl, + jsonControl, + toArray, showLabelPropertyView, withContext, + ColorControl, StringControl, + NumberControl, + withDefault, ColorOrBoolCodeControl, } from "lowcoder-sdk"; import { trans } from "i18n/comps"; -const BarTypeOptions = [ - { - label: trans("chart.basicLine"), - value: "basicLine", - }, - { - label: trans("chart.stackedLine"), - value: "stackedLine", - }, - { - label: trans("chart.areaLine"), - value: "areaLine", - }, -] as const; - export const ItemColorComp = withContext( new MultiCompBuilder({ value: ColorOrBoolCodeControl }, (props) => props.value) .setPropertyViewFn((children) => @@ -38,13 +28,83 @@ export const ItemColorComp = withContext( ["seriesName", "value"] as const ); +export const SymbolOptions = [ + { + label: trans("chart.rect"), + value: "rect", + }, + { + label: trans("chart.circle"), + value: "circle", + }, + { + label: trans("chart.roundRect"), + value: "roundRect", + }, + { + label: trans("chart.triangle"), + value: "triangle", + }, + { + label: trans("chart.diamond"), + value: "diamond", + }, + { + label: trans("chart.pin"), + value: "pin", + }, + { + label: trans("chart.arrow"), + value: "arrow", + }, + { + label: trans("chart.none"), + value: "none", + }, + { + label: trans("chart.emptyCircle"), + value: "emptyCircle", + }, +] as const; + +export const BorderTypeOptions = [ + { + label: trans("lineChart.solid"), + value: "solid", + }, + { + label: trans("lineChart.dashed"), + value: "dashed", + }, + { + label: trans("lineChart.dotted"), + value: "dotted", + }, +] as const; + export const LineChartConfig = (function () { return new MultiCompBuilder( { showLabel: BoolControl, - type: dropdownControl(BarTypeOptions, "basicLine"), + showEndLabel: BoolControl, + stacked: BoolControl, + area: BoolControl, smooth: BoolControl, + polar: BoolControl, itemColor: ItemColorComp, + symbol: dropdownControl(SymbolOptions, "emptyCircle"), + symbolSize: withDefault(NumberControl, 4), + radiusAxisMax: NumberControl, + polarRadiusStart: withDefault(StringControl, '30'), + polarRadiusEnd: withDefault(StringControl, '80%'), + polarStartAngle: withDefault(NumberControl, 90), + polarEndAngle: withDefault(NumberControl, -180), + polarIsTangent: withDefault(BoolControl, false), + labelData: jsonControl(toArray, []), + //series-line.itemStyle + borderColor: ColorControl, + borderWidth: NumberControl, + borderType: dropdownControl(BorderTypeOptions, 'solid'), }, (props): LineSeriesOption => { const config: LineSeriesOption = { @@ -52,12 +112,14 @@ export const LineChartConfig = (function () { label: { show: props.showLabel, }, + symbol: props.symbol, + symbolSize: props.symbolSize, itemStyle: { color: (params) => { if (!params.encode || !params.dimensionNames) { return params.color; } - const dataKey = params.dimensionNames[params.encode["y"][0]]; + const dataKey = params.dimensionNames[params.encode[props.polar?"radius":"y"][0]]; const color = (props.itemColor as any)({ seriesName: params.seriesName, value: (params.data as any)[dataKey], @@ -69,27 +131,96 @@ export const LineChartConfig = (function () { } return color; }, + borderColor: props.borderColor, + borderWidth: props.borderWidth, + borderType: props.borderType, + }, + polarData: { + polar: props.polar, + radiusAxisMax: props.radiusAxisMax, + polarRadiusStart: props.polarRadiusStart, + polarRadiusEnd: props.polarRadiusEnd, + polarStartAngle: props.polarStartAngle, + polarEndAngle: props.polarEndAngle, + labelData: props.labelData, + polarIsTangent: props.polarIsTangent, }, }; - if (props.type === "stackedLine") { + if (props.stacked) { config.stack = "stackValue"; - } else if (props.type === "areaLine") { + } + if (props.area) { config.areaStyle = {}; } if (props.smooth) { config.smooth = true; } + if (props.showEndLabel) { + config.endLabel = { + show: true, + formatter: '{a}', + distance: 20 + } + } + if (props.polar) { + config.coordinateSystem = 'polar'; + } return config; } ) .setPropertyViewFn((children) => ( <> - {children.type.propertyView({ - label: trans("chart.lineType"), + {children.stacked.propertyView({ + label: trans("lineChart.stacked"), + })} + {children.area.propertyView({ + label: trans("lineChart.area"), + })} + {children.polar.propertyView({ + label: trans("lineChart.polar"), + })} + {children.polar.getView() && children.polarIsTangent.propertyView({ + label: trans("barChart.polarIsTangent"), + })} + {children.polar.getView() && children.polarStartAngle.propertyView({ + label: trans("barChart.polarStartAngle"), + })} + {children.polar.getView() && children.polarEndAngle.propertyView({ + label: trans("barChart.polarEndAngle"), + })} + {children.polar.getView() && children.radiusAxisMax.propertyView({ + label: trans("barChart.radiusAxisMax"), + })} + {children.polar.getView() && children.polarRadiusStart.propertyView({ + label: trans("barChart.polarRadiusStart"), + })} + {children.polar.getView() && children.polarRadiusEnd.propertyView({ + label: trans("barChart.polarRadiusEnd"), + })} + {children.polar.getView() && children.labelData.propertyView({ + label: trans("barChart.polarLabelData"), })} {showLabelPropertyView(children)} + {children.showEndLabel.propertyView({ + label: trans("lineChart.showEndLabel"), + })} {children.smooth.propertyView({ label: trans("chart.smooth") })} + {children.symbol.propertyView({ + label: trans("lineChart.symbol"), + })} + {children.symbolSize.propertyView({ + label: trans("lineChart.symbolSize"), + })} {children.itemColor.getPropertyView()} + {children.borderColor.propertyView({ + label: trans("lineChart.borderColor"), + })} + {children.borderWidth.propertyView({ + label: trans("lineChart.borderWidth"), + })} + {children.borderType.propertyView({ + label: trans("lineChart.borderType"), + })} )) .build(); diff --git a/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/pieChartConfig.tsx b/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/pieChartConfig.tsx index 0861fb6ba..5ddb10aa0 100644 --- a/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/pieChartConfig.tsx +++ b/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/pieChartConfig.tsx @@ -1,6 +1,11 @@ import { MultiCompBuilder } from "lowcoder-sdk"; import { PieSeriesOption } from "echarts"; -import { dropdownControl } from "lowcoder-sdk"; +import { + dropdownControl, + NumberControl, + StringControl, + withDefault, + } from "lowcoder-sdk"; import { ConstructorToView } from "lowcoder-core"; import { trans } from "i18n/comps"; @@ -17,6 +22,14 @@ const BarTypeOptions = [ label: trans("chart.rosePie"), value: "rosePie", }, + { + label: trans("chart.calendarPie"), + value: "calendarPie", + }, + { + label: trans("chart.geoPie"), + value: "geoPie", + }, ] as const; // radius percent for each pie chart when one line has [1, 2, 3] pie charts @@ -28,20 +41,37 @@ export const PieChartConfig = (function () { return new MultiCompBuilder( { type: dropdownControl(BarTypeOptions, "basicPie"), + cellSize: withDefault(NumberControl, 40), + range: withDefault(StringControl, "2021-09"), + mapUrl: withDefault(StringControl, "https://echarts.apache.org/examples/data/asset/geo/USA.json"), }, (props): PieSeriesOption => { const config: PieSeriesOption = { type: "pie", + subtype: props.type, label: { show: true, - formatter: "{d}%", + formatter: "{c}", }, + range: props.range, }; if (props.type === "rosePie") { config.roseType = "area"; - } else if (props.type === "doughnutPie") { + } + if (props.type === "doughnutPie") { config.radius = ["40%", "60%"]; } + if (props.type === "calendarPie") { + config.coordinateSystem = 'calendar'; + config.cellSize = [props.cellSize, props.cellSize]; + config.label = { + ...config.label, + position: 'inside' + }; + } + if (props.type === "geoPie") { + config.mapUrl = props.mapUrl; + } return config; } ) @@ -50,6 +80,15 @@ export const PieChartConfig = (function () { {children.type.propertyView({ label: trans("chart.pieType"), })} + {children.type.getView() === "calendarPie" && children.cellSize.propertyView({ + label: trans("lineChart.cellSize"), + })} + {children.type.getView() === "calendarPie" && children.range.propertyView({ + label: trans("lineChart.range"), + })} + {children.type.getView() === "geoPie" && children.mapUrl.propertyView({ + label: trans("pieChart.mapUrl"), + })} )) .build(); diff --git a/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/scatterChartConfig.tsx b/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/scatterChartConfig.tsx index edb339bdb..34b5f2cb6 100644 --- a/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/scatterChartConfig.tsx +++ b/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/scatterChartConfig.tsx @@ -2,6 +2,10 @@ import { MultiCompBuilder, dropdownControl, BoolControl, + StringControl, + NumberControl, + ColorControl, + withDefault, showLabelPropertyView, } from "lowcoder-sdk"; import { ScatterSeriesOption } from "echarts"; @@ -38,7 +42,19 @@ export const ScatterChartConfig = (function () { return new MultiCompBuilder( { showLabel: BoolControl, + labelIndex: withDefault(NumberControl, 2), shape: dropdownControl(ScatterShapeOptions, "circle"), + singleAxis: BoolControl, + boundaryGap: withDefault(BoolControl, true), + visualMap: BoolControl, + visualMapMin: NumberControl, + visualMapMax: NumberControl, + visualMapDimension: NumberControl, + visualMapColorMin: ColorControl, + visualMapColorMax: ColorControl, + polar: BoolControl, + heatmap: BoolControl, + heatmapMonth: withDefault(StringControl, "2021-09"), }, (props): ScatterSeriesOption => { return { @@ -46,16 +62,82 @@ export const ScatterChartConfig = (function () { symbol: props.shape, label: { show: props.showLabel, + position: 'right', + formatter: function (param) { + return param.data[props.labelIndex]; + }, }, + labelLayout: function () { + return { + x: '88%', + moveOverlap: 'shiftY' + }; + }, + labelLine: { + show: true, + length2: 5, + lineStyle: { + color: '#bbb' + } + }, + singleAxis: props.singleAxis, + boundaryGap: props.boundaryGap, + visualMapData: { + visualMap: props.visualMap, + visualMapMin: props.visualMapMin, + visualMapMax: props.visualMapMax, + visualMapDimension: props.visualMapDimension, + visualMapColorMin: props.visualMapColorMin, + visualMapColorMax: props.visualMapColorMax, + }, + polar: props.polar, + heatmap: props.heatmap, + heatmapMonth: props.heatmapMonth, }; } ) .setPropertyViewFn((children) => ( <> {showLabelPropertyView(children)} + {children.showLabel.getView() && children.labelIndex.propertyView({ + label: trans("scatterChart.labelIndex"), + })} + {children.boundaryGap.propertyView({ + label: trans("scatterChart.boundaryGap"), + })} {children.shape.propertyView({ label: trans("chart.scatterShape"), })} + {children.singleAxis.propertyView({ + label: trans("scatterChart.singleAxis"), + })} + {children.visualMap.propertyView({ + label: trans("scatterChart.visualMap"), + })} + {children.visualMap.getView() && children.visualMapMin.propertyView({ + label: trans("scatterChart.visualMapMin"), + })} + {children.visualMap.getView() && children.visualMapMax.propertyView({ + label: trans("scatterChart.visualMapMax"), + })} + {children.visualMap.getView() && children.visualMapDimension.propertyView({ + label: trans("scatterChart.visualMapDimension"), + })} + {children.visualMap.getView() && children.visualMapColorMin.propertyView({ + label: trans("scatterChart.visualMapColorMin"), + })} + {children.visualMap.getView() && children.visualMapColorMax.propertyView({ + label: trans("scatterChart.visualMapColorMax"), + })} + {children.visualMap.getView() && children.heatmap.propertyView({ + label: trans("scatterChart.heatmap"), + })} + {children.visualMap.getView() && children.heatmapMonth.propertyView({ + label: trans("scatterChart.heatmapMonth"), + })} + {children.polar.propertyView({ + label: trans("scatterChart.polar"), + })} )) .build(); diff --git a/client/packages/lowcoder-comps/src/comps/basicChartComp/chartUtils.ts b/client/packages/lowcoder-comps/src/comps/basicChartComp/chartUtils.ts index 402011e6c..6c5020690 100644 --- a/client/packages/lowcoder-comps/src/comps/basicChartComp/chartUtils.ts +++ b/client/packages/lowcoder-comps/src/comps/basicChartComp/chartUtils.ts @@ -276,7 +276,7 @@ export function getEchartsConfig( }, }; } - // log.log("Echarts transformedData and config", transformedData, config); + // console.log("Echarts transformedData and config", transformedData, config); return config; } diff --git a/client/packages/lowcoder-comps/src/comps/chartComp/chartConfigs/barChartConfig.tsx b/client/packages/lowcoder-comps/src/comps/chartComp/chartConfigs/barChartConfig.tsx index 6c91fe252..707b16170 100644 --- a/client/packages/lowcoder-comps/src/comps/chartComp/chartConfigs/barChartConfig.tsx +++ b/client/packages/lowcoder-comps/src/comps/chartComp/chartConfigs/barChartConfig.tsx @@ -5,7 +5,7 @@ import { showLabelPropertyView, } from "lowcoder-sdk"; import { BarSeriesOption } from "echarts"; -import { trans } from "i18n/comps"; +import { i18nObjs, trans } from "i18n/comps"; const BarTypeOptions = [ { diff --git a/client/packages/lowcoder-comps/src/comps/lineChartComp/lineChartComp.tsx b/client/packages/lowcoder-comps/src/comps/lineChartComp/lineChartComp.tsx new file mode 100644 index 000000000..be3e5bf65 --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/lineChartComp/lineChartComp.tsx @@ -0,0 +1,314 @@ +import { + changeChildAction, + changeValueAction, + CompAction, + CompActionTypes, + wrapChildAction, +} from "lowcoder-core"; +import { AxisFormatterComp, EchartsAxisType } from "../basicChartComp/chartConfigs/cartesianAxisConfig"; +import { lineChartChildrenMap, ChartSize, getDataKeys } from "./lineChartConstants"; +import { lineChartPropertyView } from "./lineChartPropertyView"; +import _ from "lodash"; +import { useContext, useEffect, useMemo, useRef, useState } from "react"; +import ReactResizeDetector from "react-resize-detector"; +import ReactECharts from "../basicChartComp/reactEcharts"; +import { + childrenToProps, + depsConfig, + genRandomKey, + NameConfig, + UICompBuilder, + withDefault, + withExposingConfigs, + withViewFn, + ThemeContext, + chartColorPalette, + getPromiseAfterDispatch, + dropdownControl, +} from "lowcoder-sdk"; +import { getEchartsLocale, trans } from "i18n/comps"; +import { ItemColorComp } from "comps/basicChartComp/chartConfigs/lineChartConfig"; +import { + echartsConfigOmitChildren, + getEchartsConfig, + getSelectedPoints, +} from "./lineChartUtils"; +import 'echarts-extension-gmap'; +import log from "loglevel"; + +let clickEventCallback = () => {}; + +const chartModeOptions = [ + { + label: "ECharts JSON", + value: "json", + } +] as const; + +let LineChartTmpComp = (function () { + return new UICompBuilder({mode:dropdownControl(chartModeOptions,'ui'),...lineChartChildrenMap}, () => null) + .setPropertyViewFn(lineChartPropertyView) + .build(); +})(); + +LineChartTmpComp = withViewFn(LineChartTmpComp, (comp) => { + const mode = comp.children.mode.getView(); + const onUIEvent = comp.children.onUIEvent.getView(); + const onEvent = comp.children.onEvent.getView(); + const echartsCompRef = useRef(); + const [chartSize, setChartSize] = useState(); + const firstResize = useRef(true); + const theme = useContext(ThemeContext); + const defaultChartTheme = { + color: chartColorPalette, + backgroundColor: "#fff", + }; + + let themeConfig = defaultChartTheme; + try { + themeConfig = theme?.theme.chart ? JSON.parse(theme?.theme.chart) : defaultChartTheme; + } catch (error) { + log.error('theme chart error: ', error); + } + + const triggerClickEvent = async (dispatch: any, action: CompAction) => { + await getPromiseAfterDispatch( + dispatch, + action, + { autoHandleAfterReduce: true } + ); + onEvent('click'); + } + + useEffect(() => { + const echartsCompInstance = echartsCompRef?.current?.getEchartsInstance(); + if (!echartsCompInstance) { + return _.noop; + } + echartsCompInstance?.on("click", (param: any) => { + document.dispatchEvent(new CustomEvent("clickEvent", { + bubbles: true, + detail: { + action: 'click', + data: param.data, + } + })); + triggerClickEvent( + comp.dispatch, + changeChildAction("lastInteractionData", param.data, false) + ); + }); + return () => { + echartsCompInstance?.off("click"); + document.removeEventListener('clickEvent', clickEventCallback) + }; + }, []); + + useEffect(() => { + // bind events + const echartsCompInstance = echartsCompRef?.current?.getEchartsInstance(); + if (!echartsCompInstance) { + return _.noop; + } + echartsCompInstance?.on("selectchanged", (param: any) => { + const option: any = echartsCompInstance?.getOption(); + document.dispatchEvent(new CustomEvent("clickEvent", { + bubbles: true, + detail: { + action: param.fromAction, + data: getSelectedPoints(param, option) + } + })); + + if (param.fromAction === "select") { + comp.dispatch(changeChildAction("selectedPoints", getSelectedPoints(param, option), false)); + onUIEvent("select"); + } else if (param.fromAction === "unselect") { + comp.dispatch(changeChildAction("selectedPoints", getSelectedPoints(param, option), false)); + onUIEvent("unselect"); + } + + triggerClickEvent( + comp.dispatch, + changeChildAction("lastInteractionData", getSelectedPoints(param, option), false) + ); + }); + // unbind + return () => { + echartsCompInstance?.off("selectchanged"); + document.removeEventListener('clickEvent', clickEventCallback) + }; + }, [onUIEvent]); + + const echartsConfigChildren = _.omit(comp.children, echartsConfigOmitChildren); + const childrenProps = childrenToProps(echartsConfigChildren); + const option = useMemo(() => { + return getEchartsConfig( + childrenProps as ToViewReturn, + chartSize, + themeConfig + ); + }, [theme, childrenProps, chartSize, ...Object.values(echartsConfigChildren)]); + + return ( + { + if (w && h) { + setChartSize({ w: w, h: h }); + } + if (!firstResize.current) { + // ignore the first resize, which will impact the loading animation + echartsCompRef.current?.getEchartsInstance().resize(); + } else { + firstResize.current = false; + } + }} + > + (echartsCompRef.current = e)} + style={{ height: "100%" }} + notMerge + lazyUpdate + opts={{ locale: getEchartsLocale() }} + option={option} + mode={mode} + /> + + ); +}); + +function getYAxisFormatContextValue( + data: Array, + yAxisType: EchartsAxisType, + yAxisName?: string +) { + const dataSample = yAxisName && data.length > 0 && data[0][yAxisName]; + let contextValue = dataSample; + if (yAxisType === "time") { + // to timestamp + const time = + typeof dataSample === "number" || typeof dataSample === "string" + ? new Date(dataSample).getTime() + : null; + if (time) contextValue = time; + } + return contextValue; +} + +LineChartTmpComp = class extends LineChartTmpComp { + private lastYAxisFormatContextVal?: JSONValue; + private lastColorContext?: JSONObject; + + updateContext(comp: this) { + // the context value of axis format + let resultComp = comp; + const data = comp.children.data.getView(); + const sampleSeries = comp.children.series.getView().find((s) => !s.getView().hide); + const yAxisContextValue = getYAxisFormatContextValue( + data, + comp.children.yConfig.children.yAxisType.getView(), + sampleSeries?.children.columnName.getView() + ); + if (yAxisContextValue !== comp.lastYAxisFormatContextVal) { + comp.lastYAxisFormatContextVal = yAxisContextValue; + resultComp = comp.setChild( + "yConfig", + comp.children.yConfig.reduce( + wrapChildAction( + "formatter", + AxisFormatterComp.changeContextDataAction({ value: yAxisContextValue }) + ) + ) + ); + } + // item color context + const colorContextVal = { + seriesName: sampleSeries?.children.seriesName.getView(), + value: yAxisContextValue, + }; + if ( + comp.children.chartConfig.children.comp.children.hasOwnProperty("itemColor") && + !_.isEqual(colorContextVal, comp.lastColorContext) + ) { + comp.lastColorContext = colorContextVal; + resultComp = resultComp.setChild( + "chartConfig", + comp.children.chartConfig.reduce( + wrapChildAction( + "comp", + wrapChildAction("itemColor", ItemColorComp.changeContextDataAction(colorContextVal)) + ) + ) + ); + } + return resultComp; + } + + override reduce(action: CompAction): this { + const comp = super.reduce(action); + if (action.type === CompActionTypes.UPDATE_NODES_V2) { + const newData = comp.children.data.getView(); + // data changes + if (comp.children.data !== this.children.data) { + setTimeout(() => { + // update x-axis value + const keys = getDataKeys(newData); + if (keys.length > 0 && !keys.includes(comp.children.xAxisKey.getView())) { + comp.children.xAxisKey.dispatch(changeValueAction(keys[0] || "")); + } + // pass to child series comp + comp.children.series.dispatchDataChanged(newData); + }, 0); + } + return this.updateContext(comp); + } + return comp; + } + + override autoHeight(): boolean { + return false; + } +}; + +let LineChartComp = withExposingConfigs(LineChartTmpComp, [ + depsConfig({ + name: "selectedPoints", + desc: trans("chart.selectedPointsDesc"), + depKeys: ["selectedPoints"], + func: (input) => { + return input.selectedPoints; + }, + }), + depsConfig({ + name: "lastInteractionData", + desc: trans("chart.lastInteractionDataDesc"), + depKeys: ["lastInteractionData"], + func: (input) => { + return input.lastInteractionData; + }, + }), + depsConfig({ + name: "data", + desc: trans("chart.dataDesc"), + depKeys: ["data", "mode"], + func: (input) =>[] , + }), + new NameConfig("title", trans("chart.titleDesc")), +]); + + +export const LineChartCompWithDefault = withDefault(LineChartComp, { + xAxisKey: "date", + series: [ + { + dataIndex: genRandomKey(), + seriesName: trans("chart.spending"), + columnName: "spending", + }, + { + dataIndex: genRandomKey(), + seriesName: trans("chart.budget"), + columnName: "budget", + }, + ], +}); diff --git a/client/packages/lowcoder-comps/src/comps/lineChartComp/lineChartConstants.tsx b/client/packages/lowcoder-comps/src/comps/lineChartComp/lineChartConstants.tsx new file mode 100644 index 000000000..2685f1972 --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/lineChartComp/lineChartConstants.tsx @@ -0,0 +1,315 @@ +import { + jsonControl, + stateComp, + toJSONObjectArray, + toObject, + BoolControl, + ColorControl, + withDefault, + StringControl, + NumberControl, + dropdownControl, + list, + eventHandlerControl, + valueComp, + withType, + uiChildren, + clickEvent, + toArray, + styleControl, + EchartDefaultTextStyle, + EchartDefaultChartStyle, + MultiCompBuilder, +} from "lowcoder-sdk"; +import { RecordConstructorToComp, RecordConstructorToView } from "lowcoder-core"; +import { BarChartConfig } from "../basicChartComp/chartConfigs/barChartConfig"; +import { XAxisConfig, YAxisConfig } from "../basicChartComp/chartConfigs/cartesianAxisConfig"; +import { LegendConfig } from "../basicChartComp/chartConfigs/legendConfig"; +import { EchartsLegendConfig } from "../basicChartComp/chartConfigs/echartsLegendConfig"; +import { EchartsLabelConfig } from "../basicChartComp/chartConfigs/echartsLabelConfig"; +import { LineChartConfig } from "../basicChartComp/chartConfigs/lineChartConfig"; +import { PieChartConfig } from "../basicChartComp/chartConfigs/pieChartConfig"; +import { ScatterChartConfig } from "../basicChartComp/chartConfigs/scatterChartConfig"; +import { SeriesListComp } from "./seriesComp"; +import { EChartsOption } from "echarts"; +import { i18nObjs, trans } from "i18n/comps"; +import { GaugeChartConfig } from "../basicChartComp/chartConfigs/gaugeChartConfig"; +import { FunnelChartConfig } from "../basicChartComp/chartConfigs/funnelChartConfig"; +import {EchartsTitleVerticalConfig} from "../chartComp/chartConfigs/echartsTitleVerticalConfig"; +import {EchartsTitleConfig} from "../basicChartComp/chartConfigs/echartsTitleConfig"; + +export const ChartTypeOptions = [ + { + label: trans("chart.bar"), + value: "bar", + }, + { + label: trans("chart.line"), + value: "line", + }, + { + label: trans("chart.scatter"), + value: "scatter", + }, + { + label: trans("chart.pie"), + value: "pie", + }, +] as const; + +export const UIEventOptions = [ + { + label: trans("chart.select"), + value: "select", + description: trans("chart.selectDesc"), + }, + { + label: trans("chart.unSelect"), + value: "unselect", + description: trans("chart.unselectDesc"), + }, +] as const; + +export const XAxisDirectionOptions = [ + { + label: trans("chart.horizontal"), + value: "horizontal", + }, + { + label: trans("chart.vertical"), + value: "vertical", + }, +] as const; + +export type XAxisDirectionType = ValueFromOption; + +export const noDataAxisConfig = { + animation: false, + xAxis: { + type: "category", + name: trans("chart.noData"), + nameLocation: "middle", + data: [], + axisLine: { + lineStyle: { + color: "#8B8FA3", + }, + }, + }, + yAxis: { + type: "value", + axisLabel: { + color: "#8B8FA3", + }, + splitLine: { + lineStyle: { + color: "#F0F0F0", + }, + }, + }, + tooltip: { + show: false, + }, + series: [ + { + data: [700], + type: "line", + itemStyle: { + opacity: 0, + }, + }, + ], +} as EChartsOption; + +export const noDataPieChartConfig = { + animation: false, + tooltip: { + show: false, + }, + legend: { + formatter: trans("chart.unknown"), + top: "bottom", + selectedMode: false, + }, + color: ["#B8BBCC", "#CED0D9", "#DCDEE6", "#E6E6EB"], + series: [ + { + type: "pie", + radius: "35%", + center: ["25%", "50%"], + silent: true, + label: { + show: false, + }, + data: [ + { + name: "1", + value: 70, + }, + { + name: "2", + value: 68, + }, + { + name: "3", + value: 48, + }, + { + name: "4", + value: 40, + }, + ], + }, + { + type: "pie", + radius: "35%", + center: ["75%", "50%"], + silent: true, + label: { + show: false, + }, + data: [ + { + name: "1", + value: 70, + }, + { + name: "2", + value: 68, + }, + { + name: "3", + value: 48, + }, + { + name: "4", + value: 40, + }, + ], + }, + ], +} as EChartsOption; + +const areaPiecesChildrenMap = { + color: ColorControl, + from: StringControl, + to: StringControl, + // unique key, for sort + dataIndex: valueComp(""), +}; +const AreaPiecesTmpComp = new MultiCompBuilder(areaPiecesChildrenMap, (props) => { + return props; +}) + .setPropertyViewFn((children: any) => + (<> + {children.color.propertyView({label: trans("lineChart.color")})} + {children.from.propertyView({label: trans("lineChart.from")})} + {children.to.propertyView({label: trans("lineChart.to")})} + ) + ) + .build(); + +export type ChartSize = { w: number; h: number }; + +export const getDataKeys = (data: Array) => { + if (!data) { + return []; + } + const dataKeys: Array = []; + data.slice(0, 50).forEach((d) => { + Object.keys(d).forEach((key) => { + if (!dataKeys.includes(key)) { + dataKeys.push(key); + } + }); + }); + return dataKeys; +}; + +const ChartOptionMap = { + bar: BarChartConfig, + line: LineChartConfig, + pie: PieChartConfig, + scatter: ScatterChartConfig, +}; + +const EchartsOptionMap = { + funnel: FunnelChartConfig, + gauge: GaugeChartConfig, +}; + +const ChartOptionComp = withType(ChartOptionMap, "line"); +const EchartsOptionComp = withType(EchartsOptionMap, "funnel"); +export type CharOptionCompType = keyof typeof ChartOptionMap; + +export const chartUiModeChildren = { + title: withDefault(StringControl, trans("echarts.defaultTitle")), + data: jsonControl(toJSONObjectArray, i18nObjs.defaultDataSource), + xAxisKey: valueComp(""), // x-axis, key from data + xAxisDirection: dropdownControl(XAxisDirectionOptions, "horizontal"), + xAxisData: jsonControl(toArray, []), + series: SeriesListComp, + xConfig: XAxisConfig, + yConfig: YAxisConfig, + legendConfig: LegendConfig, + chartConfig: ChartOptionComp, + areaPieces: list(AreaPiecesTmpComp), + animationDuration: withDefault(NumberControl, 1000), + onUIEvent: eventHandlerControl(UIEventOptions), +}; + +let chartJsonModeChildren: any = { + echartsOption: jsonControl(toObject, i18nObjs.defaultEchartsJsonOption), + echartsTitle: withDefault(StringControl, trans("echarts.defaultTitle")), + echartsLegendConfig: EchartsLegendConfig, + echartsLabelConfig: EchartsLabelConfig, + echartsConfig: EchartsOptionComp, + echartsTitleVerticalConfig: EchartsTitleVerticalConfig, + echartsTitleConfig:EchartsTitleConfig, + + left:withDefault(NumberControl,trans('chart.defaultLeft')), + right:withDefault(NumberControl,trans('chart.defaultRight')), + top:withDefault(NumberControl,trans('chart.defaultTop')), + bottom:withDefault(NumberControl,trans('chart.defaultBottom')), + + tooltip: withDefault(BoolControl, true), + legendVisibility: withDefault(BoolControl, true), +} + +if (EchartDefaultChartStyle && EchartDefaultTextStyle) { + chartJsonModeChildren = { + ...chartJsonModeChildren, + chartStyle: styleControl(EchartDefaultChartStyle, 'chartStyle'), + titleStyle: styleControl(EchartDefaultTextStyle, 'titleStyle'), + xAxisStyle: styleControl(EchartDefaultTextStyle, 'xAxis'), + yAxisStyle: styleControl(EchartDefaultTextStyle, 'yAxisStyle'), + legendStyle: styleControl(EchartDefaultTextStyle, 'legendStyle'), + } +} + +export type UIChartDataType = { + seriesName: string; + // coordinate chart + x?: any; + y?: any; + // pie or funnel + itemName?: any; + value?: any; +}; + +export type NonUIChartDataType = { + name: string; + value: any; +} + +export const lineChartChildrenMap = { + selectedPoints: stateComp>([]), + lastInteractionData: stateComp | NonUIChartDataType>({}), + onEvent: eventHandlerControl([clickEvent] as const), + ...chartUiModeChildren, + ...chartJsonModeChildren, +}; + +const chartUiChildrenMap = uiChildren(lineChartChildrenMap); +export type ChartCompPropsType = RecordConstructorToView; +export type ChartCompChildrenType = RecordConstructorToComp; diff --git a/client/packages/lowcoder-comps/src/comps/lineChartComp/lineChartPropertyView.tsx b/client/packages/lowcoder-comps/src/comps/lineChartComp/lineChartPropertyView.tsx new file mode 100644 index 000000000..5a67d8ecf --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/lineChartComp/lineChartPropertyView.tsx @@ -0,0 +1,187 @@ +import { changeChildAction, CompAction } from "lowcoder-core"; +import { ChartCompChildrenType, ChartTypeOptions,getDataKeys } from "./lineChartConstants"; +import { newSeries } from "./seriesComp"; +import { + CustomModal, + Dropdown, + hiddenPropertyView, + Option, + RedButton, + Section, + sectionNames, + controlItem, +} from "lowcoder-sdk"; +import { trans } from "i18n/comps"; + +export function lineChartPropertyView( + children: ChartCompChildrenType, + dispatch: (action: CompAction) => void +) { + const series = children.series.getView(); + const columnOptions = getDataKeys(children.data.getView()).map((key) => ({ + label: key, + value: key, + })); + + const uiModePropertyView = ( + <> +
+ {children.chartConfig.getPropertyView()} + {children.animationDuration.propertyView({label: trans("lineChart.animationDuration")})} + { + dispatch(changeChildAction("xAxisKey", value)); + }} + /> + {children.chartConfig.getView().subtype === "waterfall" && children.xAxisData.propertyView({ + label: "X-Label-Data" + })} +
+
+
+ {children.onUIEvent.propertyView({title: trans("chart.chartEventHandlers")})} +
+
+ {children.onEvent.propertyView()} +
+
+
+ {children.echartsTitleConfig.getPropertyView()} + {children.echartsTitleVerticalConfig.getPropertyView()} + {children.legendConfig.getPropertyView()} + {children.title.propertyView({ label: trans("chart.title") })} + {children.left.propertyView({ label: trans("chart.left"), tooltip: trans("echarts.leftTooltip") })} + {children.right.propertyView({ label: trans("chart.right"), tooltip: trans("echarts.rightTooltip") })} + {children.top.propertyView({ label: trans("chart.top"), tooltip: trans("echarts.topTooltip") })} + {children.bottom.propertyView({ label: trans("chart.bottom"), tooltip: trans("echarts.bottomTooltip") })} + {children.chartConfig.children.compType.getView() !== "pie" && ( + <> + {children.xAxisDirection.propertyView({ + label: trans("chart.xAxisDirection"), + radioButton: true, + })} + {children.xConfig.getPropertyView()} + {children.yConfig.getPropertyView()} + + )} + {hiddenPropertyView(children)} + {children.tooltip.propertyView({label: trans("echarts.tooltip"), tooltip: trans("echarts.tooltipTooltip")})} +
+
+ {children.chartStyle?.getPropertyView()} +
+
+ {children.titleStyle?.getPropertyView()} +
+
+ {children.xAxisStyle?.getPropertyView()} +
+
+ {children.yAxisStyle?.getPropertyView()} +
+
+ {children.legendStyle?.getPropertyView()} +
+
+ {children.data.propertyView({ + label: trans("chart.data"), + })} +
+ + ); + + const getChatConfigByMode = (mode: string) => { + switch(mode) { + case "ui": + return uiModePropertyView; + } + } + return ( + <> + {getChatConfigByMode(children.mode.getView())} + + ); +} diff --git a/client/packages/lowcoder-comps/src/comps/lineChartComp/lineChartUtils.ts b/client/packages/lowcoder-comps/src/comps/lineChartComp/lineChartUtils.ts new file mode 100644 index 000000000..64d640d75 --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/lineChartComp/lineChartUtils.ts @@ -0,0 +1,397 @@ +import { + CharOptionCompType, + ChartCompPropsType, + ChartSize, + noDataAxisConfig, + noDataPieChartConfig, +} from "comps/lineChartComp/lineChartConstants"; +import { getPieRadiusAndCenter } from "comps/basicChartComp/chartConfigs/pieChartConfig"; +import { EChartsOptionWithMap } from "../basicChartComp/reactEcharts/types"; +import _ from "lodash"; +import { chartColorPalette, isNumeric, JSONObject, loadScript } from "lowcoder-sdk"; +import { calcXYConfig } from "comps/basicChartComp/chartConfigs/cartesianAxisConfig"; +import Big from "big.js"; +import { googleMapsApiUrl } from "../basicChartComp/chartConfigs/chartUrls"; +import opacityToHex from "../../util/opacityToHex"; +import parseBackground from "../../util/gradientBackgroundColor"; +import {ba, s} from "@fullcalendar/core/internal-common"; +import {chartStyleWrapper, styleWrapper} from "../../util/styleWrapper"; + +export function transformData( + originData: JSONObject[], + xAxis: string, + seriesColumnNames: string[] +) { + // aggregate data by x-axis + const transformedData: JSONObject[] = []; + originData.reduce((prev, cur) => { + if (cur === null || cur === undefined) { + return prev; + } + const groupValue = cur[xAxis] as string; + if (!prev[groupValue]) { + // init as 0 + const initValue: any = {}; + seriesColumnNames.forEach((name) => { + initValue[name] = 0; + }); + prev[groupValue] = initValue; + transformedData.push(prev[groupValue]); + } + // remain the x-axis data + prev[groupValue][xAxis] = groupValue; + seriesColumnNames.forEach((key) => { + if (key === xAxis) { + return; + } else if (isNumeric(cur[key])) { + const bigNum = Big(cur[key]); + prev[groupValue][key] = bigNum.add(prev[groupValue][key]).toNumber(); + } else { + prev[groupValue][key] += 1; + } + }); + return prev; + }, {} as any); + return transformedData; +} + +const notAxisChartSet: Set = new Set(["pie"] as const); +const notAxisChartSubtypeSet: Set = new Set(["polar"] as const); +export const echartsConfigOmitChildren = [ + "hidden", + "selectedPoints", + "onUIEvent", + "mapInstance" +] as const; +type EchartsConfigProps = Omit; + + +export function isAxisChart(type: CharOptionCompType, polar: boolean) { + return !notAxisChartSet.has(type) && !polar; +} + +export function getSeriesConfig(props: EchartsConfigProps) { + let visibleSeries = props.series.filter((s) => !s.getView().hide); + if(props.chartConfig.subtype === "waterfall") { + const seriesOn = visibleSeries[0]; + const seriesPlaceholder = visibleSeries[0]; + visibleSeries = [seriesPlaceholder, seriesOn]; + } + const seriesLength = visibleSeries.length; + return visibleSeries.map((s, index) => { + if (isAxisChart(props.chartConfig.type, props.chartConfig.polarData.polar)) { + let encodeX: string, encodeY: string; + const horizontalX = props.xAxisDirection === "horizontal"; + let itemStyle = props.chartConfig.itemStyle; + + if (horizontalX) { + encodeX = props.xAxisKey; + encodeY = s.getView().columnName; + } else { + encodeX = s.getView().columnName; + encodeY = props.xAxisKey; + } + const markLineData = s.getView().markLines.map(line => ({type: line.getView().type})); + const markAreaData = s.getView().markAreas.map(area => ([{name: area.getView().name, [horizontalX?"xAxis":"yAxis"]: area.getView().from, label: { + position: horizontalX?"top":"right", + }}, {[horizontalX?"xAxis":"yAxis"]: area.getView().to}])); + return { + name: s.getView().seriesName, + selectedMode: "single", + select: { + itemStyle: { + borderColor: "#000", + }, + }, + step: s.getView().step, + encode: { + x: encodeX, + y: encodeY, + }, + markLine: { + data: markLineData, + }, + markArea: { + itemStyle: { + color: 'rgba(255, 173, 177, 0.4)', + }, + data: markAreaData, + }, + // each type of chart's config + ...props.chartConfig, + itemStyle: itemStyle, + label: { + ...props.chartConfig.label, + ...(!horizontalX && { position: "outside" }), + }, + }; + } else { + // pie + const radiusAndCenter = getPieRadiusAndCenter(seriesLength, index, props.chartConfig); + return { + ...props.chartConfig, + radius: radiusAndCenter.radius, + center: radiusAndCenter.center, + name: s.getView().seriesName, + selectedMode: "single", + encode: { + itemName: props.xAxisKey, + value: s.getView().columnName, + }, + }; + } + }); +} + +// https://echarts.apache.org/en/option.html +export function getEchartsConfig( + props: EchartsConfigProps, + chartSize?: ChartSize, + theme?: any, +): EChartsOptionWithMap { + // axisChart + const axisChart = isAxisChart(props.chartConfig.type, props.chartConfig.polarData.polar); + const gridPos = { + left: `${props?.left}%`, + right: `${props?.right}%`, + bottom: `${props?.bottom}%`, + top: `${props?.top}%`, + }; + + let config: any = { + title: { + text: props.title, + top: props.echartsTitleVerticalConfig.top, + left:props.echartsTitleConfig.top, + textStyle: { + ...styleWrapper(props?.titleStyle, theme?.titleStyle) + } + }, + backgroundColor: parseBackground( props?.chartStyle?.background || theme?.chartStyle?.backgroundColor || "#FFFFFF"), + legend: { + ...props.legendConfig, + textStyle: { + ...styleWrapper(props?.legendStyle, theme?.legendStyle, 15) + } + }, + tooltip: props.tooltip && { + trigger: "axis", + axisPointer: { + type: "line", + lineStyle: { + color: "rgba(0,0,0,0.2)", + width: 2, + type: "solid" + } + } + }, + grid: { + ...gridPos, + containLabel: true, + }, + animationDuration: props.animationDuration, + }; + if (props.areaPieces.length > 0) { + config.visualMap = { + type: 'piecewise', + show: false, + dimension: 0, + seriesIndex: 0, + pieces: props.areaPieces?.filter(p => p.getView().from && p.getView().to && p.getView().color)?.map(p => ( + { + ...(p.getView().from?{min: parseInt(p.getView().from)}:{}), + ...(p.getView().to?{max: parseInt(p.getView().to)}:{}), + ...(p.getView().color?{color: p.getView().color}:{}), + } + )) + } + } + if(props.chartConfig.race) { + config = { + ...config, + // Disable init animation. + animationDuration: 0, + animationDurationUpdate: 2000, + animationEasing: 'linear', + animationEasingUpdate: 'linear', + } + } + if (props.data.length <= 0) { + // no data + return { + ...config, + ...(axisChart ? noDataAxisConfig : noDataPieChartConfig), + }; + } + const yAxisConfig = props.yConfig(); + const seriesColumnNames = props.series + .filter((s) => !s.getView().hide) + .map((s) => s.getView().columnName); + // y-axis is category and time, data doesn't need to aggregate + let transformedData = + yAxisConfig.type === "category" || yAxisConfig.type === "time" ? props.data : transformData(props.data, props.xAxisKey, seriesColumnNames); + + if(props.chartConfig.polarData.polar) { + config = { + ...config, + polar: { + radius: [props.chartConfig.polarData.polarRadiusStart, props.chartConfig.polarData.polarRadiusEnd], + }, + radiusAxis: { + type: props.chartConfig.polarData.polarIsTangent?'category':undefined, + data: props.chartConfig.polarData.polarIsTangent && props.chartConfig.polarData.labelData.length!==0?props.chartConfig.polarData.labelData:undefined, + max: props.chartConfig.polarData.polarIsTangent?undefined:props.chartConfig.polarData.radiusAxisMax || undefined, + }, + angleAxis: { + type: props.chartConfig.polarData.polarIsTangent?undefined:'category', + data: !props.chartConfig.polarData.polarIsTangent && props.chartConfig.polarData.labelData.length!==0?props.chartConfig.polarData.labelData:undefined, + max: props.chartConfig.polarData.polarIsTangent?props.chartConfig.polarData.radiusAxisMax || undefined:undefined, + startAngle: props.chartConfig.polarData.polarStartAngle, + endAngle: props.chartConfig.polarData.polarEndAngle, + }, + } + } + + config = { + ...config, + dataset: [ + { + source: transformedData, + sourceHeader: false, + }, + ], + series: getSeriesConfig(props).map(series => ({ + ...series, + encode: { + ...series.encode, + y: series.name, + }, + itemStyle: { + ...series.itemStyle, + // ...chartStyleWrapper(props?.chartStyle, theme?.chartStyle) + }, + lineStyle: { + ...chartStyleWrapper(props?.chartStyle, theme?.chartStyle) + }, + data: transformedData.map((i: any) => i[series.name]) + })), + }; + if (axisChart) { + // pure chart's size except the margin around + let chartRealSize; + if (chartSize) { + const rightSize = + typeof gridPos.right === "number" + ? gridPos.right + : (chartSize.w * parseFloat(gridPos.right)) / 100.0; + chartRealSize = { + // actually it's self-adaptive with the x-axis label on the left, not that accurate but work + w: chartSize.w - gridPos.left - rightSize, + // also self-adaptive on the bottom + h: chartSize.h - gridPos.top - gridPos.bottom, + right: rightSize, + }; + } + const finalXyConfig = calcXYConfig( + props.xConfig, + yAxisConfig, + props.xAxisDirection, + transformedData.map((d) => d[props.xAxisKey]), + chartRealSize + ); + config = { + ...config, + // @ts-ignore + xAxis: { + ...finalXyConfig.xConfig, + axisLabel: { + ...styleWrapper(props?.xAxisStyle, theme?.xAxisStyle, 11) + }, + data: finalXyConfig.xConfig.type === "category" && (props.xAxisData as []).length!==0?props?.xAxisData:transformedData.map((i: any) => i[props.xAxisKey]), + }, + // @ts-ignore + yAxis: { + ...finalXyConfig.yConfig, + axisLabel: { + ...styleWrapper(props?.yAxisStyle, theme?.yAxisStyle, 11) + }, + data: finalXyConfig.yConfig.type === "category" && (props.xAxisData as []).length!==0?props?.xAxisData:transformedData.map((i: any) => i[props.xAxisKey]), + }, + }; + + if(props.chartConfig.race) { + config = { + ...config, + xAxis: { + ...config.xAxis, + animationDuration: 300, + animationDurationUpdate: 300 + }, + yAxis: { + ...config.yAxis, + animationDuration: 300, + animationDurationUpdate: 300 + }, + } + } + } + + // log.log("Echarts transformedData and config", transformedData, config); + return config; +} + +export function getSelectedPoints(param: any, option: any) { + const series = option.series; + const dataSource = _.isArray(option.dataset) && option.dataset[0]?.source; + if (series && dataSource) { + return param.selected.flatMap((selectInfo: any) => { + const seriesInfo = series[selectInfo.seriesIndex]; + if (!seriesInfo || !seriesInfo.encode) { + return []; + } + return selectInfo.dataIndex.map((index: any) => { + const commonResult = { + seriesName: seriesInfo.name, + }; + if (seriesInfo.encode.itemName && seriesInfo.encode.value) { + return { + ...commonResult, + itemName: dataSource[index][seriesInfo.encode.itemName], + value: dataSource[index][seriesInfo.encode.value], + }; + } else { + return { + ...commonResult, + x: dataSource[index][seriesInfo.encode.x], + y: dataSource[index][seriesInfo.encode.y], + }; + } + }); + }); + } + return []; +} + +export function loadGoogleMapsScript(apiKey: string) { + const mapsUrl = `${googleMapsApiUrl}?key=${apiKey}`; + const scripts = document.getElementsByTagName('script'); + // is script already loaded + let scriptIndex = _.findIndex(scripts, (script) => script.src.endsWith(mapsUrl)); + if(scriptIndex > -1) { + return scripts[scriptIndex]; + } + // is script loaded with diff api_key, remove the script and load again + scriptIndex = _.findIndex(scripts, (script) => script.src.startsWith(googleMapsApiUrl)); + if(scriptIndex > -1) { + scripts[scriptIndex].remove(); + } + + const script = document.createElement("script"); + script.type = "text/javascript"; + script.src = mapsUrl; + script.async = true; + script.defer = true; + window.document.body.appendChild(script); + + return script; +} diff --git a/client/packages/lowcoder-comps/src/comps/lineChartComp/seriesComp.tsx b/client/packages/lowcoder-comps/src/comps/lineChartComp/seriesComp.tsx new file mode 100644 index 000000000..5a61774f5 --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/lineChartComp/seriesComp.tsx @@ -0,0 +1,280 @@ +import { + BoolControl, + StringControl, + list, + isNumeric, + genRandomKey, + Dropdown, + Option, + RedButton, + CustomModal, + MultiCompBuilder, + valueComp, + dropdownControl, +} from "lowcoder-sdk"; +import { trans } from "i18n/comps"; + +import { ConstructorToComp, ConstructorToDataType, ConstructorToView } from "lowcoder-core"; +import { CompAction, CustomAction, customAction, isMyCustomAction } from "lowcoder-core"; + +export type SeriesCompType = ConstructorToComp; +export type RawSeriesCompType = ConstructorToView; +type SeriesDataType = ConstructorToDataType; +type MarkLineDataType = ConstructorToDataType; + +type ActionDataType = { + type: "chartDataChanged"; + chartData: Array; +}; + +export function newSeries(name: string, columnName: string): SeriesDataType { + return { + seriesName: name, + columnName: columnName, + dataIndex: genRandomKey(), + }; +} + +export function newMarkLine(type: string): MarkLineDataType { + return { + type, + dataIndex: genRandomKey(), + }; +} + +export const MarkLineTypeOptions = [ + { + label: trans("lineChart.max"), + value: "max", + }, + { + label: trans("lineChart.average"), + value: "average", + }, + { + label: trans("lineChart.min"), + value: "min", + }, +] as const; + +export const StepOptions = [ + { + label: trans("lineChart.none"), + value: "", + }, + { + label: trans("lineChart.start"), + value: "start", + }, + { + label: trans("lineChart.middle"), + value: "middle", + }, + { + label: trans("lineChart.end"), + value: "end", + }, +] as const; + +const valToLabel = (val) => MarkLineTypeOptions.find(o => o.value === val)?.label || ""; +const markLinesChildrenMap = { + type: dropdownControl(MarkLineTypeOptions, "max"), + // unique key, for sort + dataIndex: valueComp(""), +}; +const MarkLinesTmpComp = new MultiCompBuilder(markLinesChildrenMap, (props) => { + return props; +}) + .setPropertyViewFn((children: any) => { + return <>{children.type.propertyView({label: trans("lineChart.type")})}; + }) + .build(); +const markAreasChildrenMap = { + name: StringControl, + from: StringControl, + to: StringControl, + // unique key, for sort + dataIndex: valueComp(""), +}; +const MarkAreasTmpComp = new MultiCompBuilder(markAreasChildrenMap, (props) => { + return props; +}) + .setPropertyViewFn((children: any) => + (<> + {children.name.propertyView({label: trans("lineChart.name")})} + {children.from.propertyView({label: trans("lineChart.from")})} + {children.to.propertyView({label: trans("lineChart.to")})} + ) + ) + .build(); + + +export function newMarkArea(): MarkLineDataType { + return { + dataIndex: genRandomKey(), + }; +} + +const seriesChildrenMap = { + columnName: StringControl, + seriesName: StringControl, + markLines: list(MarkLinesTmpComp), + markAreas: list(MarkAreasTmpComp), + hide: BoolControl, + // unique key, for sort + dataIndex: valueComp(""), + step: dropdownControl(StepOptions, ""), +}; + +const SeriesTmpComp = new MultiCompBuilder(seriesChildrenMap, (props) => { + return props; +}) + .setPropertyViewFn(() => { + return <>; + }) + .build(); + +class SeriesComp extends SeriesTmpComp { + getPropertyViewWithData(columnOptions: OptionsType): React.ReactNode { + return ( + <> + {this.children.seriesName.propertyView({ + label: trans("chart.seriesName"), + })} + { + this.children.columnName.dispatchChangeValueAction(value); + }} + /> + {this.children.step.propertyView({ + label: trans("lineChart.step"), + })} +