diff --git a/client/packages/lowcoder-comps/package.json b/client/packages/lowcoder-comps/package.json index e402564b9..45c1eb728 100644 --- a/client/packages/lowcoder-comps/package.json +++ b/client/packages/lowcoder-comps/package.json @@ -1,6 +1,6 @@ { "name": "lowcoder-comps", - "version": "2.4.3", + "version": "2.4.4", "type": "module", "license": "MIT", "dependencies": { @@ -74,6 +74,14 @@ "h": 40 } }, + "basicChart": { + "name": "Basic 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/basicChartComp/chartComp.tsx b/client/packages/lowcoder-comps/src/comps/basicChartComp/chartComp.tsx new file mode 100644 index 000000000..e2eb34577 --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/basicChartComp/chartComp.tsx @@ -0,0 +1,295 @@ +import { + changeChildAction, + changeValueAction, + CompAction, + CompActionTypes, + wrapChildAction, +} from "lowcoder-core"; +import { AxisFormatterComp, EchartsAxisType } from "./chartConfigs/cartesianAxisConfig"; +import { chartChildrenMap, ChartSize, getDataKeys } from "./chartConstants"; +import { chartPropertyView } from "./chartPropertyView"; +import _ from "lodash"; +import { useContext, useEffect, useMemo, useRef, useState } from "react"; +import ReactResizeDetector from "react-resize-detector"; +import ReactECharts from "./reactEcharts"; +import { + childrenToProps, + depsConfig, + genRandomKey, + NameConfig, + UICompBuilder, + withDefault, + withExposingConfigs, + withMethodExposing, + withViewFn, + ThemeContext, + chartColorPalette, + getPromiseAfterDispatch, + dropdownControl +} from "lowcoder-sdk"; +import { getEchartsLocale, trans } from "i18n/comps"; +import { ItemColorComp } from "comps/chartComp/chartConfigs/lineChartConfig"; +import { + echartsConfigOmitChildren, + getEchartsConfig, + getSelectedPoints, +} from "comps/chartComp/chartUtils"; +import 'echarts-extension-gmap'; +import log from "loglevel"; + +let clickEventCallback = () => {}; + +const chartModeOptions = [ + { + label: trans("chart.UIMode"), + value: "ui", + } +] as const; + +let BasicChartTmpComp = (function () { + return new UICompBuilder({mode:dropdownControl(chartModeOptions,'ui'),...chartChildrenMap}, () => null) + .setPropertyViewFn(chartPropertyView) + .build(); +})(); + +BasicChartTmpComp = withViewFn(BasicChartTmpComp, (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(() => { + // 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 option = useMemo(() => { + return getEchartsConfig( + childrenToProps(echartsConfigChildren) as ToViewReturn, + chartSize + ); + }, [chartSize, ...Object.values(echartsConfigChildren)]); + + useEffect(() => { + comp.children.mapInstance.dispatch(changeValueAction(null, false)) + }, [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} + theme={themeConfig} + 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; +} + +BasicChartTmpComp = class extends BasicChartTmpComp { + 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 BasicChartComp = withExposingConfigs(BasicChartTmpComp, [ + 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) => input.data, + }), + new NameConfig("title", trans("chart.titleDesc")), +]); + +export const BasicChartCompWithDefault = withDefault(BasicChartComp, { + 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/basicChartComp/chartConfigs/barChartConfig.tsx b/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/barChartConfig.tsx new file mode 100644 index 000000000..6c91fe252 --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/barChartConfig.tsx @@ -0,0 +1,51 @@ +import { + BoolControl, + dropdownControl, + MultiCompBuilder, + showLabelPropertyView, +} from "lowcoder-sdk"; +import { BarSeriesOption } from "echarts"; +import { trans } from "i18n/comps"; + +const BarTypeOptions = [ + { + label: trans("chart.basicBar"), + value: "basicBar", + }, + { + label: trans("chart.stackedBar"), + value: "stackedBar", + }, +] as const; + +export const BarChartConfig = (function () { + return new MultiCompBuilder( + { + showLabel: BoolControl, + type: dropdownControl(BarTypeOptions, "basicBar"), + }, + (props): BarSeriesOption => { + const config: BarSeriesOption = { + type: "bar", + label: { + show: props.showLabel, + position: "top", + }, + }; + if (props.type === "stackedBar") { + config.stack = "stackValue"; + } + return config; + } + ) + .setPropertyViewFn((children) => ( + <> + {showLabelPropertyView(children)} + {children.type.propertyView({ + label: trans("chart.barType"), + radioButton: true, + })} + + )) + .build(); +})(); diff --git a/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/candleStickChartConfig.tsx b/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/candleStickChartConfig.tsx new file mode 100644 index 000000000..7b7b5b103 --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/candleStickChartConfig.tsx @@ -0,0 +1,35 @@ +import { + BoolControl, + MultiCompBuilder, + showLabelPropertyView, +} from "lowcoder-sdk"; +import { CandlestickSeriesOption } from "echarts"; +import { trans } from "i18n/comps"; + +export const CandleStickChartConfig = (function () { + return new MultiCompBuilder( + { + showLabel: BoolControl, + }, + (props): CandlestickSeriesOption => { + const config: CandlestickSeriesOption = { + type: "candlestick", + label: { + show: props.showLabel, + position: "top", + }, + }; + return config; + } + ) + .setPropertyViewFn((children) => ( + <> + {showLabelPropertyView(children)} + {children.type.propertyView({ + label: trans("candleStickChart.candleStickType"), + radioButton: true, + })} + + )) + .build(); +})(); diff --git a/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/cartesianAxisConfig.tsx b/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/cartesianAxisConfig.tsx new file mode 100644 index 000000000..da4d70221 --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/cartesianAxisConfig.tsx @@ -0,0 +1,307 @@ +import { XAXisComponentOption, YAXisComponentOption } from "echarts"; +import { ChartSize, XAxisDirectionType } from "../chartConstants"; +import { i18n } from "lowcoder-core"; +import { + MultiCompBuilder, + withContext, + NumberControl, + StringControl, + dropdownControl, + JSONValue, + isNumeric, +} from "lowcoder-sdk"; +import { i18nObjs, trans } from "i18n/comps"; +import _, { isNil } from "lodash"; +import { xAxisTypeUrl } from "./chartUrls"; + +const XAxisTypeOptions = [ + { + label: trans("chart.auto"), + value: "default", + }, + { + label: trans("chart.categoryAxis"), + value: "category", + }, + { + label: trans("chart.valueAxis"), + value: "value", + }, + { + label: trans("chart.timeAxis"), + value: "time", + }, + { + label: trans("chart.logAxis"), + value: "log", + }, +] as const; + +const YAxisTypeOptions = [ + { + label: trans("chart.valueAxis"), + value: "value", + }, + { + label: trans("chart.categoryAxis"), + value: "category", + }, + { + label: trans("chart.timeAxis"), + value: "time", + }, + { + label: trans("chart.logAxis"), + value: "log", + }, +] as const; + +export type EchartsAxisType = "category" | "value" | "time" | "log"; + +const axisCommonMap = { + axisName: StringControl, + logBase: NumberControl, +}; + +export const AxisFormatterComp = withContext( + new MultiCompBuilder({ value: StringControl }, (props) => props.value) + .setPropertyViewFn((children) => + children.value.propertyView({ + label: trans("chart.yAxisDataFormat"), + placeholder: "{{value}}", + tooltip: trans("chart.yAxisDataFormatTooltip"), + }) + ) + .build(), + ["value"] as const +); + +export const XAxisConfig = (function () { + return new MultiCompBuilder( + { + ...axisCommonMap, + type: dropdownControl(XAxisTypeOptions, "default"), + }, + (props): XAXisComponentOption => { + const config: XAXisComponentOption = { + name: props.axisName, + nameGap: 22, + // @ts-ignore + nameLocation: "middle", + }; + if (props.type !== "default") { + // don't assign value for default value, compute it in the end + config.type = props.type; + } + return config; + } + ) + .setPropertyViewFn((children) => ( + <> + {children.axisName.propertyView({ + label: trans("chart.xAxisName"), + })} + {children.type.propertyView({ + label: trans("chart.xAxisType"), + tooltip: ( + <> + {trans("chart.xAxisTypeTooltip")} + + {trans("chart.xAxisType")} + + + ), + })} + {children.type.getView() === "log" && + children.logBase.propertyView({ + label: trans("chart.logBase"), + })} + + )) + .build(); +})(); + +export const YAxisConfig = (function () { + return new MultiCompBuilder( + { + ...axisCommonMap, + // the old data has "type" field with default value "category". change field name to "yAxisType" for compatibility + yAxisType: dropdownControl(YAxisTypeOptions, "value"), + formatter: AxisFormatterComp, + }, + (props) => () => { + const config: YAXisComponentOption = { + name: props.axisName, + type: props.yAxisType, + nameTextStyle: { + align: "left", + }, + }; + const numberFormat = new Intl.NumberFormat(i18n.locales, { + notation: "compact", + }); + (config.axisLabel as any) = { + formatter: (value: string | number) => { + const res = (props.formatter as any)({ value: value }); + if (!isNil(res) && res !== "") { + return res; + } + if ( + (props.yAxisType === "value" || props.yAxisType === "log") && + typeof value === "number" + ) { + return numberFormat.format(value); + } + return value + ""; + }, + }; + if (props.yAxisType === "log") { + (config as any).logBase = props.logBase || 10; + } + return config; + } + ) + .setPropertyViewFn((children) => ( + <> + {children.axisName.propertyView({ + label: trans("chart.yAxisName"), + })} + {children.yAxisType.propertyView({ + label: trans("chart.yAxisType"), + })} + {children.yAxisType.getView() === "log" && + children.logBase.propertyView({ + label: trans("chart.logBase"), + })} + {children.formatter.getPropertyView()} + + )) + .build(); +})(); + +function calcXAxisType(xAxisData: Array): EchartsAxisType { + if (!xAxisData || xAxisData.length <= 0) { + return "category"; + } + const sampleData = xAxisData[0]; + if (!sampleData) { + return "category"; + } + if (isNumeric(sampleData)) { + return "value"; + } else if (!isNaN(new Date(sampleData.toString()).getDate())) { + return "time"; + } else { + return "category"; + } +} + +const dateInterval = { + year: 3600 * 24 * 1000 * 365, + month: 3600 * 24 * 1000 * 28, + day: 3600 * 24 * 1000, +}; + +function calcTimeInterval(xAxisData: Array) { + const minIntervals = xAxisData.map((data) => { + if (!data) { + // 1 is echarts default value, to make sure axis tick is integer + return 1; + } + const dataLen = data.toString().length; + if (dataLen === 4) { + // year 2022 + return dateInterval.year; + } else if (dataLen === 6 || dataLen === 7) { + // month 2022-01 222201 + return dateInterval.month; + } else if (dataLen === 10 || dataLen === 8) { + // day 2022-01-01 20220101 + return dateInterval.day; + } else { + return 1; + } + }); + return _.min(minIntervals); +} + +let measureCanvas: HTMLCanvasElement; + +// calculate x-axis text width +function getXAxisDataLength(xAxisData: Array) { + const canvas = measureCanvas || (measureCanvas = document.createElement("canvas")); + const context = canvas.getContext("2d"); + if (!context) { + return []; + } + // echarts default font + context.font = "normal 12px sans-serif"; + return xAxisData.map((d) => (d ? context.measureText(d.toString()).width + 2 : 0)); +} + +export function calcXYConfig( + xConfig: XAXisComponentOption, + yConfig: YAXisComponentOption, + xAxisDirection: XAxisDirectionType, + xAxisData: Array, + chartSize?: ChartSize & { right: number } +) { + const resXConfig = { ...xConfig }; + const resYConfig = { ...yConfig }; + + if (!resXConfig.type) { + // simple calculate x-axis type + resXConfig.type = calcXAxisType(xAxisData); + } + // x-axis label style adaptive + if (resXConfig.type === "category" && chartSize) { + const xAxisDataLenList = getXAxisDataLength(xAxisData); + // get x-axis single data's max width + const maxDataWidth = _.max(xAxisDataLenList); + const lastDataWidth = xAxisDataLenList[xAxisDataLenList.length - 1]; + // grid width + let eachDataWidth = chartSize.w / xAxisData.length; + let rotate = 0; + let labelWidth = maxDataWidth; + // rotate when width is not enough + if (maxDataWidth && eachDataWidth < maxDataWidth && xAxisDirection === "horizontal") { + labelWidth = Math.min(maxDataWidth, 150); + // vertical rotate 0.87 => sin(60) when exceeding the right boundary + const verticalRotate = + lastDataWidth && lastDataWidth * 0.87 > eachDataWidth / 2 + chartSize.right; + rotate = verticalRotate ? 270 : 330; + // to keep x-axis name under label, nameGap is related to label rotation angle + resXConfig.nameGap = verticalRotate ? labelWidth + 5 : labelWidth / 2 + 10; + } else if (xAxisDirection === "vertical" && maxDataWidth) { + // vertical direction + resXConfig.nameGap = maxDataWidth + 10; + } + resXConfig.axisLabel = { + interval: 0, + width: labelWidth, + // @ts-ignore + overflow: "truncate", + rotate: rotate, + }; + } else if (resXConfig.type === "time") { + (resXConfig as any).minInterval = calcTimeInterval(xAxisData); + const timeXAxisLabel = i18nObjs.timeXAxisLabel; + if (timeXAxisLabel) { + resXConfig.axisLabel = timeXAxisLabel; + } + } + if (xAxisDirection === "vertical") { + resYConfig.nameLocation = "middle"; + resYConfig.nameGap = 25; + } + + return xAxisDirection === "horizontal" + ? { + xConfig: resXConfig, + yConfig: resYConfig, + } + : { + xConfig: resYConfig, + yConfig: resXConfig, + }; +} diff --git a/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/chartUrls.tsx b/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/chartUrls.tsx new file mode 100644 index 000000000..ef8ada4b0 --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/chartUrls.tsx @@ -0,0 +1,9 @@ +import { language } from "i18n/comps"; + +const echartsUrlLocale = language === "zh" ? "zh" : "en"; +export const optionUrl = `https://echarts.apache.org/${echartsUrlLocale}/option.html`; +export const examplesUrl = `https://echarts.apache.org/examples/${echartsUrlLocale}/index.html`; +export const xAxisTypeUrl = `${optionUrl}#xAxis.type`; +export const googleMapsApiUrl = `https://maps.googleapis.com/maps/api/js`; +export const mapOptionUrl = `https://github.com/plainheart/echarts-extension-gmap`; +export const mapExamplesUrl = `https://codepen.io/plainheart/pen/VweLGbR`; \ No newline at end of file diff --git a/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/echartsLabelConfig.tsx b/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/echartsLabelConfig.tsx new file mode 100644 index 000000000..64b808e01 --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/echartsLabelConfig.tsx @@ -0,0 +1,49 @@ +import { + AlignClose, + AlignRight, + AlignLeft, + dropdownControl, + MultiCompBuilder, +} from "lowcoder-sdk"; +import { LegendComponentOption } from "echarts"; +import { trans } from "i18n/comps"; + +const LabelPositionOptions = [ + { + label: , + value: "inside", + }, + { + label: , + value: "right", + }, + { + label: , + value: "left", + }, +] as const; + +export const EchartsLabelConfig = (function () { + return new MultiCompBuilder( + { + position: dropdownControl(LabelPositionOptions, "inside"), + }, + (props): LegendComponentOption => { + const config: LegendComponentOption = { + top: "inside", + type: "scroll", + }; + config.top = props.position + return config; + } + ) + .setPropertyViewFn((children) => ( + <> + {children.position.propertyView({ + label: trans("echarts.labelPosition"), + radioButton: true, + })} + + )) + .build(); +})(); diff --git a/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/echartsLegendConfig.tsx b/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/echartsLegendConfig.tsx new file mode 100644 index 000000000..41fb166fb --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/echartsLegendConfig.tsx @@ -0,0 +1,44 @@ +import { + AlignBottom, + AlignTop, + dropdownControl, + MultiCompBuilder, +} from "lowcoder-sdk"; +import { LegendComponentOption } from "echarts"; +import { trans } from "i18n/comps"; + +const LegendPositionOptions = [ + { + label: , + value: "bottom", + }, + { + label: , + value: "top", + }, +] as const; + +export const EchartsLegendConfig = (function () { + return new MultiCompBuilder( + { + position: dropdownControl(LegendPositionOptions, "bottom"), + }, + (props): LegendComponentOption => { + const config: LegendComponentOption = { + top: "bottom", + type: "scroll", + }; + config.top = props.position + return config; + } + ) + .setPropertyViewFn((children) => ( + <> + {children.position.propertyView({ + label: trans("echarts.legendPosition"), + radioButton: true, + })} + + )) + .build(); +})(); diff --git a/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/echartsTitleConfig.tsx b/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/echartsTitleConfig.tsx new file mode 100644 index 000000000..a9305de25 --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/echartsTitleConfig.tsx @@ -0,0 +1,49 @@ +import { + AlignClose, + AlignRight, + AlignLeft, + dropdownControl, + MultiCompBuilder, +} from "lowcoder-sdk"; +import { LegendComponentOption } from "echarts"; +import { trans } from "i18n/comps"; + +const TitlePositionOptions = [ + { + label: , + value: "center", + }, + { + label: , + value: "right", + }, + { + label: , + value: "left", + }, +] as const; + +export const EchartsTitleConfig = (function () { + return new MultiCompBuilder( + { + position: dropdownControl(TitlePositionOptions, "center"), + }, + (props): LegendComponentOption => { + const config: LegendComponentOption = { + top: "center", + type: "scroll", + }; + config.top = props.position + return config; + } + ) + .setPropertyViewFn((children) => ( + <> + {children.position.propertyView({ + label: trans("echarts.titlePosition"), + radioButton: true, + })} + + )) + .build(); +})(); diff --git a/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/funnelChartConfig.tsx b/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/funnelChartConfig.tsx new file mode 100644 index 000000000..84d3f4691 --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/funnelChartConfig.tsx @@ -0,0 +1,35 @@ +import { + BoolControl, + MultiCompBuilder, + showLabelPropertyView, +} from "lowcoder-sdk"; +import { FunnelSeriesOption } from "echarts"; +import { trans } from "i18n/comps"; + +export const FunnelChartConfig = (function () { + return new MultiCompBuilder( + { + showLabel: BoolControl, + }, + (props): FunnelSeriesOption => { + const config: FunnelSeriesOption = { + type: "funnel", + label: { + show: props.showLabel, + position: "top", + }, + }; + return config; + } + ) + .setPropertyViewFn((children) => ( + <> + {showLabelPropertyView(children)} + {children.type.propertyView({ + label: trans("funnelChart.funnelType"), + radioButton: true, + })} + + )) + .build(); +})(); diff --git a/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/gaugeChartConfig.tsx b/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/gaugeChartConfig.tsx new file mode 100644 index 000000000..a72c96cbb --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/gaugeChartConfig.tsx @@ -0,0 +1,31 @@ +import { + BoolControl, + MultiCompBuilder, + showLabelPropertyView, +} from "lowcoder-sdk"; +import { GaugeSeriesOption } from "echarts"; +import { trans } from "i18n/comps"; + +export const GaugeChartConfig = (function () { + return new MultiCompBuilder( + { + showLabel: BoolControl, + }, + (props): GaugeSeriesOption => { + const config: GaugeSeriesOption = { + type: "gauge", + }; + return config; + } + ) + .setPropertyViewFn((children) => ( + <> + {showLabelPropertyView(children)} + {children.type.propertyView({ + label: trans("gaugeChart.gaugeType"), + radioButton: true, + })} + + )) + .build(); +})(); diff --git a/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/graphChartConfig.tsx b/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/graphChartConfig.tsx new file mode 100644 index 000000000..dbc23403e --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/graphChartConfig.tsx @@ -0,0 +1,31 @@ +import { + BoolControl, + MultiCompBuilder, + showLabelPropertyView, +} from "lowcoder-sdk"; +import { GraphSeriesOption } from "echarts"; +import { trans } from "i18n/comps"; + +export const GraphChartConfig = (function () { + return new MultiCompBuilder( + { + showLabel: BoolControl, + }, + (props): GraphSeriesOption => { + const config: GraphSeriesOption = { + type: "graph", + }; + return config; + } + ) + .setPropertyViewFn((children) => ( + <> + {showLabelPropertyView(children)} + {children.type.propertyView({ + label: trans("graphChart.graphType"), + radioButton: true, + })} + + )) + .build(); +})(); diff --git a/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/heatmapChartConfig.tsx b/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/heatmapChartConfig.tsx new file mode 100644 index 000000000..cbebb6410 --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/heatmapChartConfig.tsx @@ -0,0 +1,31 @@ +import { + BoolControl, + MultiCompBuilder, + showLabelPropertyView, +} from "lowcoder-sdk"; +import { HeatmapSeriesOption } from "echarts"; +import { trans } from "i18n/comps"; + +export const HeatmapChartConfig = (function () { + return new MultiCompBuilder( + { + showLabel: BoolControl, + }, + (props): HeatmapSeriesOption => { + const config: HeatmapSeriesOption = { + type: "heatmap", + }; + return config; + } + ) + .setPropertyViewFn((children) => ( + <> + {showLabelPropertyView(children)} + {children.type.propertyView({ + label: trans("heatmapChart.heatmapType"), + radioButton: true, + })} + + )) + .build(); +})(); diff --git a/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/legendConfig.tsx b/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/legendConfig.tsx new file mode 100644 index 000000000..1384c88d8 --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/legendConfig.tsx @@ -0,0 +1,55 @@ +import { + AlignBottom, + AlignClose, + AlignRight, + dropdownControl, + MultiCompBuilder, +} from "lowcoder-sdk"; +import { LegendComponentOption } from "echarts"; +import { trans } from "i18n/comps"; + +const LegendPositionOptions = [ + { + label: , + value: "bottom", + }, + { + label: , + value: "right", + }, + { + label: , + value: "close", + }, +] as const; + +export const LegendConfig = (function () { + return new MultiCompBuilder( + { + position: dropdownControl(LegendPositionOptions, "bottom"), + }, + (props): LegendComponentOption => { + const config: LegendComponentOption = { + top: "bottom", + type: "scroll", + }; + if (props.position === "right") { + config.top = "center"; + config.left = "right"; + config.orient = "vertical"; + } else if (props.position === "close") { + config.show = false; + } + return config; + } + ) + .setPropertyViewFn((children) => ( + <> + {children.position.propertyView({ + label: trans("chart.legendPosition"), + radioButton: true, + })} + + )) + .build(); +})(); diff --git a/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/lineChartConfig.tsx b/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/lineChartConfig.tsx new file mode 100644 index 000000000..266e5fbf7 --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/lineChartConfig.tsx @@ -0,0 +1,96 @@ +import { LineSeriesOption } from "echarts"; +import { + MultiCompBuilder, + BoolControl, + dropdownControl, + showLabelPropertyView, + withContext, + StringControl, + 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) => + children.value.propertyView({ + label: trans("chart.pointColorLabel"), + placeholder: "{{value < 25000}}", + tooltip: trans("chart.pointColorTooltip"), + }) + ) + .build(), + ["seriesName", "value"] as const +); + +export const LineChartConfig = (function () { + return new MultiCompBuilder( + { + showLabel: BoolControl, + type: dropdownControl(BarTypeOptions, "basicLine"), + smooth: BoolControl, + itemColor: ItemColorComp, + }, + (props): LineSeriesOption => { + const config: LineSeriesOption = { + type: "line", + label: { + show: props.showLabel, + }, + itemStyle: { + color: (params) => { + if (!params.encode || !params.dimensionNames) { + return params.color; + } + const dataKey = params.dimensionNames[params.encode["y"][0]]; + const color = (props.itemColor as any)({ + seriesName: params.seriesName, + value: (params.data as any)[dataKey], + }); + if (color === "true") { + return "red"; + } else if (color === "false" || !color) { + return params.color; + } + return color; + }, + }, + }; + if (props.type === "stackedLine") { + config.stack = "stackValue"; + } else if (props.type === "areaLine") { + config.areaStyle = {}; + } + if (props.smooth) { + config.smooth = true; + } + return config; + } + ) + .setPropertyViewFn((children) => ( + <> + {children.type.propertyView({ + label: trans("chart.lineType"), + })} + {showLabelPropertyView(children)} + {children.smooth.propertyView({ label: trans("chart.smooth") })} + {children.itemColor.getPropertyView()} + + )) + .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 new file mode 100644 index 000000000..0861fb6ba --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/pieChartConfig.tsx @@ -0,0 +1,83 @@ +import { MultiCompBuilder } from "lowcoder-sdk"; +import { PieSeriesOption } from "echarts"; +import { dropdownControl } from "lowcoder-sdk"; +import { ConstructorToView } from "lowcoder-core"; +import { trans } from "i18n/comps"; + +const BarTypeOptions = [ + { + label: trans("chart.basicPie"), + value: "basicPie", + }, + { + label: trans("chart.doughnutPie"), + value: "doughnutPie", + }, + { + label: trans("chart.rosePie"), + value: "rosePie", + }, +] as const; + +// radius percent for each pie chart when one line has [1, 2, 3] pie charts +const pieRadiusConfig = [65, 35, 20]; + +type PieConfigViewType = ConstructorToView; + +export const PieChartConfig = (function () { + return new MultiCompBuilder( + { + type: dropdownControl(BarTypeOptions, "basicPie"), + }, + (props): PieSeriesOption => { + const config: PieSeriesOption = { + type: "pie", + label: { + show: true, + formatter: "{d}%", + }, + }; + if (props.type === "rosePie") { + config.roseType = "area"; + } else if (props.type === "doughnutPie") { + config.radius = ["40%", "60%"]; + } + return config; + } + ) + .setPropertyViewFn((children) => ( + <> + {children.type.propertyView({ + label: trans("chart.pieType"), + })} + + )) + .build(); +})(); + +export function getPieRadiusAndCenter( + seriesLength: number, + pieIndex: number, + pieConfig: PieConfigViewType +) { + const columnPieNum = Math.min(seriesLength, pieRadiusConfig.length); + const radiusNumber = pieRadiusConfig[columnPieNum - 1]; + const isDoughnutPie = Array.isArray(pieConfig.radius); + const radius = isDoughnutPie + ? [(radiusNumber / 1.6).toFixed(2) + "%", radiusNumber + "%"] + : radiusNumber + "%"; + + /*** calculate center coordinates ***/ + const pieDiameter = 100 / columnPieNum; + const xPosition = (pieDiameter * (pieIndex % columnPieNum) + pieDiameter / 2).toFixed(2) + "%"; + const rowIndex = Math.floor(pieIndex / columnPieNum) + 1; + const yPosition = + ((100 / Math.floor((columnPieNum * 2 + seriesLength - 1) / columnPieNum)) * rowIndex).toFixed( + 2 + ) + "%"; + // log.log("Echarts height: index:", pieConfig, radius, pieIndex, xPosition, yPosition); + return { + radius: radius, + center: [xPosition, yPosition], + } as const; +} diff --git a/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/radarChartConfig.tsx b/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/radarChartConfig.tsx new file mode 100644 index 000000000..5615c2d73 --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/radarChartConfig.tsx @@ -0,0 +1,31 @@ +import { + BoolControl, + MultiCompBuilder, + showLabelPropertyView, +} from "lowcoder-sdk"; +import { RadarSeriesOption } from "echarts"; +import { trans } from "i18n/comps"; + +export const RadarChartConfig = (function () { + return new MultiCompBuilder( + { + showLabel: BoolControl, + }, + (props): RadarSeriesOption => { + const config: RadarSeriesOption = { + type: "radar", + }; + return config; + } + ) + .setPropertyViewFn((children) => ( + <> + {showLabelPropertyView(children)} + {children.type.propertyView({ + label: trans("radarChart.radarType"), + radioButton: true, + })} + + )) + .build(); +})(); diff --git a/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/sankeyChartConfig.tsx b/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/sankeyChartConfig.tsx new file mode 100644 index 000000000..60c646c0b --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/sankeyChartConfig.tsx @@ -0,0 +1,35 @@ +import { + BoolControl, + MultiCompBuilder, + showLabelPropertyView, +} from "lowcoder-sdk"; +import { SankeySeriesOption } from "echarts"; +import { trans } from "i18n/comps"; + +export const SankeyChartConfig = (function () { + return new MultiCompBuilder( + { + showLabel: BoolControl, + }, + (props): SankeySeriesOption => { + const config: SankeySeriesOption = { + type: "sankey", + label: { + show: props.showLabel, + position: "top", + }, + }; + return config; + } + ) + .setPropertyViewFn((children) => ( + <> + {showLabelPropertyView(children)} + {children.type.propertyView({ + label: trans("sankeyChart.sankeyType"), + radioButton: true, + })} + + )) + .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 new file mode 100644 index 000000000..edb339bdb --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/scatterChartConfig.tsx @@ -0,0 +1,62 @@ +import { + MultiCompBuilder, + dropdownControl, + BoolControl, + showLabelPropertyView, +} from "lowcoder-sdk"; +import { ScatterSeriesOption } from "echarts"; +import { trans } from "i18n/comps"; + +const ScatterShapeOptions = [ + { + label: trans("chart.circle"), + value: "circle", + }, + { + label: trans("chart.rect"), + value: "rect", + }, + { + label: trans("chart.triangle"), + value: "triangle", + }, + { + label: trans("chart.diamond"), + value: "diamond", + }, + { + label: trans("chart.pin"), + value: "pin", + }, + { + label: trans("chart.arrow"), + value: "arrow", + }, +] as const; + +export const ScatterChartConfig = (function () { + return new MultiCompBuilder( + { + showLabel: BoolControl, + shape: dropdownControl(ScatterShapeOptions, "circle"), + }, + (props): ScatterSeriesOption => { + return { + type: "scatter", + symbol: props.shape, + label: { + show: props.showLabel, + }, + }; + } + ) + .setPropertyViewFn((children) => ( + <> + {showLabelPropertyView(children)} + {children.shape.propertyView({ + label: trans("chart.scatterShape"), + })} + + )) + .build(); +})(); diff --git a/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/sunburstChartConfig.tsx b/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/sunburstChartConfig.tsx new file mode 100644 index 000000000..8306911a3 --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/sunburstChartConfig.tsx @@ -0,0 +1,31 @@ +import { + BoolControl, + MultiCompBuilder, + showLabelPropertyView, +} from "lowcoder-sdk"; +import { SunburstSeriesOption } from "echarts"; +import { trans } from "i18n/comps"; + +export const SunburstChartConfig = (function () { + return new MultiCompBuilder( + { + showLabel: BoolControl, + }, + (props): SunburstSeriesOption => { + const config: SunburstSeriesOption = { + type: "sunburst", + }; + return config; + } + ) + .setPropertyViewFn((children) => ( + <> + {showLabelPropertyView(children)} + {children.type.propertyView({ + label: trans("sunburstChart.sunburstType"), + radioButton: true, + })} + + )) + .build(); +})(); diff --git a/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/themeriverChartConfig.tsx b/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/themeriverChartConfig.tsx new file mode 100644 index 000000000..ae639eb22 --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/themeriverChartConfig.tsx @@ -0,0 +1,31 @@ +import { + BoolControl, + MultiCompBuilder, + showLabelPropertyView, +} from "lowcoder-sdk"; +import { ThemeRiverSeriesOption } from "echarts"; +import { trans } from "i18n/comps"; + +export const ThemeriverChartConfig = (function () { + return new MultiCompBuilder( + { + showLabel: BoolControl, + }, + (props): ThemeRiverSeriesOption => { + const config: ThemeRiverSeriesOption = { + type: "themeRiver", + }; + return config; + } + ) + .setPropertyViewFn((children) => ( + <> + {showLabelPropertyView(children)} + {children.type.propertyView({ + label: trans("themeriverChart.themeriverType"), + radioButton: true, + })} + + )) + .build(); +})(); diff --git a/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/treeChartConfig.tsx b/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/treeChartConfig.tsx new file mode 100644 index 000000000..3f824008d --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/treeChartConfig.tsx @@ -0,0 +1,31 @@ +import { + BoolControl, + MultiCompBuilder, + showLabelPropertyView, +} from "lowcoder-sdk"; +import { TreeSeriesOption } from "echarts"; +import { trans } from "i18n/comps"; + +export const TreeChartConfig = (function () { + return new MultiCompBuilder( + { + showLabel: BoolControl, + }, + (props): TreeSeriesOption => { + const config: TreeSeriesOption = { + type: "tree", + }; + return config; + } + ) + .setPropertyViewFn((children) => ( + <> + {showLabelPropertyView(children)} + {children.type.propertyView({ + label: trans("treeChart.treeType"), + radioButton: true, + })} + + )) + .build(); +})(); diff --git a/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/treemapChartConfig.tsx b/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/treemapChartConfig.tsx new file mode 100644 index 000000000..a201337ef --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/treemapChartConfig.tsx @@ -0,0 +1,31 @@ +import { + BoolControl, + MultiCompBuilder, + showLabelPropertyView, +} from "lowcoder-sdk"; +import { TreemapSeriesOption } from "echarts"; +import { trans } from "i18n/comps"; + +export const TreemapChartConfig = (function () { + return new MultiCompBuilder( + { + showLabel: BoolControl, + }, + (props): TreemapSeriesOption => { + const config: TreemapSeriesOption = { + type: "treemap", + }; + return config; + } + ) + .setPropertyViewFn((children) => ( + <> + {showLabelPropertyView(children)} + {children.type.propertyView({ + label: trans("treemapChart.treemapType"), + radioButton: true, + })} + + )) + .build(); +})(); diff --git a/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConstants.tsx b/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConstants.tsx new file mode 100644 index 000000000..d43134234 --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConstants.tsx @@ -0,0 +1,301 @@ +import { + jsonControl, + JSONObject, + stateComp, + toJSONObjectArray, + toObject, + BoolControl, + withDefault, + StringControl, + NumberControl, + FunctionControl, + dropdownControl, + eventHandlerControl, + valueComp, + withType, + uiChildren, + clickEvent, + styleControl, + EchartsStyle +} from "lowcoder-sdk"; +import { RecordConstructorToComp, RecordConstructorToView } from "lowcoder-core"; +import { BarChartConfig } from "./chartConfigs/barChartConfig"; +import { XAxisConfig, YAxisConfig } from "./chartConfigs/cartesianAxisConfig"; +import { LegendConfig } from "./chartConfigs/legendConfig"; +import { EchartsLegendConfig } from "./chartConfigs/echartsLegendConfig"; +import { EchartsLabelConfig } from "./chartConfigs/echartsLabelConfig"; +import { LineChartConfig } from "./chartConfigs/lineChartConfig"; +import { PieChartConfig } from "./chartConfigs/pieChartConfig"; +import { ScatterChartConfig } from "./chartConfigs/scatterChartConfig"; +import { SeriesListComp } from "./seriesComp"; +import { EChartsOption } from "echarts"; +import { i18nObjs, trans } from "i18n/comps"; +import { GaugeChartConfig } from "./chartConfigs/gaugeChartConfig"; +import { FunnelChartConfig } from "./chartConfigs/funnelChartConfig"; + +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: StringControl, + data: jsonControl(toJSONObjectArray, i18nObjs.defaultDataSource), + xAxisKey: valueComp(""), // x-axis, key from data + xAxisDirection: dropdownControl(XAxisDirectionOptions, "horizontal"), + series: SeriesListComp, + xConfig: XAxisConfig, + yConfig: YAxisConfig, + legendConfig: LegendConfig, + chartConfig: ChartOptionComp, + onUIEvent: eventHandlerControl(UIEventOptions), +}; + +const chartJsonModeChildren = { + echartsOption: jsonControl(toObject, i18nObjs.defaultEchartsJsonOption), + echartsTitle: withDefault(StringControl, trans("echarts.defaultTitle")), + echartsLegendConfig: EchartsLegendConfig, + echartsLabelConfig: EchartsLabelConfig, + echartsConfig: EchartsOptionComp, + style: styleControl(EchartsStyle), + tooltip: withDefault(BoolControl, true), + legendVisibility: withDefault(BoolControl, true), +} + +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 chartChildrenMap = { + selectedPoints: stateComp>([]), + lastInteractionData: stateComp | NonUIChartDataType>({}), + onEvent: eventHandlerControl([clickEvent] as const), + ...chartUiModeChildren, + ...chartJsonModeChildren, + ...chartMapModeChildren, +}; + +const chartUiChildrenMap = uiChildren(chartChildrenMap); +export type ChartCompPropsType = RecordConstructorToView; +export type ChartCompChildrenType = RecordConstructorToComp; diff --git a/client/packages/lowcoder-comps/src/comps/basicChartComp/chartPropertyView.tsx b/client/packages/lowcoder-comps/src/comps/basicChartComp/chartPropertyView.tsx new file mode 100644 index 000000000..cfda76b14 --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/basicChartComp/chartPropertyView.tsx @@ -0,0 +1,214 @@ +import { changeChildAction, CompAction } from "lowcoder-core"; +import { ChartCompChildrenType, ChartTypeOptions,getDataKeys } from "./chartConstants"; +import { newSeries } from "./seriesComp"; +import { + CustomModal, + Dropdown, + hiddenPropertyView, + Option, + RedButton, + Section, + sectionNames, + controlItem, +} from "lowcoder-sdk"; +import { trans } from "i18n/comps"; +import { examplesUrl, mapExamplesUrl, mapOptionUrl, optionUrl } from "./chartConfigs/chartUrls"; + +export function chartPropertyView( + 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.data.propertyView({ + label: trans("chart.data"), + })} + { + // keep the previous value + if (children.chartConfig.children.comp.children.hasOwnProperty("showLabel")) { + children.chartConfig.dispatchChangeValueAction({ + compType: value as any, + comp: { + showLabel: ( + children.chartConfig.children.comp.children as any + ).showLabel.toJsonValue(), + }, + }); + } else { + children.chartConfig.dispatchChangeValueAction({ + compType: value, + }); + } + }} + /> + { + dispatch(changeChildAction("xAxisKey", value)); + }} + /> +
+
+
+ {children.onUIEvent.propertyView({title: trans("chart.chartEventHandlers")})} +
+
+ {children.onEvent.propertyView()} +
+
+
+ {children.title.propertyView({ label: trans("chart.title") })} + {children.chartConfig.children.compType.getView() !== "pie" && ( + <> + {children.xAxisDirection.propertyView({ + label: trans("chart.xAxisDirection"), + radioButton: true, + })} + {children.xConfig.getPropertyView()} + {children.yConfig.getPropertyView()} + + )} + {children.legendConfig.getPropertyView()} + {hiddenPropertyView(children)} +
+
{children.chartConfig.getPropertyView()}
+ + ); + + const jsonModePropertyView = ( + <> + + ); + + const mapModePropertyView = ( + <> +
+ {children.mapApiKey.propertyView({ + label: "API Key" + })} + {children.mapZoomLevel.propertyView({ + label: "Zoom Level" + })} + {controlItem({}, ( + + {'Center Position'} + + ))} + {children.mapCenterLng.propertyView({ + label: "Longitude" + })} + {children.mapCenterLat.propertyView({ + label: "Latitude" + })} + {children.showCharts.propertyView({ + label: "Show Charts" + })} +
+
+ {children.mapOptions.propertyView({ + label: trans("chart.echartsOptionLabel"), + styleName: "higher", + tooltip: ( + + ), + })} +
+
+
+ {children.onMapEvent.propertyView({title: trans("chart.chartEventHandlers")})} +
+
+ {children.onEvent.propertyView()} +
+
+
{hiddenPropertyView(children)}
+ + ); + + const getChatConfigByMode = (mode: string) => { + switch(mode) { + case "ui": + return uiModePropertyView; + case "json": + return jsonModePropertyView; + case "map": + return mapModePropertyView; + } + } + return ( + <> + {/*
+ {children.mode.propertyView({ + label: "", + radioButton: true, + })} +
*/} + {getChatConfigByMode(children.mode.getView())} + + ); +} diff --git a/client/packages/lowcoder-comps/src/comps/basicChartComp/chartUtils.ts b/client/packages/lowcoder-comps/src/comps/basicChartComp/chartUtils.ts new file mode 100644 index 000000000..57b908be5 --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/basicChartComp/chartUtils.ts @@ -0,0 +1,328 @@ +import { + CharOptionCompType, + ChartCompPropsType, + ChartSize, + noDataAxisConfig, + noDataPieChartConfig, +} from "comps/chartComp/chartConstants"; +import { getPieRadiusAndCenter } from "comps/chartComp/chartConfigs/pieChartConfig"; +import { EChartsOptionWithMap } from "./reactEcharts/types"; +import _ from "lodash"; +import { chartColorPalette, isNumeric, JSONObject, loadScript } from "lowcoder-sdk"; +import { calcXYConfig } from "comps/chartComp/chartConfigs/cartesianAxisConfig"; +import Big from "big.js"; +import { googleMapsApiUrl } from "./chartConfigs/chartUrls"; + +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); +export const echartsConfigOmitChildren = [ + "hidden", + "selectedPoints", + "onUIEvent", + "mapInstance" +] as const; +type EchartsConfigProps = Omit; + +export function isAxisChart(type: CharOptionCompType) { + return !notAxisChartSet.has(type); +} + +export function getSeriesConfig(props: EchartsConfigProps) { + const visibleSeries = props.series.filter((s) => !s.getView().hide); + const seriesLength = visibleSeries.length; + return visibleSeries.map((s, index) => { + if (isAxisChart(props.chartConfig.type)) { + 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 (horizontalX) { + encodeX = props.xAxisKey; + encodeY = s.getView().columnName; + } else { + encodeX = s.getView().columnName; + encodeY = props.xAxisKey; + } + return { + name: 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): EChartsOptionWithMap { + if (props.mode === "json") { + let opt={ + "title": { + "text": props.echartsTitle, + 'top': props.echartsLegendConfig.top === 'bottom' ?'top':'bottom', + "left":"center" + }, + "backgroundColor": props?.style?.background, + "color": props.echartsOption.data?.map(data => data.color), + "tooltip": props.tooltip&&{ + "trigger": "item", + "formatter": "{a}
{b} : {c}%" + }, + "legend":props.legendVisibility&& { + "data": props.echartsOption.data?.map(data=>data.name), + "top": props.echartsLegendConfig.top, + }, + "series": [ + { + "name": props.echartsConfig.type, + "type": props.echartsConfig.type, + "left": "10%", + "top": 60, + "bottom": 60, + "width": "80%", + "min": 0, + "max": 100, + "gap": 2, + "label": { + "show": true, + "position": props.echartsLabelConfig.top + }, + "data": props.echartsOption.data + } + ] +} + return props.echartsOption ? opt : {}; + + } + + if(props.mode === "map") { + const { + mapZoomLevel, + mapCenterLat, + mapCenterLng, + mapOptions, + showCharts, + } = props; + + const echartsOption = mapOptions && showCharts ? mapOptions : {}; + return { + gmap: { + center: [mapCenterLng, mapCenterLat], + zoom: mapZoomLevel, + renderOnMoving: true, + echartsLayerZIndex: showCharts ? 2019 : 0, + roam: true + }, + ...echartsOption, + } + } + // axisChart + const axisChart = isAxisChart(props.chartConfig.type); + const gridPos = { + left: 20, + right: props.legendConfig.left === "right" ? "10%" : 20, + top: 50, + bottom: 35, + }; + let config: EChartsOptionWithMap = { + title: { text: props.title, left: "center" }, + tooltip: { + confine: true, + trigger: axisChart ? "axis" : "item", + }, + legend: props.legendConfig, + grid: { + ...gridPos, + containLabel: true, + }, + }; + 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 + const transformedData = + yAxisConfig.type === "category" || yAxisConfig.type === "time" + ? props.data + : transformData(props.data, props.xAxisKey, seriesColumnNames); + config = { + ...config, + dataset: [ + { + source: transformedData, + sourceHeader: false, + }, + ], + series: getSeriesConfig(props), + }; + 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, + // @ts-ignore + yAxis: finalXyConfig.yConfig, + }; + } + // 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/basicChartComp/reactEcharts/core.tsx b/client/packages/lowcoder-comps/src/comps/basicChartComp/reactEcharts/core.tsx new file mode 100644 index 000000000..1b5d9410a --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/basicChartComp/reactEcharts/core.tsx @@ -0,0 +1,194 @@ +import type { ECharts } from "echarts"; +import { PureComponent } from "react"; +import isEqual from "fast-deep-equal"; +import { EChartsReactProps, EChartsInstance } from "./types"; +import _ from "lodash"; +import log from "loglevel"; + +function isString(v: any): boolean { + return typeof v === "string"; +} + +function isFunction(v: any): boolean { + return typeof v === "function"; +} + +/** + * core component for echarts binding + */ +export default class EChartsReactCore extends PureComponent { + /** + * echarts render container + */ + public ele: HTMLElement | null; + + /** + * echarts library entry + */ + protected echarts: any; + + constructor(props: EChartsReactProps) { + super(props); + + this.echarts = props.echarts; + this.ele = null; + } + + componentDidMount() { + this.renderNewEcharts(); + } + + // update + componentDidUpdate(prevProps: EChartsReactProps) { + /** + * if shouldSetOption return false, then return, not update echarts options + * default is true + */ + const { shouldSetOption } = this.props; + if (shouldSetOption && isFunction(shouldSetOption) && !shouldSetOption(prevProps, this.props)) { + return; + } + + /** + * the props below need to dispose before re-render + * 1. when switching theme + * 2. when modifying opts + * 3. when modifying onEvents, thus the binded event issue #151 can be cancel + */ + if ( + !isEqual(prevProps.theme, this.props.theme) || + !isEqual(prevProps.opts, this.props.opts) || + !isEqual(prevProps.onEvents, this.props.onEvents) || + !isEqual(prevProps.mode, this.props.mode) + // this.props.option.gmap + ) { + this.dispose(); + + this.renderNewEcharts(); // re-render + return; + } + + if(this.props.mode === "json") { + this.updateEChartsOption(); + return; + } + + // when these props are not isEqual, update echarts + const pickKeys = ["option", "notMerge", "lazyUpdate", "showLoading", "loadingOption"]; + if (!isEqual(_.pick(this.props, pickKeys), _.pick(prevProps, pickKeys))) { + this.updateEChartsOption(); + } + } + + componentWillUnmount() { + this.dispose(); + } + + /** + * return the echart object + * 1. if exist, return the existed instance + * 2. or new one instance + */ + public getEchartsInstance(): ECharts { + return ( + this.echarts.getInstanceByDom(this.ele) || + this.echarts.init(this.ele, this.props.theme, this.props.opts) + ); + } + + /** + * dispose echarts and clear size-sensor + */ + private dispose() { + if (this.ele) { + // dispose echarts instance + this.echarts.dispose(this.ele); + } + } + + /** + * render a new echarts instance + */ + private renderNewEcharts() { + const { onEvents, onChartReady } = this.props; + + // 1. new echarts instance + const echartsInstance = this.updateEChartsOption(); + + // 2. bind events + this.bindEvents(echartsInstance, onEvents || {}); + + // 3. on chart ready + if (onChartReady && isFunction(onChartReady)) onChartReady(echartsInstance); + } + + // bind the events + private bindEvents(instance: any, events: EChartsReactProps["onEvents"]) { + function _bindEvent(eventName: string, func: Function) { + // ignore the event config which not satisfy + if (isString(eventName) && isFunction(func)) { + // binding event + instance.on(eventName, (param: any) => { + func(param, instance); + }); + } + } + + // loop and bind + for (const eventName in events) { + if (Object.prototype.hasOwnProperty.call(events, eventName)) { + _bindEvent(eventName, events[eventName]); + } + } + } + + /** + * render the echarts + */ + private updateEChartsOption(): EChartsInstance { + const { + option, + notMerge = false, + lazyUpdate = false, + showLoading, + loadingOption = null, + } = this.props; + // 1. get or initial the echarts object + const echartInstance = this.getEchartsInstance(); + // 2. set the echarts option + try { + // set option catch exception + echartInstance.setOption(option, { + notMerge: notMerge, + lazyUpdate: lazyUpdate, + silent: true, + }); + } catch (e) { + // FIXME: if don't dispose, setOption again will call cause bugs + // https://github.com/apache/echarts/issues/16608 + this.dispose(); + log.warn("invalid echarts option:", e); + } + // 3. set loading mask + if (showLoading) echartInstance.showLoading(loadingOption); + else echartInstance.hideLoading(); + + return echartInstance; + } + + render(): JSX.Element { + const { style, className = "" } = this.props; + // default height = 300 + const newStyle = { height: 300, ...style }; + + return ( +
{ + this.ele = e; + }} + style={newStyle} + className={`echarts-for-react ${className}`} + /> + ); + } +} diff --git a/client/packages/lowcoder-comps/src/comps/basicChartComp/reactEcharts/index.ts b/client/packages/lowcoder-comps/src/comps/basicChartComp/reactEcharts/index.ts new file mode 100644 index 000000000..dcb57f0f9 --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/basicChartComp/reactEcharts/index.ts @@ -0,0 +1,21 @@ +import * as echarts from "echarts"; +import "echarts-wordcloud"; +import { EChartsReactProps, EChartsInstance, EChartsOptionWithMap } from "./types"; +import EChartsReactCore from "./core"; + +/** + * reference: https://github.com/hustcc/echarts-for-react + * add exception-catch for setOption + * if query isn't successfully loaded, chart will fail to load and can't reload + */ +export type { EChartsReactProps, EChartsOptionWithMap, EChartsInstance }; + +// export the Component the echarts Object. +export default class EChartsReact extends EChartsReactCore { + constructor(props: EChartsReactProps) { + super(props); + + // initialize as echarts package + this.echarts = echarts; + } +} diff --git a/client/packages/lowcoder-comps/src/comps/basicChartComp/reactEcharts/types.ts b/client/packages/lowcoder-comps/src/comps/basicChartComp/reactEcharts/types.ts new file mode 100644 index 000000000..2764d4b81 --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/basicChartComp/reactEcharts/types.ts @@ -0,0 +1,76 @@ +import { CSSProperties } from "react"; +import { EChartsOption } from "echarts"; +import { GoogleMapComponentOption } from "echarts-extension-gmap"; + +export type EChartsOptionWithMap = EChartsOption & GoogleMapComponentOption; + +export type EChartsInstance = any; + +export type Opts = { + readonly devicePixelRatio?: number; + readonly renderer?: "canvas" | "svg"; + readonly width?: number | null | undefined | "auto"; + readonly height?: number | null | undefined | "auto"; + readonly locale?: string; +}; + +export type EChartsReactProps = { + /** + * echarts library entry, use it for import necessary. + */ + readonly echarts?: any; + /** + * `className` for container + */ + readonly className?: string; + /** + * `style` for container + */ + readonly style?: CSSProperties; + /** + * echarts option + */ + readonly option: EChartsOptionWithMap; + /** + * echarts theme config, can be: + * 1. theme name string + * 2. theme object + */ + readonly theme?: string | Record; + /** + * notMerge config for echarts, default is `false` + */ + readonly notMerge?: boolean; + /** + * lazyUpdate config for echarts, default is `false` + */ + readonly lazyUpdate?: boolean; + /** + * showLoading config for echarts, default is `false` + */ + readonly showLoading?: boolean; + /** + * loadingOption config for echarts, default is `null` + */ + readonly loadingOption?: any; + /** + * echarts opts config, default is `{}` + */ + readonly opts?: Opts; + /** + * when after chart reander, do the callback widht echarts instance + */ + readonly onChartReady?: (instance: EChartsInstance) => void; + /** + * bind events, default is `{}` + */ + readonly onEvents?: Record; + /** + * should update echarts options + */ + readonly shouldSetOption?: (prevProps: EChartsReactProps, props: EChartsReactProps) => boolean; + /** + * echarts mode: ui | json | map + */ + readonly mode?: 'ui' | 'json' | 'map' +}; diff --git a/client/packages/lowcoder-comps/src/comps/basicChartComp/seriesComp.tsx b/client/packages/lowcoder-comps/src/comps/basicChartComp/seriesComp.tsx new file mode 100644 index 000000000..9ded885b5 --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/basicChartComp/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/chartComp/chartComp.tsx b/client/packages/lowcoder-comps/src/comps/chartComp/chartComp.tsx index 8b56ec0c1..7e49fed09 100644 --- a/client/packages/lowcoder-comps/src/comps/chartComp/chartComp.tsx +++ b/client/packages/lowcoder-comps/src/comps/chartComp/chartComp.tsx @@ -16,7 +16,10 @@ import { childrenToProps, depsConfig, genRandomKey, + JSONObject, + JSONValue, NameConfig, + ToViewReturn, UICompBuilder, withDefault, withExposingConfigs, @@ -25,7 +28,6 @@ import { ThemeContext, chartColorPalette, getPromiseAfterDispatch, - dropdownControl } from "lowcoder-sdk"; import { getEchartsLocale, trans } from "i18n/comps"; import { ItemColorComp } from "comps/chartComp/chartConfigs/lineChartConfig"; @@ -33,32 +35,34 @@ import { echartsConfigOmitChildren, getEchartsConfig, getSelectedPoints, + loadGoogleMapsScript, } from "comps/chartComp/chartUtils"; import 'echarts-extension-gmap'; import log from "loglevel"; let clickEventCallback = () => {}; -const chartModeOptions = [ - { - label: trans("chart.UIMode"), - value: "ui", - } -] as const; - let ChartTmpComp = (function () { - return new UICompBuilder({mode:dropdownControl(chartModeOptions,'ui'),...chartChildrenMap}, () => null) + return new UICompBuilder(chartChildrenMap, () => null) .setPropertyViewFn(chartPropertyView) .build(); })(); ChartTmpComp = withViewFn(ChartTmpComp, (comp) => { + const apiKey = comp.children.mapApiKey.getView(); const mode = comp.children.mode.getView(); + const mapCenterPosition = { + lng: comp.children.mapCenterLng.getView(), + lat: comp.children.mapCenterLat.getView(), + } + const mapZoomlevel = comp.children.mapZoomLevel.getView(); const onUIEvent = comp.children.onUIEvent.getView(); + const onMapEvent = comp.children.onMapEvent.getView(); const onEvent = comp.children.onEvent.getView(); const echartsCompRef = useRef(); const [chartSize, setChartSize] = useState(); + const [mapScriptLoaded, setMapScriptLoaded] = useState(false); const firstResize = useRef(true); const theme = useContext(ThemeContext); const defaultChartTheme = { @@ -83,6 +87,36 @@ ChartTmpComp = withViewFn(ChartTmpComp, (comp) => { } useEffect(() => { + // click events for JSON/Map mode + if (mode === 'ui') return; + + 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) + }; + }, [mode, mapScriptLoaded]); + + useEffect(() => { + // click events for UI mode + if(mode !== 'ui') return; + // bind events const echartsCompInstance = echartsCompRef?.current?.getEchartsInstance(); if (!echartsCompInstance) { @@ -90,6 +124,8 @@ ChartTmpComp = withViewFn(ChartTmpComp, (comp) => { } echartsCompInstance?.on("selectchanged", (param: any) => { const option: any = echartsCompInstance?.getOption(); + //log.log("chart select change", param); + // trigger click event listener document.dispatchEvent(new CustomEvent("clickEvent", { bubbles: true, @@ -117,7 +153,7 @@ ChartTmpComp = withViewFn(ChartTmpComp, (comp) => { echartsCompInstance?.off("selectchanged"); document.removeEventListener('clickEvent', clickEventCallback) }; - }, [onUIEvent]); + }, [mode, onUIEvent]); const echartsConfigChildren = _.omit(comp.children, echartsConfigOmitChildren); const option = useMemo(() => { @@ -127,9 +163,55 @@ ChartTmpComp = withViewFn(ChartTmpComp, (comp) => { ); }, [chartSize, ...Object.values(echartsConfigChildren)]); + const isMapScriptLoaded = useMemo(() => { + return mapScriptLoaded || window?.google; + }, [mapScriptLoaded]) + + const loadGoogleMapData = () => { + const echartsCompInstance = echartsCompRef?.current?.getEchartsInstance(); + if (!echartsCompInstance) { + return _.noop; + } + + comp.children.mapInstance.dispatch(changeValueAction(echartsCompInstance)) + onMapEvent('mapReady') + } + + const handleOnMapScriptLoad = () => { + setMapScriptLoaded(true); + setTimeout(() => { + loadGoogleMapData(); + }) + } + useEffect(() => { - comp.children.mapInstance.dispatch(changeValueAction(null, false)) - }, [option]) + if( mode !== 'map') { + comp.children.mapInstance.dispatch(changeValueAction(null, false)) + return; + } + + if(comp.children.mapInstance.value) return; + + const gMapScript = loadGoogleMapsScript(apiKey); + if(isMapScriptLoaded) { + handleOnMapScriptLoad(); + return; + } + gMapScript.addEventListener('load', handleOnMapScriptLoad); + return () => { + gMapScript.removeEventListener('load', handleOnMapScriptLoad); + } + }, [mode, apiKey, option]) + + useEffect(() => { + if(mode !== 'map') return; + onMapEvent('centerPositionChange'); + }, [mode, mapCenterPosition.lat, mapCenterPosition.lng]) + + useEffect(() => { + if(mode !== 'map') return; + onMapEvent('zoomLevelChange'); + }, [mode, mapZoomlevel]) return ( { } }} > - (echartsCompRef.current = e)} style={{ height: "100%" }} notMerge lazyUpdate opts={{ locale: getEchartsLocale() }} option={option} - theme={themeConfig} + theme={mode !== 'map' ? themeConfig : undefined} mode={mode} /> + )} ); }); @@ -273,11 +357,74 @@ let ChartComp = withExposingConfigs(ChartTmpComp, [ name: "data", desc: trans("chart.dataDesc"), depKeys: ["data", "mode"], - func: (input) => input.data, + func: (input) => { + if (input.mode === "ui") { + return input.data; + } else { + // no data in json mode + return []; + } + }, }), new NameConfig("title", trans("chart.titleDesc")), ]); +ChartComp = withMethodExposing(ChartComp, [ + { + method: { + name: "getMapInstance", + }, + execute: (comp) => { + return new Promise(resolve => { + let intervalCount = 0; + const mapInstanceInterval = setInterval(() => { + const instance = comp.children.mapInstance.getView(); + const mapInstance = instance?.getModel()?.getComponent("gmap")?.getGoogleMap() + if(mapInstance || intervalCount === 10) { + clearInterval(mapInstanceInterval) + resolve(mapInstance) + } + intervalCount++; + }, 1000); + }) + } + }, + { + method: { + name: "getMapZoomLevel", + }, + execute: (comp) => { + return comp.children.mapZoomLevel.getView(); + } + }, + { + method: { + name: "getMapCenterPosition", + }, + execute: (comp) => { + return Promise.resolve({ + lng: comp.children.mapCenterLng.getView(), + lat: comp.children.mapCenterLat.getView(), + }); + } + }, + { + method: { + name: "onClick", + params: [ + { + name: "callback", + type: "function", + }, + ], + }, + execute: (comp, params) => { + clickEventCallback = params[0]; + document.addEventListener('clickEvent', clickEventCallback); + } + }, +]) + export const ChartCompWithDefault = withDefault(ChartComp, { xAxisKey: "date", series: [ @@ -292,4 +439,4 @@ export const ChartCompWithDefault = withDefault(ChartComp, { columnName: "budget", }, ], -}); +}); \ No newline at end of file diff --git a/client/packages/lowcoder-comps/src/comps/chartComp/chartConstants.tsx b/client/packages/lowcoder-comps/src/comps/chartComp/chartConstants.tsx index d43134234..71962fd53 100644 --- a/client/packages/lowcoder-comps/src/comps/chartComp/chartConstants.tsx +++ b/client/packages/lowcoder-comps/src/comps/chartComp/chartConstants.tsx @@ -13,25 +13,20 @@ import { eventHandlerControl, valueComp, withType, + ValueFromOption, uiChildren, clickEvent, - styleControl, - EchartsStyle } from "lowcoder-sdk"; import { RecordConstructorToComp, RecordConstructorToView } from "lowcoder-core"; import { BarChartConfig } from "./chartConfigs/barChartConfig"; import { XAxisConfig, YAxisConfig } from "./chartConfigs/cartesianAxisConfig"; import { LegendConfig } from "./chartConfigs/legendConfig"; -import { EchartsLegendConfig } from "./chartConfigs/echartsLegendConfig"; -import { EchartsLabelConfig } from "./chartConfigs/echartsLabelConfig"; import { LineChartConfig } from "./chartConfigs/lineChartConfig"; import { PieChartConfig } from "./chartConfigs/pieChartConfig"; import { ScatterChartConfig } from "./chartConfigs/scatterChartConfig"; import { SeriesListComp } from "./seriesComp"; import { EChartsOption } from "echarts"; import { i18nObjs, trans } from "i18n/comps"; -import { GaugeChartConfig } from "./chartConfigs/gaugeChartConfig"; -import { FunnelChartConfig } from "./chartConfigs/funnelChartConfig"; export const ChartTypeOptions = [ { @@ -52,6 +47,21 @@ export const ChartTypeOptions = [ }, ] as const; +const chartModeOptions = [ + { + label: trans("chart.UIMode"), + value: "ui", + }, + { + label: "ECharts JSON", + value: "json", + }, + { + label: "Map", + value: "map", + }, +] as const; + export const UIEventOptions = [ { label: trans("chart.select"), @@ -227,13 +237,7 @@ const ChartOptionMap = { 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 = { @@ -251,13 +255,6 @@ export const chartUiModeChildren = { const chartJsonModeChildren = { echartsOption: jsonControl(toObject, i18nObjs.defaultEchartsJsonOption), - echartsTitle: withDefault(StringControl, trans("echarts.defaultTitle")), - echartsLegendConfig: EchartsLegendConfig, - echartsLabelConfig: EchartsLabelConfig, - echartsConfig: EchartsOptionComp, - style: styleControl(EchartsStyle), - tooltip: withDefault(BoolControl, true), - legendVisibility: withDefault(BoolControl, true), } const chartMapModeChildren = { @@ -288,6 +285,7 @@ export type NonUIChartDataType = { } export const chartChildrenMap = { + mode: dropdownControl(chartModeOptions, "ui"), selectedPoints: stateComp>([]), lastInteractionData: stateComp | NonUIChartDataType>({}), onEvent: eventHandlerControl([clickEvent] as const), @@ -298,4 +296,4 @@ export const chartChildrenMap = { const chartUiChildrenMap = uiChildren(chartChildrenMap); export type ChartCompPropsType = RecordConstructorToView; -export type ChartCompChildrenType = RecordConstructorToComp; +export type ChartCompChildrenType = RecordConstructorToComp; \ No newline at end of file diff --git a/client/packages/lowcoder-comps/src/comps/chartComp/chartPropertyView.tsx b/client/packages/lowcoder-comps/src/comps/chartComp/chartPropertyView.tsx index cfda76b14..7bc6cf488 100644 --- a/client/packages/lowcoder-comps/src/comps/chartComp/chartPropertyView.tsx +++ b/client/packages/lowcoder-comps/src/comps/chartComp/chartPropertyView.tsx @@ -1,5 +1,5 @@ import { changeChildAction, CompAction } from "lowcoder-core"; -import { ChartCompChildrenType, ChartTypeOptions,getDataKeys } from "./chartConstants"; +import { ChartCompChildrenType, ChartTypeOptions, getDataKeys } from "./chartConstants"; import { newSeries } from "./seriesComp"; import { CustomModal, @@ -134,6 +134,27 @@ export function chartPropertyView( const jsonModePropertyView = ( <> +
+ {children.echartsOption.propertyView({ + label: trans("chart.echartsOptionLabel"), + styleName: "higher", + tooltip: ( + + ), + })} +
+
+ {children.onEvent.propertyView()} +
+
{hiddenPropertyView(children)}
); @@ -202,13 +223,13 @@ export function chartPropertyView( } return ( <> - {/*
+
{children.mode.propertyView({ label: "", radioButton: true, })} -
*/} +
{getChatConfigByMode(children.mode.getView())} ); -} +} \ No newline at end of file diff --git a/client/packages/lowcoder-comps/src/comps/chartComp/chartUtils.ts b/client/packages/lowcoder-comps/src/comps/chartComp/chartUtils.ts index 57b908be5..5b652ef94 100644 --- a/client/packages/lowcoder-comps/src/comps/chartComp/chartUtils.ts +++ b/client/packages/lowcoder-comps/src/comps/chartComp/chartUtils.ts @@ -130,45 +130,8 @@ export function getSeriesConfig(props: EchartsConfigProps) { // https://echarts.apache.org/en/option.html export function getEchartsConfig(props: EchartsConfigProps, chartSize?: ChartSize): EChartsOptionWithMap { if (props.mode === "json") { - let opt={ - "title": { - "text": props.echartsTitle, - 'top': props.echartsLegendConfig.top === 'bottom' ?'top':'bottom', - "left":"center" - }, - "backgroundColor": props?.style?.background, - "color": props.echartsOption.data?.map(data => data.color), - "tooltip": props.tooltip&&{ - "trigger": "item", - "formatter": "{a}
{b} : {c}%" - }, - "legend":props.legendVisibility&& { - "data": props.echartsOption.data?.map(data=>data.name), - "top": props.echartsLegendConfig.top, - }, - "series": [ - { - "name": props.echartsConfig.type, - "type": props.echartsConfig.type, - "left": "10%", - "top": 60, - "bottom": 60, - "width": "80%", - "min": 0, - "max": 100, - "gap": 2, - "label": { - "show": true, - "position": props.echartsLabelConfig.top - }, - "data": props.echartsOption.data - } - ] -} - return props.echartsOption ? opt : {}; - + return props.echartsOption ? props.echartsOption : {}; } - if(props.mode === "map") { const { mapZoomLevel, @@ -325,4 +288,4 @@ export function loadGoogleMapsScript(apiKey: string) { window.document.body.appendChild(script); return script; -} +} \ No newline at end of file diff --git a/client/packages/lowcoder-comps/src/comps/chartsGeoMapComp/chartsGeoMapComp.tsx b/client/packages/lowcoder-comps/src/comps/chartsGeoMapComp/chartsGeoMapComp.tsx index 74075e4ce..d58c75a67 100644 --- a/client/packages/lowcoder-comps/src/comps/chartsGeoMapComp/chartsGeoMapComp.tsx +++ b/client/packages/lowcoder-comps/src/comps/chartsGeoMapComp/chartsGeoMapComp.tsx @@ -180,16 +180,18 @@ MapTmpComp = withViewFn(MapTmpComp, (comp) => { } }} > - (echartsCompRef.current = e)} - style={{ height: "100%" }} - notMerge - lazyUpdate - opts={{ locale: getEchartsLocale() }} - option={option} - theme={undefined} - mode={mode} - /> + {isMapScriptLoaded && ( + (echartsCompRef.current = e)} + style={{ height: "100%" }} + notMerge + lazyUpdate + opts={{ locale: getEchartsLocale() }} + option={option} + theme={undefined} + mode={mode} + /> + )} ); }); diff --git a/client/packages/lowcoder-comps/src/index.ts b/client/packages/lowcoder-comps/src/index.ts index 0ab3dfa69..acdc6c784 100644 --- a/client/packages/lowcoder-comps/src/index.ts +++ b/client/packages/lowcoder-comps/src/index.ts @@ -18,9 +18,11 @@ import { MermaidComp } from "./comps/mermaidComp"; import { MeetingControllerComp } from "./comps/agoraMeetingComp/meetingControllerComp"; import { VideoMeetingStreamComp } from "./comps/agoraMeetingComp/videoMeetingStreamComp"; import { VideoSharingStreamComp } from "./comps/agoraMeetingComp/videoSharingStreamComp"; +import { BasicChartCompWithDefault } from "comps/basicChartComp/chartComp"; export default { chart: ChartCompWithDefault, + basicChart: BasicChartCompWithDefault, chartsGeoMap: ChartsGeoMapComp, funnelChart: FunnelChartCompWithDefault, gaugeChart: GaugeChartCompWithDefault, diff --git a/client/packages/lowcoder/src/comps/comps/remoteComp/loaders.tsx b/client/packages/lowcoder/src/comps/comps/remoteComp/loaders.tsx index cc6b9ef74..4390a7bb9 100644 --- a/client/packages/lowcoder/src/comps/comps/remoteComp/loaders.tsx +++ b/client/packages/lowcoder/src/comps/comps/remoteComp/loaders.tsx @@ -16,7 +16,7 @@ async function npmLoader( const { packageName, packageVersion, compName } = remoteInfo; const entry = `${NPM_PLUGIN_ASSETS_BASE_URL}/${packageName}@${localPackageVersion}/index.js`; - /// const entry = `../../../../../public/package/index.js`; + // const entry = `../../../../../public/package/index.js`; // console.log("Entry", entry); try { const module = await import( diff --git a/client/packages/lowcoder/src/comps/index.tsx b/client/packages/lowcoder/src/comps/index.tsx index 5455e994b..757e5609d 100644 --- a/client/packages/lowcoder/src/comps/index.tsx +++ b/client/packages/lowcoder/src/comps/index.tsx @@ -121,6 +121,20 @@ export var uiCompMap: Registry = { }, }, + basicChart: { + name: trans("uiComp.basicChartCompName"), + enName: "Basic Chart", + description: trans("uiComp.basicChartCompDesc"), + categories: ["dashboards"], + icon: ChartCompIcon, + comp: remoteComp({ ...builtInRemoteComps, compName: "basicChart" }), + keywords: trans("uiComp.basicChartCompKeywords"), + layoutInfo: { + w: 12, + h: 40, + }, + }, + funnelChart : { "name": trans("uiComp.funnelChartCompName"), "enName": "Sankey Chart", diff --git a/client/packages/lowcoder/src/comps/uiCompRegistry.ts b/client/packages/lowcoder/src/comps/uiCompRegistry.ts index a0dabf014..5bc1216f0 100644 --- a/client/packages/lowcoder/src/comps/uiCompRegistry.ts +++ b/client/packages/lowcoder/src/comps/uiCompRegistry.ts @@ -158,6 +158,7 @@ export type UICompType = | "treemapChart" | "sunburstChart" | "themeriverChart" + | "basicChart" ; diff --git a/client/packages/lowcoder/src/i18n/locales/en.ts b/client/packages/lowcoder/src/i18n/locales/en.ts index 350e70e54..7a0d55e24 100644 --- a/client/packages/lowcoder/src/i18n/locales/en.ts +++ b/client/packages/lowcoder/src/i18n/locales/en.ts @@ -1212,6 +1212,10 @@ export const en = { "themeriverChartCompDesc": "A visualization resembling a stream graph that shows changes in a data set over time across categories.", "themeriverChartCompKeywords": "theme river, time series, trends", + "basicChartCompName": "Basic Chart", + "basicChartCompDesc": "A versatile component for visualizing data through various types of charts and graphs.", + "basicChartCompKeywords": "chart, graph, data, visualization", + // by mousheng "colorPickerCompName": "Color Picker", diff --git a/client/packages/lowcoder/src/pages/editor/editorConstants.tsx b/client/packages/lowcoder/src/pages/editor/editorConstants.tsx index f42ff079b..dab6ca9f9 100644 --- a/client/packages/lowcoder/src/pages/editor/editorConstants.tsx +++ b/client/packages/lowcoder/src/pages/editor/editorConstants.tsx @@ -177,6 +177,7 @@ export const CompStateIcon: { treemapChart : , sunburstChart : , themeriverChart : , + basicChart : , avatar: , avatarGroup: ,