diff --git a/client/packages/lowcoder-comps/package.json b/client/packages/lowcoder-comps/package.json index 5fc6173ef..14bdc2148 100644 --- a/client/packages/lowcoder-comps/package.json +++ b/client/packages/lowcoder-comps/package.json @@ -89,6 +89,102 @@ "w": 15, "h": 40 } + }, + "funnelChart": { + "name": "Funnel Chart", + "icon": "./icons/icon-chart.svg", + "layoutInfo": { + "w": 15, + "h": 40 + } + }, + "gaugeChart": { + "name": "Gauge Chart", + "icon": "./icons/icon-chart.svg", + "layoutInfo": { + "w": 15, + "h": 40 + } + }, + "sankeyChart": { + "name": "Sankey Chart", + "icon": "./icons/icon-chart.svg", + "layoutInfo": { + "w": 15, + "h": 40 + } + }, + "candleStickChart": { + "name": "CandleStick Chart", + "icon": "./icons/icon-chart.svg", + "layoutInfo": { + "w": 15, + "h": 40 + } + }, + "radarChart": { + "name": "Radar Chart", + "icon": "./icons/icon-chart.svg", + "layoutInfo": { + "w": 15, + "h": 40 + } + }, + "heatmapChart": { + "name": "Heatmap Chart", + "icon": "./icons/icon-chart.svg", + "layoutInfo": { + "w": 15, + "h": 40 + } + }, + "graphChart": { + "name": "Graph Chart", + "icon": "./icons/icon-chart.svg", + "layoutInfo": { + "w": 15, + "h": 40 + } + }, + "treeChart": { + "name": "Tree Chart", + "icon": "./icons/icon-chart.svg", + "layoutInfo": { + "w": 15, + "h": 40 + } + }, + "treemapChart": { + "name": "Treemap Chart", + "icon": "./icons/icon-chart.svg", + "layoutInfo": { + "w": 15, + "h": 40 + } + }, + "sunburstChart": { + "name": "Sunburst Chart", + "icon": "./icons/icon-chart.svg", + "layoutInfo": { + "w": 15, + "h": 40 + } + }, + "themeriverChart": { + "name": "Themeriver Chart", + "icon": "./icons/icon-chart.svg", + "layoutInfo": { + "w": 15, + "h": 40 + } + }, + "map": { + "name": "Map", + "icon": "./icons/icon-chart.svg", + "layoutInfo": { + "w": 15, + "h": 40 + } } } }, diff --git a/client/packages/lowcoder-comps/src/comps/candleStickChartComp/candleStickChartComp.tsx b/client/packages/lowcoder-comps/src/comps/candleStickChartComp/candleStickChartComp.tsx new file mode 100644 index 000000000..3d1750d9c --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/candleStickChartComp/candleStickChartComp.tsx @@ -0,0 +1,318 @@ +import { + changeChildAction, + changeValueAction, + CompAction, + CompActionTypes, + wrapChildAction, +} from "lowcoder-core"; +import { AxisFormatterComp, EchartsAxisType } from "../chartComp/chartConfigs/cartesianAxisConfig"; +import { candleStickChartChildrenMap, ChartSize, getDataKeys } from "./candleStickChartConstants"; +import { candleStickChartPropertyView } from "./candleStickChartPropertyView"; +import _ from "lodash"; +import { useContext, useEffect, useMemo, useRef, useState } from "react"; +import ReactResizeDetector from "react-resize-detector"; +import ReactECharts from "../chartComp/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/chartComp/chartConfigs/lineChartConfig"; +import { + echartsConfigOmitChildren, + getEchartsConfig, + getSelectedPoints, +} from "./candleStickChartUtils"; +import 'echarts-extension-gmap'; +import log from "loglevel"; + +let clickEventCallback = () => {}; + +const chartModeOptions = [ + { + label: "ECharts JSON", + value: "json", + } +] as const; + +let CandleStickChartTmpComp = (function () { + return new UICompBuilder({mode:dropdownControl(chartModeOptions,'json'),...candleStickChartChildrenMap}, () => null) + .setPropertyViewFn(candleStickChartPropertyView) + .build(); +})(); + +CandleStickChartTmpComp = withViewFn(CandleStickChartTmpComp, (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 option = useMemo(() => { + return getEchartsConfig( + childrenToProps(echartsConfigChildren) as ToViewReturn, + chartSize + ); + }, [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} + theme={mode !== 'map' ? themeConfig : undefined} + 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; +} + +CandleStickChartTmpComp = class extends CandleStickChartTmpComp { + 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 CandleStickChartComp = withExposingConfigs(CandleStickChartTmpComp, [ + 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 CandleStickChartCompWithDefault = withDefault(CandleStickChartComp, { + 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/candleStickChartComp/candleStickChartConstants.tsx b/client/packages/lowcoder-comps/src/comps/candleStickChartComp/candleStickChartConstants.tsx new file mode 100644 index 000000000..1de9ffdb3 --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/candleStickChartComp/candleStickChartConstants.tsx @@ -0,0 +1,299 @@ +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 "../chartComp/chartConfigs/barChartConfig"; +import { XAxisConfig, YAxisConfig } from "../chartComp/chartConfigs/cartesianAxisConfig"; +import { LegendConfig } from "../chartComp/chartConfigs/legendConfig"; +import { EchartsLegendConfig } from "../chartComp/chartConfigs/echartsLegendConfig"; +import { EchartsLabelConfig } from "../chartComp/chartConfigs/echartsLabelConfig"; +import { LineChartConfig } from "../chartComp/chartConfigs/lineChartConfig"; +import { PieChartConfig } from "../chartComp/chartConfigs/pieChartConfig"; +import { ScatterChartConfig } from "../chartComp/chartConfigs/scatterChartConfig"; +import { SeriesListComp } from "../chartComp/seriesComp"; +import { EChartsOption } from "echarts"; +import { i18nObjs, trans } from "i18n/comps"; +import { CandleStickChartConfig } from "../chartComp/chartConfigs/candleStickChartConfig"; + +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 = { + candleStick: CandleStickChartConfig, +}; + +const ChartOptionComp = withType(ChartOptionMap, "bar"); +const EchartsOptionComp = withType(EchartsOptionMap, "candleStick"); +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.defaultCandleStickChartOption), + echartsTitle: withDefault(StringControl, trans("candleStickChart.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 candleStickChartChildrenMap = { + selectedPoints: stateComp>([]), + lastInteractionData: stateComp | NonUIChartDataType>({}), + onEvent: eventHandlerControl([clickEvent] as const), + ...chartUiModeChildren, + ...chartJsonModeChildren, + ...chartMapModeChildren, +}; + +const chartUiChildrenMap = uiChildren(candleStickChartChildrenMap); +export type ChartCompPropsType = RecordConstructorToView; +export type ChartCompChildrenType = RecordConstructorToComp; diff --git a/client/packages/lowcoder-comps/src/comps/candleStickChartComp/candleStickChartPropertyView.tsx b/client/packages/lowcoder-comps/src/comps/candleStickChartComp/candleStickChartPropertyView.tsx new file mode 100644 index 000000000..9fa9060e3 --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/candleStickChartComp/candleStickChartPropertyView.tsx @@ -0,0 +1,54 @@ +import { CompAction } from "lowcoder-core"; +import { ChartCompChildrenType } from "./candleStickChartConstants"; +import { + hiddenPropertyView, + Section, + sectionNames, +} from "lowcoder-sdk"; +import { trans } from "i18n/comps"; +import { examplesUrl,optionUrl } from "../chartComp/chartConfigs/chartUrls"; + +export function candleStickChartPropertyView( + children: ChartCompChildrenType, + dispatch: (action: CompAction) => void +) { + + const jsonModePropertyView = ( + <> +
+ {children.echartsOption.propertyView({ + label: trans("chart.echartsOptionLabel"), + styleName: "higher", + tooltip: ( + + ), + })} + {children.echartsTitle.propertyView({ label: trans("candleStickChart.title") })} + {children.tooltip.propertyView({label: trans("candleStickChart.tooltip")})} +
+
+ {children.onEvent.propertyView()} +
+
+ {children.style.getPropertyView()} +
+
{hiddenPropertyView(children)}
+ + ); + + const getChatConfigByMode = (mode: string) => { + switch(mode) { + case "json": + return jsonModePropertyView; + } + } + return getChatConfigByMode(children.mode.getView()) +} diff --git a/client/packages/lowcoder-comps/src/comps/candleStickChartComp/candleStickChartUtils.ts b/client/packages/lowcoder-comps/src/comps/candleStickChartComp/candleStickChartUtils.ts new file mode 100644 index 000000000..00dcb0d2d --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/candleStickChartComp/candleStickChartUtils.ts @@ -0,0 +1,339 @@ +import { + CharOptionCompType, + ChartCompPropsType, + ChartSize, + noDataAxisConfig, + noDataPieChartConfig, +} from "comps/chartComp/chartConstants"; +import { getPieRadiusAndCenter } from "comps/chartComp/chartConfigs/pieChartConfig"; +import { EChartsOptionWithMap } from "../chartComp/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 "../chartComp/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": "axis", + "axisPointer": { + "type": "cross" + } + }, + "grid": { + "left": "10%", + "right": "10%", + "bottom": "10%", + }, + "xAxis": { + "type": "category", + "data": props.echartsOption.xAxis.data + }, + "yAxis": { + "type": "value", + "scale": true + }, + "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/chartComp/chartComp.tsx b/client/packages/lowcoder-comps/src/comps/chartComp/chartComp.tsx index 53873b2b5..8b56ec0c1 100644 --- a/client/packages/lowcoder-comps/src/comps/chartComp/chartComp.tsx +++ b/client/packages/lowcoder-comps/src/comps/chartComp/chartComp.tsx @@ -16,10 +16,7 @@ import { childrenToProps, depsConfig, genRandomKey, - JSONObject, - JSONValue, NameConfig, - ToViewReturn, UICompBuilder, withDefault, withExposingConfigs, @@ -28,6 +25,7 @@ import { ThemeContext, chartColorPalette, getPromiseAfterDispatch, + dropdownControl } from "lowcoder-sdk"; import { getEchartsLocale, trans } from "i18n/comps"; import { ItemColorComp } from "comps/chartComp/chartConfigs/lineChartConfig"; @@ -35,34 +33,32 @@ 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(chartChildrenMap, () => null) + return new UICompBuilder({mode:dropdownControl(chartModeOptions,'ui'),...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 = { @@ -87,36 +83,6 @@ 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) { @@ -124,8 +90,6 @@ 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, @@ -153,7 +117,7 @@ ChartTmpComp = withViewFn(ChartTmpComp, (comp) => { echartsCompInstance?.off("selectchanged"); document.removeEventListener('clickEvent', clickEventCallback) }; - }, [mode, onUIEvent]); + }, [onUIEvent]); const echartsConfigChildren = _.omit(comp.children, echartsConfigOmitChildren); const option = useMemo(() => { @@ -163,55 +127,9 @@ 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(() => { - 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]) + comp.children.mapInstance.dispatch(changeValueAction(null, false)) + }, [option]) return ( { } }} > - {(mode !== 'map' || (mode === 'map' && isMapScriptLoaded)) && ( - (echartsCompRef.current = e)} style={{ height: "100%" }} notMerge lazyUpdate opts={{ locale: getEchartsLocale() }} option={option} - theme={mode !== 'map' ? themeConfig : undefined} + theme={themeConfig} mode={mode} /> - )} ); }); @@ -357,74 +273,11 @@ let ChartComp = withExposingConfigs(ChartTmpComp, [ name: "data", desc: trans("chart.dataDesc"), depKeys: ["data", "mode"], - func: (input) => { - if (input.mode === "ui") { - return input.data; - } else { - // no data in json mode - return []; - } - }, + func: (input) => input.data, }), 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: [ diff --git a/client/packages/lowcoder-comps/src/comps/chartComp/chartConfigs/candleStickChartConfig.tsx b/client/packages/lowcoder-comps/src/comps/chartComp/chartConfigs/candleStickChartConfig.tsx new file mode 100644 index 000000000..7b7b5b103 --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/chartComp/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/chartComp/chartConfigs/echartsLabelConfig.tsx b/client/packages/lowcoder-comps/src/comps/chartComp/chartConfigs/echartsLabelConfig.tsx new file mode 100644 index 000000000..64b808e01 --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/chartComp/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/chartComp/chartConfigs/echartsLegendConfig.tsx b/client/packages/lowcoder-comps/src/comps/chartComp/chartConfigs/echartsLegendConfig.tsx new file mode 100644 index 000000000..41fb166fb --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/chartComp/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/chartComp/chartConfigs/echartsTitleConfig.tsx b/client/packages/lowcoder-comps/src/comps/chartComp/chartConfigs/echartsTitleConfig.tsx new file mode 100644 index 000000000..a9305de25 --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/chartComp/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/chartComp/chartConfigs/funnelChartConfig.tsx b/client/packages/lowcoder-comps/src/comps/chartComp/chartConfigs/funnelChartConfig.tsx new file mode 100644 index 000000000..84d3f4691 --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/chartComp/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/chartComp/chartConfigs/gaugeChartConfig.tsx b/client/packages/lowcoder-comps/src/comps/chartComp/chartConfigs/gaugeChartConfig.tsx new file mode 100644 index 000000000..a72c96cbb --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/chartComp/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/chartComp/chartConfigs/graphChartConfig.tsx b/client/packages/lowcoder-comps/src/comps/chartComp/chartConfigs/graphChartConfig.tsx new file mode 100644 index 000000000..dbc23403e --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/chartComp/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/chartComp/chartConfigs/heatmapChartConfig.tsx b/client/packages/lowcoder-comps/src/comps/chartComp/chartConfigs/heatmapChartConfig.tsx new file mode 100644 index 000000000..cbebb6410 --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/chartComp/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/chartComp/chartConfigs/radarChartConfig.tsx b/client/packages/lowcoder-comps/src/comps/chartComp/chartConfigs/radarChartConfig.tsx new file mode 100644 index 000000000..5615c2d73 --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/chartComp/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/chartComp/chartConfigs/sankeyChartConfig.tsx b/client/packages/lowcoder-comps/src/comps/chartComp/chartConfigs/sankeyChartConfig.tsx new file mode 100644 index 000000000..60c646c0b --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/chartComp/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/chartComp/chartConfigs/sunburstChartConfig.tsx b/client/packages/lowcoder-comps/src/comps/chartComp/chartConfigs/sunburstChartConfig.tsx new file mode 100644 index 000000000..8306911a3 --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/chartComp/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/chartComp/chartConfigs/themeriverChartConfig.tsx b/client/packages/lowcoder-comps/src/comps/chartComp/chartConfigs/themeriverChartConfig.tsx new file mode 100644 index 000000000..ae639eb22 --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/chartComp/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/chartComp/chartConfigs/treeChartConfig.tsx b/client/packages/lowcoder-comps/src/comps/chartComp/chartConfigs/treeChartConfig.tsx new file mode 100644 index 000000000..3f824008d --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/chartComp/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/chartComp/chartConfigs/treemapChartConfig.tsx b/client/packages/lowcoder-comps/src/comps/chartComp/chartConfigs/treemapChartConfig.tsx new file mode 100644 index 000000000..a201337ef --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/chartComp/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/chartComp/chartConstants.tsx b/client/packages/lowcoder-comps/src/comps/chartComp/chartConstants.tsx index 4242a5090..d43134234 100644 --- a/client/packages/lowcoder-comps/src/comps/chartComp/chartConstants.tsx +++ b/client/packages/lowcoder-comps/src/comps/chartComp/chartConstants.tsx @@ -13,20 +13,25 @@ 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 = [ { @@ -47,21 +52,6 @@ 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"), @@ -237,7 +227,13 @@ 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 = { @@ -255,6 +251,13 @@ 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 = { @@ -285,7 +288,6 @@ export type NonUIChartDataType = { } export const chartChildrenMap = { - mode: dropdownControl(chartModeOptions, "ui"), selectedPoints: stateComp>([]), lastInteractionData: stateComp | NonUIChartDataType>({}), onEvent: eventHandlerControl([clickEvent] as const), diff --git a/client/packages/lowcoder-comps/src/comps/chartComp/chartPropertyView.tsx b/client/packages/lowcoder-comps/src/comps/chartComp/chartPropertyView.tsx index 3b183d235..cfda76b14 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,27 +134,6 @@ export function chartPropertyView( const jsonModePropertyView = ( <> -
- {children.echartsOption.propertyView({ - label: trans("chart.echartsOptionLabel"), - styleName: "higher", - tooltip: ( - - ), - })} -
-
- {children.onEvent.propertyView()} -
-
{hiddenPropertyView(children)}
); @@ -223,12 +202,12 @@ export function chartPropertyView( } return ( <> -
+ {/*
{children.mode.propertyView({ label: "", radioButton: true, })} -
+
*/} {getChatConfigByMode(children.mode.getView())} ); diff --git a/client/packages/lowcoder-comps/src/comps/chartComp/chartUtils.ts b/client/packages/lowcoder-comps/src/comps/chartComp/chartUtils.ts index 8a1d912fe..57b908be5 100644 --- a/client/packages/lowcoder-comps/src/comps/chartComp/chartUtils.ts +++ b/client/packages/lowcoder-comps/src/comps/chartComp/chartUtils.ts @@ -130,8 +130,45 @@ export function getSeriesConfig(props: EchartsConfigProps) { // https://echarts.apache.org/en/option.html export function getEchartsConfig(props: EchartsConfigProps, chartSize?: ChartSize): EChartsOptionWithMap { if (props.mode === "json") { - return props.echartsOption ? props.echartsOption : {}; + 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, diff --git a/client/packages/lowcoder-comps/src/comps/funnelChartComp/funnelChartComp.tsx b/client/packages/lowcoder-comps/src/comps/funnelChartComp/funnelChartComp.tsx new file mode 100644 index 000000000..f26078335 --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/funnelChartComp/funnelChartComp.tsx @@ -0,0 +1,318 @@ +import { + changeChildAction, + changeValueAction, + CompAction, + CompActionTypes, + wrapChildAction, +} from "lowcoder-core"; +import { AxisFormatterComp, EchartsAxisType } from "../chartComp/chartConfigs/cartesianAxisConfig"; +import { funnelChartChildrenMap, ChartSize, getDataKeys } from "./funnelChartConstants"; +import { funnelChartPropertyView } from "./funnelChartPropertyView"; +import _ from "lodash"; +import { useContext, useEffect, useMemo, useRef, useState } from "react"; +import ReactResizeDetector from "react-resize-detector"; +import ReactECharts from "../chartComp/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/chartComp/chartConfigs/lineChartConfig"; +import { + echartsConfigOmitChildren, + getEchartsConfig, + getSelectedPoints, +} from "./funnelChartUtils"; +import 'echarts-extension-gmap'; +import log from "loglevel"; + +let clickEventCallback = () => {}; + +const chartModeOptions = [ + { + label: "ECharts JSON", + value: "json", + } +] as const; + +let FunnelChartTmpComp = (function () { + return new UICompBuilder({mode:dropdownControl(chartModeOptions,'json'),...funnelChartChildrenMap}, () => null) + .setPropertyViewFn(funnelChartPropertyView) + .build(); +})(); + +FunnelChartTmpComp = withViewFn(FunnelChartTmpComp, (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 option = useMemo(() => { + return getEchartsConfig( + childrenToProps(echartsConfigChildren) as ToViewReturn, + chartSize + ); + }, [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} + theme={mode !== 'map' ? themeConfig : undefined} + 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; +} + +FunnelChartTmpComp = class extends FunnelChartTmpComp { + 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 FunnelChartComp = withExposingConfigs(FunnelChartTmpComp, [ + 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 FunnelChartCompWithDefault = withDefault(FunnelChartComp, { + 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/funnelChartComp/funnelChartConstants.tsx b/client/packages/lowcoder-comps/src/comps/funnelChartComp/funnelChartConstants.tsx new file mode 100644 index 000000000..36e55cf52 --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/funnelChartComp/funnelChartConstants.tsx @@ -0,0 +1,309 @@ +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 "../chartComp/chartConfigs/barChartConfig"; +import { XAxisConfig, YAxisConfig } from "../chartComp/chartConfigs/cartesianAxisConfig"; +import { LegendConfig } from "../chartComp/chartConfigs/legendConfig"; +import { EchartsLegendConfig } from "../chartComp/chartConfigs/echartsLegendConfig"; +import { EchartsLabelConfig } from "../chartComp/chartConfigs/echartsLabelConfig"; +import { LineChartConfig } from "../chartComp/chartConfigs/lineChartConfig"; +import { PieChartConfig } from "../chartComp/chartConfigs/pieChartConfig"; +import { ScatterChartConfig } from "../chartComp/chartConfigs/scatterChartConfig"; +import { SeriesListComp } from "../chartComp/seriesComp"; +import { EChartsOption } from "echarts"; +import { i18nObjs, trans } from "i18n/comps"; +import { FunnelChartConfig } from "../chartComp/chartConfigs/funnelChartConfig"; +import { EchartsTitleConfig } from "comps/chartComp/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, +}; + +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.defaultFunnelChartOption), + echartsTitle: withDefault(StringControl, trans("funnelChart.defaultTitle")), + echartsLegendConfig: EchartsLegendConfig, + echartsLabelConfig: EchartsLabelConfig, + echartsConfig: EchartsOptionComp, + echartsTitleConfig:EchartsTitleConfig, + style: styleControl(EchartsStyle), + tooltip: withDefault(BoolControl, true), + label: withDefault(BoolControl, true), + legendVisibility: withDefault(BoolControl, true), + left:withDefault(NumberControl,trans('funnelChart.defaultLeft')), + top:withDefault(NumberControl,trans('funnelChart.defaultTop')), + bottom:withDefault(NumberControl,trans('funnelChart.defaultBottom')), + width:withDefault(NumberControl,trans('funnelChart.defaultWidth')), + min:withDefault(NumberControl,trans('funnelChart.defaultMin')), + max:withDefault(NumberControl,trans('funnelChart.defaultMax')), + gap:withDefault(NumberControl,trans('funnelChart.defaultGap')) +} + +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 funnelChartChildrenMap = { + selectedPoints: stateComp>([]), + lastInteractionData: stateComp | NonUIChartDataType>({}), + onEvent: eventHandlerControl([clickEvent] as const), + ...chartUiModeChildren, + ...chartJsonModeChildren, + ...chartMapModeChildren, +}; + +const chartUiChildrenMap = uiChildren(funnelChartChildrenMap); +export type ChartCompPropsType = RecordConstructorToView; +export type ChartCompChildrenType = RecordConstructorToComp; diff --git a/client/packages/lowcoder-comps/src/comps/funnelChartComp/funnelChartPropertyView.tsx b/client/packages/lowcoder-comps/src/comps/funnelChartComp/funnelChartPropertyView.tsx new file mode 100644 index 000000000..4f90f9ac9 --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/funnelChartComp/funnelChartPropertyView.tsx @@ -0,0 +1,66 @@ +import { CompAction } from "lowcoder-core"; +import { ChartCompChildrenType } from "./funnelChartConstants"; +import { + hiddenPropertyView, + Section, + sectionNames, +} from "lowcoder-sdk"; +import { trans } from "i18n/comps"; +import { examplesUrl,optionUrl } from "../chartComp/chartConfigs/chartUrls"; + +export function funnelChartPropertyView( + children: ChartCompChildrenType, + dispatch: (action: CompAction) => void +) { + + const jsonModePropertyView = ( + <> +
+ {children.echartsOption.propertyView({ + label: trans("chart.echartsOptionLabel"), + styleName: "higher", + tooltip: ( + + ), + })} + {children.legendVisibility.getView()&& children.echartsLegendConfig.getPropertyView()} + {children.label.getView()&& children.echartsLabelConfig.getPropertyView()} + {children.echartsTitleConfig.getPropertyView()} + {children.left.propertyView({ label: trans("funnelChart.left") })} + {children.top.propertyView({ label: trans("funnelChart.top") })} + {children.bottom.propertyView({ label: trans("funnelChart.bottom") })} + {children.width.propertyView({ label: trans("funnelChart.width") })} + {children.min.propertyView({ label: trans("funnelChart.min") })} + {children.max.propertyView({ label: trans("funnelChart.max") })} + {children.gap.propertyView({ label: trans("funnelChart.gap") })} + {children.echartsTitle.propertyView({ label: trans("funnelChart.title") })} + {children.tooltip.propertyView({label: trans("funnelChart.tooltip")})} + {children.label.propertyView({label: trans("funnelChart.label")})} + {children.legendVisibility.propertyView({label: trans("funnelChart.legendVisibility")})} +
+
+ {children.onEvent.propertyView()} +
+
+ {children.style.getPropertyView()} +
+
{hiddenPropertyView(children)}
+ + ); + + const getChatConfigByMode = (mode: string) => { + switch(mode) { + case "json": + return jsonModePropertyView; + } + } + return getChatConfigByMode(children.mode.getView()) +} diff --git a/client/packages/lowcoder-comps/src/comps/funnelChartComp/funnelChartUtils.ts b/client/packages/lowcoder-comps/src/comps/funnelChartComp/funnelChartUtils.ts new file mode 100644 index 000000000..2f2b33505 --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/funnelChartComp/funnelChartUtils.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 "../chartComp/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 "../chartComp/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":props.echartsTitleConfig.top + }, + "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": `${props.left}%`, + "top": props.top, + "bottom": props.bottom, + "width": `${props.left}%`, + "min": props.min, + "max": props.max, + "gap": props.gap, + "label": { + "show": props.label, + "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/gaugeChartComp/gaugeChartComp.tsx b/client/packages/lowcoder-comps/src/comps/gaugeChartComp/gaugeChartComp.tsx new file mode 100644 index 000000000..3823dd888 --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/gaugeChartComp/gaugeChartComp.tsx @@ -0,0 +1,319 @@ +import { + changeChildAction, + changeValueAction, + CompAction, + CompActionTypes, + wrapChildAction, +} from "lowcoder-core"; +import { AxisFormatterComp, EchartsAxisType } from "../chartComp/chartConfigs/cartesianAxisConfig"; +import { gaugeChartChildrenMap, ChartSize, getDataKeys } from "./gaugeChartConstants"; +import { gaugeChartPropertyView } from "./gaugeChartPropertyView"; +import _ from "lodash"; +import { useContext, useEffect, useMemo, useRef, useState } from "react"; +import ReactResizeDetector from "react-resize-detector"; +import ReactECharts from "../chartComp/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/chartComp/chartConfigs/lineChartConfig"; +import { + echartsConfigOmitChildren, + getEchartsConfig, + getSelectedPoints, +} from "./gaugeChartUtils"; +import 'echarts-extension-gmap'; +import log from "loglevel"; + +let clickEventCallback = () => {}; + +const chartModeOptions = [ + { + label: "ECharts JSON", + value: "json", + } +] as const; + +let GaugeChartTmpComp = (function () { + return new UICompBuilder({mode:dropdownControl(chartModeOptions,'json'),...gaugeChartChildrenMap}, () => null) + .setPropertyViewFn(gaugeChartPropertyView) + .build(); +})(); + +GaugeChartTmpComp = withViewFn(GaugeChartTmpComp, (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 option = useMemo(() => { + return getEchartsConfig( + childrenToProps(echartsConfigChildren) as ToViewReturn, + chartSize + ); + }, [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} + theme={mode !== 'map' ? themeConfig : undefined} + 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; +} + +GaugeChartTmpComp = class extends GaugeChartTmpComp { + 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 GaugeChartComp = withExposingConfigs(GaugeChartTmpComp, [ + 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 GaugeChartCompWithDefault = withDefault(GaugeChartComp, { + 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/gaugeChartComp/gaugeChartConstants.tsx b/client/packages/lowcoder-comps/src/comps/gaugeChartComp/gaugeChartConstants.tsx new file mode 100644 index 000000000..5f128faec --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/gaugeChartComp/gaugeChartConstants.tsx @@ -0,0 +1,309 @@ +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 "../chartComp/chartConfigs/barChartConfig"; +import { XAxisConfig, YAxisConfig } from "../chartComp/chartConfigs/cartesianAxisConfig"; +import { LegendConfig } from "../chartComp/chartConfigs/legendConfig"; +import { EchartsLegendConfig } from "../chartComp/chartConfigs/echartsLegendConfig"; +import { EchartsLabelConfig } from "../chartComp/chartConfigs/echartsLabelConfig"; +import { LineChartConfig } from "../chartComp/chartConfigs/lineChartConfig"; +import { PieChartConfig } from "../chartComp/chartConfigs/pieChartConfig"; +import { ScatterChartConfig } from "../chartComp/chartConfigs/scatterChartConfig"; +import { SeriesListComp } from "../chartComp/seriesComp"; +import { EChartsOption } from "echarts"; +import { i18nObjs, trans } from "i18n/comps"; +import { GaugeChartConfig } from "../chartComp/chartConfigs/gaugeChartConfig"; +import { EchartsTitleConfig } from "comps/chartComp/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 = { + gauge: GaugeChartConfig, +}; + +const ChartOptionComp = withType(ChartOptionMap, "bar"); +const EchartsOptionComp = withType(EchartsOptionMap, "gauge"); +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.defaultGaugeChartOption), + echartsTitle: withDefault(StringControl, trans("gaugeChart.defaultTitle")), + echartsLegendConfig: EchartsLegendConfig, + echartsLabelConfig: EchartsLabelConfig, + echartsConfig: EchartsOptionComp, + echartsTitleConfig:EchartsTitleConfig, + style: styleControl(EchartsStyle), + tooltip: withDefault(BoolControl, true), + legendVisibility: withDefault(BoolControl, true), + label: withDefault(BoolControl, true), + left:withDefault(NumberControl,trans('gaugeChart.defaultLeft')), + top:withDefault(NumberControl,trans('gaugeChart.defaultTop')), + bottom:withDefault(NumberControl,trans('gaugeChart.defaultBottom')), + width:withDefault(NumberControl,trans('gaugeChart.defaultWidth')), + min:withDefault(NumberControl,trans('gaugeChart.defaultMin')), + max:withDefault(NumberControl,trans('gaugeChart.defaultMax')), + gap:withDefault(NumberControl,trans('gaugeChart.defaultGap')) +} + +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 gaugeChartChildrenMap = { + selectedPoints: stateComp>([]), + lastInteractionData: stateComp | NonUIChartDataType>({}), + onEvent: eventHandlerControl([clickEvent] as const), + ...chartUiModeChildren, + ...chartJsonModeChildren, + ...chartMapModeChildren, +}; + +const chartUiChildrenMap = uiChildren(gaugeChartChildrenMap); +export type ChartCompPropsType = RecordConstructorToView; +export type ChartCompChildrenType = RecordConstructorToComp; diff --git a/client/packages/lowcoder-comps/src/comps/gaugeChartComp/gaugeChartPropertyView.tsx b/client/packages/lowcoder-comps/src/comps/gaugeChartComp/gaugeChartPropertyView.tsx new file mode 100644 index 000000000..b1ac4bad3 --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/gaugeChartComp/gaugeChartPropertyView.tsx @@ -0,0 +1,62 @@ +import { CompAction } from "lowcoder-core"; +import { ChartCompChildrenType } from "./gaugeChartConstants"; +import { + hiddenPropertyView, + Section, + sectionNames, +} from "lowcoder-sdk"; +import { trans } from "i18n/comps"; +import { examplesUrl,optionUrl } from "../chartComp/chartConfigs/chartUrls"; + +export function gaugeChartPropertyView( + children: ChartCompChildrenType, + dispatch: (action: CompAction) => void +) { + + const jsonModePropertyView = ( + <> +
+ {children.echartsOption.propertyView({ + label: trans("chart.echartsOptionLabel"), + styleName: "higher", + tooltip: ( + + ), + })} + {children.echartsTitleConfig.getPropertyView()} + {children.echartsTitle.propertyView({ label: trans("gaugeChart.title") })} + {/* {children.left.propertyView({ label: trans("gaugeChart.left") })} + {children.top.propertyView({ label: trans("gaugeChart.top") })} + {children.bottom.propertyView({ label: trans("gaugeChart.bottom") })} + {children.width.propertyView({ label: trans("gaugeChart.width") })} */} + {children.min.propertyView({ label: trans("gaugeChart.min") })} + {children.max.propertyView({ label: trans("gaugeChart.max") })} + {/* {children.gap.propertyView({ label: trans("gaugeChart.gap") })} */} + {children.tooltip.propertyView({ label: trans("gaugeChart.tooltip") })} +
+
+ {children.onEvent.propertyView()} +
+
+ {children.style.getPropertyView()} +
+
{hiddenPropertyView(children)}
+ + ); + + const getChatConfigByMode = (mode: string) => { + switch(mode) { + case "json": + return jsonModePropertyView; + } + } + return getChatConfigByMode(children.mode.getView()) +} diff --git a/client/packages/lowcoder-comps/src/comps/gaugeChartComp/gaugeChartUtils.ts b/client/packages/lowcoder-comps/src/comps/gaugeChartComp/gaugeChartUtils.ts new file mode 100644 index 000000000..3327823ce --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/gaugeChartComp/gaugeChartUtils.ts @@ -0,0 +1,330 @@ +import { + CharOptionCompType, + ChartCompPropsType, + ChartSize, + noDataAxisConfig, + noDataPieChartConfig, +} from "comps/chartComp/chartConstants"; +import { getPieRadiusAndCenter } from "comps/chartComp/chartConfigs/pieChartConfig"; +import { EChartsOptionWithMap } from "../chartComp/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 "../chartComp/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":props.echartsTitleConfig.top + }, + "backgroundColor": props?.style?.background, + "color": props.echartsOption.data?.map(data => data.color), + "tooltip": props.tooltip&&{ + "trigger": "item", + "formatter": "{a}
{b} : {c}%" + }, + "series": [ + { + "name": props.echartsConfig.type, + "type": props.echartsConfig.type, + "left": `${props.left}%`, + "top": props.top, + "bottom": props.bottom, + "width": `${props.left}%`, + "min": props.min, + "max": props.max, + "gap": props.gap, + 'detail': { + "backgroundColor": props?.style?.background, + }, + "label": { + "show": props.label, + "position": props.echartsLabelConfig.top + }, + "detail": { + "formatter": "{value}%" + }, + "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/graphChartComp/graphChartComp.tsx b/client/packages/lowcoder-comps/src/comps/graphChartComp/graphChartComp.tsx new file mode 100644 index 000000000..7b395901c --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/graphChartComp/graphChartComp.tsx @@ -0,0 +1,319 @@ +import { + changeChildAction, + changeValueAction, + CompAction, + CompActionTypes, + wrapChildAction, +} from "lowcoder-core"; +import { AxisFormatterComp, EchartsAxisType } from "../chartComp/chartConfigs/cartesianAxisConfig"; +import { graphChartChildrenMap, ChartSize, getDataKeys } from "./graphChartConstants"; +import { graphChartPropertyView } from "./graphChartPropertyView"; +import _ from "lodash"; +import { useContext, useEffect, useMemo, useRef, useState } from "react"; +import ReactResizeDetector from "react-resize-detector"; +import ReactECharts from "../chartComp/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/chartComp/chartConfigs/lineChartConfig"; +import { + echartsConfigOmitChildren, + getEchartsConfig, + getSelectedPoints, +} from "./graphChartUtils"; +import 'echarts-extension-gmap'; +import log from "loglevel"; + +let clickEventCallback = () => {}; + +const chartModeOptions = [ + { + label: "ECharts JSON", + value: "json", + } +] as const; + +let GraphChartTmpComp = (function () { + return new UICompBuilder({mode:dropdownControl(chartModeOptions,'json'),...graphChartChildrenMap}, () => null) + .setPropertyViewFn(graphChartPropertyView) + .build(); +})(); + +GraphChartTmpComp = withViewFn(GraphChartTmpComp, (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 option = useMemo(() => { + return getEchartsConfig( + childrenToProps(echartsConfigChildren) as ToViewReturn, + chartSize + ); + }, [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} + theme={mode !== 'map' ? themeConfig : undefined} + 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; +} + +GraphChartTmpComp = class extends GraphChartTmpComp { + 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 GraphChartComp = withExposingConfigs(GraphChartTmpComp, [ + 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 GraphChartCompWithDefault = withDefault(GraphChartComp, { + 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/graphChartComp/graphChartConstants.tsx b/client/packages/lowcoder-comps/src/comps/graphChartComp/graphChartConstants.tsx new file mode 100644 index 000000000..a3c5c0095 --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/graphChartComp/graphChartConstants.tsx @@ -0,0 +1,299 @@ +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 "../chartComp/chartConfigs/barChartConfig"; +import { XAxisConfig, YAxisConfig } from "../chartComp/chartConfigs/cartesianAxisConfig"; +import { LegendConfig } from "../chartComp/chartConfigs/legendConfig"; +import { EchartsLegendConfig } from "../chartComp/chartConfigs/echartsLegendConfig"; +import { EchartsLabelConfig } from "../chartComp/chartConfigs/echartsLabelConfig"; +import { LineChartConfig } from "../chartComp/chartConfigs/lineChartConfig"; +import { PieChartConfig } from "../chartComp/chartConfigs/pieChartConfig"; +import { ScatterChartConfig } from "../chartComp/chartConfigs/scatterChartConfig"; +import { SeriesListComp } from "../chartComp/seriesComp"; +import { EChartsOption } from "echarts"; +import { i18nObjs, trans } from "i18n/comps"; +import { GraphChartConfig } from "../chartComp/chartConfigs/graphChartConfig"; + +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 = { + graph: GraphChartConfig, +}; + +const ChartOptionComp = withType(ChartOptionMap, "bar"); +const EchartsOptionComp = withType(EchartsOptionMap, "graph"); +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.defaultGraphChartOption), + echartsTitle: withDefault(StringControl, trans("graphChart.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 graphChartChildrenMap = { + selectedPoints: stateComp>([]), + lastInteractionData: stateComp | NonUIChartDataType>({}), + onEvent: eventHandlerControl([clickEvent] as const), + ...chartUiModeChildren, + ...chartJsonModeChildren, + ...chartMapModeChildren, +}; + +const chartUiChildrenMap = uiChildren(graphChartChildrenMap); +export type ChartCompPropsType = RecordConstructorToView; +export type ChartCompChildrenType = RecordConstructorToComp; diff --git a/client/packages/lowcoder-comps/src/comps/graphChartComp/graphChartPropertyView.tsx b/client/packages/lowcoder-comps/src/comps/graphChartComp/graphChartPropertyView.tsx new file mode 100644 index 000000000..3ad76fb1e --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/graphChartComp/graphChartPropertyView.tsx @@ -0,0 +1,54 @@ +import { CompAction } from "lowcoder-core"; +import { ChartCompChildrenType } from "./graphChartConstants"; +import { + hiddenPropertyView, + Section, + sectionNames, +} from "lowcoder-sdk"; +import { trans } from "i18n/comps"; +import { examplesUrl,optionUrl } from "../chartComp/chartConfigs/chartUrls"; + +export function graphChartPropertyView( + children: ChartCompChildrenType, + dispatch: (action: CompAction) => void +) { + + const jsonModePropertyView = ( + <> +
+ {children.echartsOption.propertyView({ + label: trans("chart.echartsOptionLabel"), + styleName: "higher", + tooltip: ( + + ), + })} + {children.echartsTitle.propertyView({ label: trans("graphChart.title") })} + {children.tooltip.propertyView({label: trans("graphChart.tooltip")})} +
+
+ {children.onEvent.propertyView()} +
+
+ {children.style.getPropertyView()} +
+
{hiddenPropertyView(children)}
+ + ); + + const getChatConfigByMode = (mode: string) => { + switch(mode) { + case "json": + return jsonModePropertyView; + } + } + return getChatConfigByMode(children.mode.getView()) +} diff --git a/client/packages/lowcoder-comps/src/comps/graphChartComp/graphChartUtils.ts b/client/packages/lowcoder-comps/src/comps/graphChartComp/graphChartUtils.ts new file mode 100644 index 000000000..df231bd0d --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/graphChartComp/graphChartUtils.ts @@ -0,0 +1,319 @@ +import { + CharOptionCompType, + ChartCompPropsType, + ChartSize, + noDataAxisConfig, + noDataPieChartConfig, +} from "comps/chartComp/chartConstants"; +import { getPieRadiusAndCenter } from "comps/chartComp/chartConfigs/pieChartConfig"; +import { EChartsOptionWithMap } from "../chartComp/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 "../chartComp/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" + }, + 'series': [ + { + "type": "graph", + "layout": "force", + "force": { + "repulsion": 100, + "gravity": 0.1, + "edgeLength": 100 + }, + 'categories': props.echartsOption.categories, + 'links': props.echartsOption.links, + 'nodes': props.echartsOption.nodes, + } + ] +} + 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/heatmapChartComp/heatmapChartComp.tsx b/client/packages/lowcoder-comps/src/comps/heatmapChartComp/heatmapChartComp.tsx new file mode 100644 index 000000000..760b79dca --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/heatmapChartComp/heatmapChartComp.tsx @@ -0,0 +1,318 @@ +import { + changeChildAction, + changeValueAction, + CompAction, + CompActionTypes, + wrapChildAction, +} from "lowcoder-core"; +import { AxisFormatterComp, EchartsAxisType } from "../chartComp/chartConfigs/cartesianAxisConfig"; +import { heatmapChartChildrenMap, ChartSize, getDataKeys } from "./heatmapChartConstants"; +import { heatmapChartPropertyView } from "./heatmapChartPropertyView"; +import _ from "lodash"; +import { useContext, useEffect, useMemo, useRef, useState } from "react"; +import ReactResizeDetector from "react-resize-detector"; +import ReactECharts from "../chartComp/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/chartComp/chartConfigs/lineChartConfig"; +import { + echartsConfigOmitChildren, + getEchartsConfig, + getSelectedPoints, +} from "./heatmapChartUtils"; +import 'echarts-extension-gmap'; +import log from "loglevel"; + +let clickEventCallback = () => {}; + +const chartModeOptions = [ + { + label: "ECharts JSON", + value: "json", + } +] as const; + +let HeatmapChartTmpComp = (function () { + return new UICompBuilder({mode:dropdownControl(chartModeOptions,'json'),...heatmapChartChildrenMap}, () => null) + .setPropertyViewFn(heatmapChartPropertyView) + .build(); +})(); + +HeatmapChartTmpComp = withViewFn(HeatmapChartTmpComp, (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 option = useMemo(() => { + return getEchartsConfig( + childrenToProps(echartsConfigChildren) as ToViewReturn, + chartSize + ); + }, [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} + theme={mode !== 'map' ? themeConfig : undefined} + 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; +} + +HeatmapChartTmpComp = class extends HeatmapChartTmpComp { + 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 HeatmapChartComp = withExposingConfigs(HeatmapChartTmpComp, [ + 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 HeatmapChartCompWithDefault = withDefault(HeatmapChartComp, { + 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/heatmapChartComp/heatmapChartConstants.tsx b/client/packages/lowcoder-comps/src/comps/heatmapChartComp/heatmapChartConstants.tsx new file mode 100644 index 000000000..d4a8a9a29 --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/heatmapChartComp/heatmapChartConstants.tsx @@ -0,0 +1,299 @@ +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 "../chartComp/chartConfigs/barChartConfig"; +import { XAxisConfig, YAxisConfig } from "../chartComp/chartConfigs/cartesianAxisConfig"; +import { LegendConfig } from "../chartComp/chartConfigs/legendConfig"; +import { EchartsLegendConfig } from "../chartComp/chartConfigs/echartsLegendConfig"; +import { EchartsLabelConfig } from "../chartComp/chartConfigs/echartsLabelConfig"; +import { LineChartConfig } from "../chartComp/chartConfigs/lineChartConfig"; +import { PieChartConfig } from "../chartComp/chartConfigs/pieChartConfig"; +import { ScatterChartConfig } from "../chartComp/chartConfigs/scatterChartConfig"; +import { SeriesListComp } from "../chartComp/seriesComp"; +import { EChartsOption } from "echarts"; +import { i18nObjs, trans } from "i18n/comps"; +import { HeatmapChartConfig } from "comps/chartComp/chartConfigs/heatmapChartConfig"; + +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 = { + heatmap: HeatmapChartConfig, +}; + +const ChartOptionComp = withType(ChartOptionMap, "bar"); +const EchartsOptionComp = withType(EchartsOptionMap, "heatmap"); +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.defaultHeatmapChartOption), + echartsTitle: withDefault(StringControl, trans("heatmapChart.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 heatmapChartChildrenMap = { + selectedPoints: stateComp>([]), + lastInteractionData: stateComp | NonUIChartDataType>({}), + onEvent: eventHandlerControl([clickEvent] as const), + ...chartUiModeChildren, + ...chartJsonModeChildren, + ...chartMapModeChildren, +}; + +const chartUiChildrenMap = uiChildren(heatmapChartChildrenMap); +export type ChartCompPropsType = RecordConstructorToView; +export type ChartCompChildrenType = RecordConstructorToComp; diff --git a/client/packages/lowcoder-comps/src/comps/heatmapChartComp/heatmapChartPropertyView.tsx b/client/packages/lowcoder-comps/src/comps/heatmapChartComp/heatmapChartPropertyView.tsx new file mode 100644 index 000000000..8f990546e --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/heatmapChartComp/heatmapChartPropertyView.tsx @@ -0,0 +1,54 @@ +import { CompAction } from "lowcoder-core"; +import { ChartCompChildrenType } from "./heatmapChartConstants"; +import { + hiddenPropertyView, + Section, + sectionNames, +} from "lowcoder-sdk"; +import { trans } from "i18n/comps"; +import { examplesUrl,optionUrl } from "../chartComp/chartConfigs/chartUrls"; + +export function heatmapChartPropertyView( + children: ChartCompChildrenType, + dispatch: (action: CompAction) => void +) { + + const jsonModePropertyView = ( + <> +
+ {children.echartsOption.propertyView({ + label: trans("chart.echartsOptionLabel"), + styleName: "higher", + tooltip: ( + + ), + })} + {children.echartsTitle.propertyView({ label: trans("heatmapChart.title") })} + {children.tooltip.propertyView({label: trans("heatmapChart.tooltip")})} +
+
+ {children.onEvent.propertyView()} +
+
+ {children.style.getPropertyView()} +
+
{hiddenPropertyView(children)}
+ + ); + + const getChatConfigByMode = (mode: string) => { + switch(mode) { + case "json": + return jsonModePropertyView; + } + } + return getChatConfigByMode(children.mode.getView()) +} diff --git a/client/packages/lowcoder-comps/src/comps/heatmapChartComp/heatmapChartUtils.ts b/client/packages/lowcoder-comps/src/comps/heatmapChartComp/heatmapChartUtils.ts new file mode 100644 index 000000000..6d9c0bef9 --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/heatmapChartComp/heatmapChartUtils.ts @@ -0,0 +1,336 @@ +import { + CharOptionCompType, + ChartCompPropsType, + ChartSize, + noDataAxisConfig, + noDataPieChartConfig, +} from "comps/chartComp/chartConstants"; +import { getPieRadiusAndCenter } from "comps/chartComp/chartConfigs/pieChartConfig"; +import { EChartsOptionWithMap } from "../chartComp/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 "../chartComp/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&&{ + "position": "top" + }, + "grid": { + "height": "50%", + "top": "10%" + }, + "visualMap": { + "min": 0, + "max": 100, + "calculable": true, + "orient": "horizontal", + "left": "center", + "bottom": "15%" + }, + "legend": { + "data": ["Heatmap"], + "left": "left" + }, + 'xAxis': { + "type": "category", + 'data':props.echartsOption.xAxis.data + }, + 'yAxis': { + "type": "category", + data: props.echartsOption.yAxis.data + }, + 'series': [ + { + 'name': 'Heatmap', + 'type': 'heatmap', + '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/mapComp/mapComp.tsx b/client/packages/lowcoder-comps/src/comps/mapComp/mapComp.tsx new file mode 100644 index 000000000..5c0e2769b --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/mapComp/mapComp.tsx @@ -0,0 +1,386 @@ +import { + changeChildAction, + changeValueAction, + CompAction, + CompActionTypes, + wrapChildAction, +} from "lowcoder-core"; +import { AxisFormatterComp, EchartsAxisType } from "../chartComp/chartConfigs/cartesianAxisConfig"; +import { chartChildrenMap, ChartSize, getDataKeys } from "../chartComp/chartConstants"; +import { chartPropertyView } from "../chartComp/chartPropertyView"; +import _ from "lodash"; +import { useContext, useEffect, useMemo, useRef, useState } from "react"; +import ReactResizeDetector from "react-resize-detector"; +import ReactECharts from "../chartComp/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, + loadGoogleMapsScript, +} from "comps/chartComp/chartUtils"; +import 'echarts-extension-gmap'; +import log from "loglevel"; + +let clickEventCallback = () => {}; + +const chartModeOptions = [ + { + label: "Map", + value: "map", + } +] as const; + +let MapTmpComp = (function () { + return new UICompBuilder({mode:dropdownControl(chartModeOptions,'map'),...chartChildrenMap}, () => null) + .setPropertyViewFn(chartPropertyView) + .build(); +})(); + +MapTmpComp = withViewFn(MapTmpComp, (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 = { + 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) + }; + }, [mapScriptLoaded]); + + const echartsConfigChildren = _.omit(comp.children, echartsConfigOmitChildren); + const option = useMemo(() => { + return getEchartsConfig( + childrenToProps(echartsConfigChildren) as ToViewReturn, + chartSize + ); + }, [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(() => { + if(comp.children.mapInstance.value) return; + + const gMapScript = loadGoogleMapsScript(apiKey); + if(isMapScriptLoaded) { + handleOnMapScriptLoad(); + return; + } + gMapScript.addEventListener('load', handleOnMapScriptLoad); + return () => { + gMapScript.removeEventListener('load', handleOnMapScriptLoad); + } + }, [apiKey, option]) + + useEffect(() => { + onMapEvent('centerPositionChange'); + }, [mapCenterPosition.lat, mapCenterPosition.lng]) + + useEffect(() => { + onMapEvent('zoomLevelChange'); + }, [mapZoomlevel]) + + 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={undefined} + 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; +} + +MapTmpComp = class extends MapTmpComp { + 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 MapComp = withExposingConfigs(MapTmpComp, [ + 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")), +]); + +MapComp = withMethodExposing(MapComp, [ + { + 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 MapCompWithDefault = withDefault(MapComp, { + 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/radarChartComp/radarChartComp.tsx b/client/packages/lowcoder-comps/src/comps/radarChartComp/radarChartComp.tsx new file mode 100644 index 000000000..b9a9af5be --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/radarChartComp/radarChartComp.tsx @@ -0,0 +1,318 @@ +import { + changeChildAction, + changeValueAction, + CompAction, + CompActionTypes, + wrapChildAction, +} from "lowcoder-core"; +import { AxisFormatterComp, EchartsAxisType } from "../chartComp/chartConfigs/cartesianAxisConfig"; +import { radarChartChildrenMap, ChartSize, getDataKeys } from "./radarChartConstants"; +import { radarChartPropertyView } from "./radarChartPropertyView"; +import _ from "lodash"; +import { useContext, useEffect, useMemo, useRef, useState } from "react"; +import ReactResizeDetector from "react-resize-detector"; +import ReactECharts from "../chartComp/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/chartComp/chartConfigs/lineChartConfig"; +import { + echartsConfigOmitChildren, + getEchartsConfig, + getSelectedPoints, +} from "./radarChartUtils"; +import 'echarts-extension-gmap'; +import log from "loglevel"; + +let clickEventCallback = () => {}; + +const chartModeOptions = [ + { + label: "ECharts JSON", + value: "json", + } +] as const; + +let RadarChartTmpComp = (function () { + return new UICompBuilder({mode:dropdownControl(chartModeOptions,'json'),...radarChartChildrenMap}, () => null) + .setPropertyViewFn(radarChartPropertyView) + .build(); +})(); + +RadarChartTmpComp = withViewFn(RadarChartTmpComp, (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 option = useMemo(() => { + return getEchartsConfig( + childrenToProps(echartsConfigChildren) as ToViewReturn, + chartSize + ); + }, [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} + theme={mode !== 'map' ? themeConfig : undefined} + 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; +} + +RadarChartTmpComp = class extends RadarChartTmpComp { + 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 RadarChartComp = withExposingConfigs(RadarChartTmpComp, [ + 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 RadarChartCompWithDefault = withDefault(RadarChartComp, { + 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/radarChartComp/radarChartConstants.tsx b/client/packages/lowcoder-comps/src/comps/radarChartComp/radarChartConstants.tsx new file mode 100644 index 000000000..a96f7a36d --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/radarChartComp/radarChartConstants.tsx @@ -0,0 +1,299 @@ +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 "../chartComp/chartConfigs/barChartConfig"; +import { XAxisConfig, YAxisConfig } from "../chartComp/chartConfigs/cartesianAxisConfig"; +import { LegendConfig } from "../chartComp/chartConfigs/legendConfig"; +import { EchartsLegendConfig } from "../chartComp/chartConfigs/echartsLegendConfig"; +import { EchartsLabelConfig } from "../chartComp/chartConfigs/echartsLabelConfig"; +import { LineChartConfig } from "../chartComp/chartConfigs/lineChartConfig"; +import { PieChartConfig } from "../chartComp/chartConfigs/pieChartConfig"; +import { ScatterChartConfig } from "../chartComp/chartConfigs/scatterChartConfig"; +import { SeriesListComp } from "../chartComp/seriesComp"; +import { EChartsOption } from "echarts"; +import { i18nObjs, trans } from "i18n/comps"; +import { RadarChartConfig } from "comps/chartComp/chartConfigs/radarChartConfig"; + +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 = { + radar: RadarChartConfig, +}; + +const ChartOptionComp = withType(ChartOptionMap, "bar"); +const EchartsOptionComp = withType(EchartsOptionMap, "radar"); +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.defaultRadarChartOption), + echartsTitle: withDefault(StringControl, trans("radarChart.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 radarChartChildrenMap = { + selectedPoints: stateComp>([]), + lastInteractionData: stateComp | NonUIChartDataType>({}), + onEvent: eventHandlerControl([clickEvent] as const), + ...chartUiModeChildren, + ...chartJsonModeChildren, + ...chartMapModeChildren, +}; + +const chartUiChildrenMap = uiChildren(radarChartChildrenMap); +export type ChartCompPropsType = RecordConstructorToView; +export type ChartCompChildrenType = RecordConstructorToComp; diff --git a/client/packages/lowcoder-comps/src/comps/radarChartComp/radarChartPropertyView.tsx b/client/packages/lowcoder-comps/src/comps/radarChartComp/radarChartPropertyView.tsx new file mode 100644 index 000000000..f6ea20b14 --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/radarChartComp/radarChartPropertyView.tsx @@ -0,0 +1,54 @@ +import { CompAction } from "lowcoder-core"; +import { ChartCompChildrenType } from "./radarChartConstants"; +import { + hiddenPropertyView, + Section, + sectionNames, +} from "lowcoder-sdk"; +import { trans } from "i18n/comps"; +import { examplesUrl,optionUrl } from "../chartComp/chartConfigs/chartUrls"; + +export function radarChartPropertyView( + children: ChartCompChildrenType, + dispatch: (action: CompAction) => void +) { + + const jsonModePropertyView = ( + <> +
+ {children.echartsOption.propertyView({ + label: trans("chart.echartsOptionLabel"), + styleName: "higher", + tooltip: ( + + ), + })} + {children.echartsTitle.propertyView({ label: trans("radarChart.title") })} + {children.tooltip.propertyView({label: trans("radarChart.tooltip")})} +
+
+ {children.onEvent.propertyView()} +
+
+ {children.style.getPropertyView()} +
+
{hiddenPropertyView(children)}
+ + ); + + const getChatConfigByMode = (mode: string) => { + switch(mode) { + case "json": + return jsonModePropertyView; + } + } + return getChatConfigByMode(children.mode.getView()) +} diff --git a/client/packages/lowcoder-comps/src/comps/radarChartComp/radarChartUtils.ts b/client/packages/lowcoder-comps/src/comps/radarChartComp/radarChartUtils.ts new file mode 100644 index 000000000..3ce2d0f7b --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/radarChartComp/radarChartUtils.ts @@ -0,0 +1,320 @@ +import { + CharOptionCompType, + ChartCompPropsType, + ChartSize, + noDataAxisConfig, + noDataPieChartConfig, +} from "comps/chartComp/chartConstants"; +import { getPieRadiusAndCenter } from "comps/chartComp/chartConfigs/pieChartConfig"; +import { EChartsOptionWithMap } from "../chartComp/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 "../chartComp/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": { + "trigger": "axis", + "formatter": function(params) { + let tooltipText = params[0].name + '
'; + params.forEach(function(item) { + tooltipText += item.seriesName + ': ' + item.value + '
'; + }); + return tooltipText; + } + }, + "radar": [ + { + "indicator": props.echartsOption.indicator, + "center": ["50%", "50%"], + "radius": "60%" + } + ], + "series": props.echartsOption.series.map(option=>{return {...option,type:'radar'}}) +} + 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/sankeyChartComp/sankeyChartComp.tsx b/client/packages/lowcoder-comps/src/comps/sankeyChartComp/sankeyChartComp.tsx new file mode 100644 index 000000000..680f47771 --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/sankeyChartComp/sankeyChartComp.tsx @@ -0,0 +1,318 @@ +import { + changeChildAction, + changeValueAction, + CompAction, + CompActionTypes, + wrapChildAction, +} from "lowcoder-core"; +import { AxisFormatterComp, EchartsAxisType } from "../chartComp/chartConfigs/cartesianAxisConfig"; +import { sankeyChartChildrenMap, ChartSize, getDataKeys } from "./sankeyChartConstants"; +import { sankeyChartPropertyView } from "./sankeyChartPropertyView"; +import _ from "lodash"; +import { useContext, useEffect, useMemo, useRef, useState } from "react"; +import ReactResizeDetector from "react-resize-detector"; +import ReactECharts from "../chartComp/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/chartComp/chartConfigs/lineChartConfig"; +import { + echartsConfigOmitChildren, + getEchartsConfig, + getSelectedPoints, +} from "./sankeyChartUtils"; +import 'echarts-extension-gmap'; +import log from "loglevel"; + +let clickEventCallback = () => {}; + +const chartModeOptions = [ + { + label: "ECharts JSON", + value: "json", + } +] as const; + +let SankeyChartTmpComp = (function () { + return new UICompBuilder({mode:dropdownControl(chartModeOptions,'json'),...sankeyChartChildrenMap}, () => null) + .setPropertyViewFn(sankeyChartPropertyView) + .build(); +})(); + +SankeyChartTmpComp = withViewFn(SankeyChartTmpComp, (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 option = useMemo(() => { + return getEchartsConfig( + childrenToProps(echartsConfigChildren) as ToViewReturn, + chartSize + ); + }, [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} + theme={mode !== 'map' ? themeConfig : undefined} + 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; +} + +SankeyChartTmpComp = class extends SankeyChartTmpComp { + 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 SankeyChartComp = withExposingConfigs(SankeyChartTmpComp, [ + 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 SankeyChartCompWithDefault = withDefault(SankeyChartComp, { + 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/sankeyChartComp/sankeyChartConstants.tsx b/client/packages/lowcoder-comps/src/comps/sankeyChartComp/sankeyChartConstants.tsx new file mode 100644 index 000000000..bc106cebe --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/sankeyChartComp/sankeyChartConstants.tsx @@ -0,0 +1,299 @@ +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 "../chartComp/chartConfigs/barChartConfig"; +import { XAxisConfig, YAxisConfig } from "../chartComp/chartConfigs/cartesianAxisConfig"; +import { LegendConfig } from "../chartComp/chartConfigs/legendConfig"; +import { EchartsLegendConfig } from "../chartComp/chartConfigs/echartsLegendConfig"; +import { EchartsLabelConfig } from "../chartComp/chartConfigs/echartsLabelConfig"; +import { LineChartConfig } from "../chartComp/chartConfigs/lineChartConfig"; +import { PieChartConfig } from "../chartComp/chartConfigs/pieChartConfig"; +import { ScatterChartConfig } from "../chartComp/chartConfigs/scatterChartConfig"; +import { SeriesListComp } from "../chartComp/seriesComp"; +import { EChartsOption } from "echarts"; +import { i18nObjs, trans } from "i18n/comps"; +import { SankeyChartConfig } from "../chartComp/chartConfigs/sankeyChartConfig"; + +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 = { + sankey: SankeyChartConfig, +}; + +const ChartOptionComp = withType(ChartOptionMap, "bar"); +const EchartsOptionComp = withType(EchartsOptionMap, "sankey"); +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.defaultSankeyChartOption), + echartsTitle: withDefault(StringControl, trans("sankeyChart.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 sankeyChartChildrenMap = { + selectedPoints: stateComp>([]), + lastInteractionData: stateComp | NonUIChartDataType>({}), + onEvent: eventHandlerControl([clickEvent] as const), + ...chartUiModeChildren, + ...chartJsonModeChildren, + ...chartMapModeChildren, +}; + +const chartUiChildrenMap = uiChildren(sankeyChartChildrenMap); +export type ChartCompPropsType = RecordConstructorToView; +export type ChartCompChildrenType = RecordConstructorToComp; diff --git a/client/packages/lowcoder-comps/src/comps/sankeyChartComp/sankeyChartPropertyView.tsx b/client/packages/lowcoder-comps/src/comps/sankeyChartComp/sankeyChartPropertyView.tsx new file mode 100644 index 000000000..da18ef2a4 --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/sankeyChartComp/sankeyChartPropertyView.tsx @@ -0,0 +1,55 @@ +import { CompAction } from "lowcoder-core"; +import { ChartCompChildrenType } from "./sankeyChartConstants"; +import { + hiddenPropertyView, + Section, + sectionNames, +} from "lowcoder-sdk"; +import { trans } from "i18n/comps"; +import { examplesUrl,optionUrl } from "../chartComp/chartConfigs/chartUrls"; + +export function sankeyChartPropertyView( + children: ChartCompChildrenType, + dispatch: (action: CompAction) => void +) { + + const jsonModePropertyView = ( + <> +
+ {children.echartsOption.propertyView({ + label: trans("chart.echartsOptionLabel"), + styleName: "higher", + tooltip: ( + + ), + })} + {children.echartsLabelConfig.getPropertyView()} + {children.echartsTitle.propertyView({ label: trans("sankeyChart.title") })} + {children.tooltip.propertyView({label: trans("sankeyChart.tooltip")})} +
+
+ {children.onEvent.propertyView()} +
+
+ {children.style.getPropertyView()} +
+
{hiddenPropertyView(children)}
+ + ); + + const getChatConfigByMode = (mode: string) => { + switch(mode) { + case "json": + return jsonModePropertyView; + } + } + return getChatConfigByMode(children.mode.getView()) +} diff --git a/client/packages/lowcoder-comps/src/comps/sankeyChartComp/sankeyChartUtils.ts b/client/packages/lowcoder-comps/src/comps/sankeyChartComp/sankeyChartUtils.ts new file mode 100644 index 000000000..819fa4e67 --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/sankeyChartComp/sankeyChartUtils.ts @@ -0,0 +1,325 @@ +import { + CharOptionCompType, + ChartCompPropsType, + ChartSize, + noDataAxisConfig, + noDataPieChartConfig, +} from "comps/chartComp/chartConstants"; +import { getPieRadiusAndCenter } from "comps/chartComp/chartConfigs/pieChartConfig"; +import { EChartsOptionWithMap } from "../chartComp/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 "../chartComp/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}%" + }, + "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, + "links":props.echartsOption.links + } + ] +} + 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/sunburstChartComp/sunburstChartComp.tsx b/client/packages/lowcoder-comps/src/comps/sunburstChartComp/sunburstChartComp.tsx new file mode 100644 index 000000000..a136b12b6 --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/sunburstChartComp/sunburstChartComp.tsx @@ -0,0 +1,318 @@ +import { + changeChildAction, + changeValueAction, + CompAction, + CompActionTypes, + wrapChildAction, +} from "lowcoder-core"; +import { AxisFormatterComp, EchartsAxisType } from "../chartComp/chartConfigs/cartesianAxisConfig"; +import { sunburstChartChildrenMap, ChartSize, getDataKeys } from "./sunburstChartConstants"; +import { sunburstChartPropertyView } from "./sunburstChartPropertyView"; +import _ from "lodash"; +import { useContext, useEffect, useMemo, useRef, useState } from "react"; +import ReactResizeDetector from "react-resize-detector"; +import ReactECharts from "../chartComp/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/chartComp/chartConfigs/lineChartConfig"; +import { + echartsConfigOmitChildren, + getEchartsConfig, + getSelectedPoints, +} from "./sunburstChartUtils"; +import 'echarts-extension-gmap'; +import log from "loglevel"; + +let clickEventCallback = () => {}; + +const chartModeOptions = [ + { + label: "ECharts JSON", + value: "json", + } +] as const; + +let SunburstChartTmpComp = (function () { + return new UICompBuilder({mode:dropdownControl(chartModeOptions,'json'),...sunburstChartChildrenMap}, () => null) + .setPropertyViewFn(sunburstChartPropertyView) + .build(); +})(); + +SunburstChartTmpComp = withViewFn(SunburstChartTmpComp, (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 option = useMemo(() => { + return getEchartsConfig( + childrenToProps(echartsConfigChildren) as ToViewReturn, + chartSize + ); + }, [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} + theme={mode !== 'map' ? themeConfig : undefined} + 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; +} + +SunburstChartTmpComp = class extends SunburstChartTmpComp { + 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 SunburstChartComp = withExposingConfigs(SunburstChartTmpComp, [ + 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 SunburstChartCompWithDefault = withDefault(SunburstChartComp, { + 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/sunburstChartComp/sunburstChartConstants.tsx b/client/packages/lowcoder-comps/src/comps/sunburstChartComp/sunburstChartConstants.tsx new file mode 100644 index 000000000..c4e7aa539 --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/sunburstChartComp/sunburstChartConstants.tsx @@ -0,0 +1,299 @@ +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 "../chartComp/chartConfigs/barChartConfig"; +import { XAxisConfig, YAxisConfig } from "../chartComp/chartConfigs/cartesianAxisConfig"; +import { LegendConfig } from "../chartComp/chartConfigs/legendConfig"; +import { EchartsLegendConfig } from "../chartComp/chartConfigs/echartsLegendConfig"; +import { EchartsLabelConfig } from "../chartComp/chartConfigs/echartsLabelConfig"; +import { LineChartConfig } from "../chartComp/chartConfigs/lineChartConfig"; +import { PieChartConfig } from "../chartComp/chartConfigs/pieChartConfig"; +import { ScatterChartConfig } from "../chartComp/chartConfigs/scatterChartConfig"; +import { SeriesListComp } from "../chartComp/seriesComp"; +import { EChartsOption } from "echarts"; +import { i18nObjs, trans } from "i18n/comps"; +import { SunburstChartConfig } from "comps/chartComp/chartConfigs/sunburstChartConfig"; + +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 = { + sunburst: SunburstChartConfig, +}; + +const ChartOptionComp = withType(ChartOptionMap, "bar"); +const EchartsOptionComp = withType(EchartsOptionMap, "sunburst"); +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.defaultSunburstChartOption), + echartsTitle: withDefault(StringControl, trans("sunburstChart.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 sunburstChartChildrenMap = { + selectedPoints: stateComp>([]), + lastInteractionData: stateComp | NonUIChartDataType>({}), + onEvent: eventHandlerControl([clickEvent] as const), + ...chartUiModeChildren, + ...chartJsonModeChildren, + ...chartMapModeChildren, +}; + +const chartUiChildrenMap = uiChildren(sunburstChartChildrenMap); +export type ChartCompPropsType = RecordConstructorToView; +export type ChartCompChildrenType = RecordConstructorToComp; diff --git a/client/packages/lowcoder-comps/src/comps/sunburstChartComp/sunburstChartPropertyView.tsx b/client/packages/lowcoder-comps/src/comps/sunburstChartComp/sunburstChartPropertyView.tsx new file mode 100644 index 000000000..c8e631be8 --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/sunburstChartComp/sunburstChartPropertyView.tsx @@ -0,0 +1,54 @@ +import { CompAction } from "lowcoder-core"; +import { ChartCompChildrenType } from "./sunburstChartConstants"; +import { + hiddenPropertyView, + Section, + sectionNames, +} from "lowcoder-sdk"; +import { trans } from "i18n/comps"; +import { examplesUrl,optionUrl } from "../chartComp/chartConfigs/chartUrls"; + +export function sunburstChartPropertyView( + children: ChartCompChildrenType, + dispatch: (action: CompAction) => void +) { + + const jsonModePropertyView = ( + <> +
+ {children.echartsOption.propertyView({ + label: trans("chart.echartsOptionLabel"), + styleName: "higher", + tooltip: ( + + ), + })} + {children.echartsTitle.propertyView({ label: trans("sunburstChart.title") })} + {children.tooltip.propertyView({label: trans("sunburstChart.tooltip")})} +
+
+ {children.onEvent.propertyView()} +
+
+ {children.style.getPropertyView()} +
+
{hiddenPropertyView(children)}
+ + ); + + const getChatConfigByMode = (mode: string) => { + switch(mode) { + case "json": + return jsonModePropertyView; + } + } + return getChatConfigByMode(children.mode.getView()) +} diff --git a/client/packages/lowcoder-comps/src/comps/sunburstChartComp/sunburstChartUtils.ts b/client/packages/lowcoder-comps/src/comps/sunburstChartComp/sunburstChartUtils.ts new file mode 100644 index 000000000..85bbb9676 --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/sunburstChartComp/sunburstChartUtils.ts @@ -0,0 +1,318 @@ +import { + CharOptionCompType, + ChartCompPropsType, + ChartSize, + noDataAxisConfig, + noDataPieChartConfig, +} from "comps/chartComp/chartConstants"; +import { getPieRadiusAndCenter } from "comps/chartComp/chartConfigs/pieChartConfig"; +import { EChartsOptionWithMap } from "../chartComp/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 "../chartComp/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": "{b}: {c}" + }, + "series": [ + { + "name": props.echartsConfig.type, + "type": props.echartsConfig.type, + "top": "10%", + "left": "10%", + "bottom": "10%", + "right": "10%", + "symbolSize": 7, + '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/themeriverChartComp/themeriverChartComp.tsx b/client/packages/lowcoder-comps/src/comps/themeriverChartComp/themeriverChartComp.tsx new file mode 100644 index 000000000..225c65cc6 --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/themeriverChartComp/themeriverChartComp.tsx @@ -0,0 +1,318 @@ +import { + changeChildAction, + changeValueAction, + CompAction, + CompActionTypes, + wrapChildAction, +} from "lowcoder-core"; +import { AxisFormatterComp, EchartsAxisType } from "../chartComp/chartConfigs/cartesianAxisConfig"; +import { themeriverChartChildrenMap, ChartSize, getDataKeys } from "./themeriverChartConstants"; +import { themeriverChartPropertyView } from "./themeriverChartPropertyView"; +import _ from "lodash"; +import { useContext, useEffect, useMemo, useRef, useState } from "react"; +import ReactResizeDetector from "react-resize-detector"; +import ReactECharts from "../chartComp/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/chartComp/chartConfigs/lineChartConfig"; +import { + echartsConfigOmitChildren, + getEchartsConfig, + getSelectedPoints, +} from "./themeriverChartUtils"; +import 'echarts-extension-gmap'; +import log from "loglevel"; + +let clickEventCallback = () => {}; + +const chartModeOptions = [ + { + label: "ECharts JSON", + value: "json", + } +] as const; + +let ThemeriverChartTmpComp = (function () { + return new UICompBuilder({mode:dropdownControl(chartModeOptions,'json'),...themeriverChartChildrenMap}, () => null) + .setPropertyViewFn(themeriverChartPropertyView) + .build(); +})(); + +ThemeriverChartTmpComp = withViewFn(ThemeriverChartTmpComp, (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 option = useMemo(() => { + return getEchartsConfig( + childrenToProps(echartsConfigChildren) as ToViewReturn, + chartSize + ); + }, [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} + theme={mode !== 'map' ? themeConfig : undefined} + 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; +} + +ThemeriverChartTmpComp = class extends ThemeriverChartTmpComp { + 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 ThemeriverChartComp = withExposingConfigs(ThemeriverChartTmpComp, [ + 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 ThemeriverChartCompWithDefault = withDefault(ThemeriverChartComp, { + 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/themeriverChartComp/themeriverChartConstants.tsx b/client/packages/lowcoder-comps/src/comps/themeriverChartComp/themeriverChartConstants.tsx new file mode 100644 index 000000000..589634148 --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/themeriverChartComp/themeriverChartConstants.tsx @@ -0,0 +1,299 @@ +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 "../chartComp/chartConfigs/barChartConfig"; +import { XAxisConfig, YAxisConfig } from "../chartComp/chartConfigs/cartesianAxisConfig"; +import { LegendConfig } from "../chartComp/chartConfigs/legendConfig"; +import { EchartsLegendConfig } from "../chartComp/chartConfigs/echartsLegendConfig"; +import { EchartsLabelConfig } from "../chartComp/chartConfigs/echartsLabelConfig"; +import { LineChartConfig } from "../chartComp/chartConfigs/lineChartConfig"; +import { PieChartConfig } from "../chartComp/chartConfigs/pieChartConfig"; +import { ScatterChartConfig } from "../chartComp/chartConfigs/scatterChartConfig"; +import { SeriesListComp } from "../chartComp/seriesComp"; +import { EChartsOption } from "echarts"; +import { i18nObjs, trans } from "i18n/comps"; +import { ThemeriverChartConfig } from "comps/chartComp/chartConfigs/themeriverChartConfig"; + +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 = { + themeriver: ThemeriverChartConfig, +}; + +const ChartOptionComp = withType(ChartOptionMap, "bar"); +const EchartsOptionComp = withType(EchartsOptionMap, "themeriver"); +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.defaultThemeriverChartOption), + echartsTitle: withDefault(StringControl, trans("themeriverChart.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 themeriverChartChildrenMap = { + selectedPoints: stateComp>([]), + lastInteractionData: stateComp | NonUIChartDataType>({}), + onEvent: eventHandlerControl([clickEvent] as const), + ...chartUiModeChildren, + ...chartJsonModeChildren, + ...chartMapModeChildren, +}; + +const chartUiChildrenMap = uiChildren(themeriverChartChildrenMap); +export type ChartCompPropsType = RecordConstructorToView; +export type ChartCompChildrenType = RecordConstructorToComp; diff --git a/client/packages/lowcoder-comps/src/comps/themeriverChartComp/themeriverChartPropertyView.tsx b/client/packages/lowcoder-comps/src/comps/themeriverChartComp/themeriverChartPropertyView.tsx new file mode 100644 index 000000000..529dbcc57 --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/themeriverChartComp/themeriverChartPropertyView.tsx @@ -0,0 +1,54 @@ +import { CompAction } from "lowcoder-core"; +import { ChartCompChildrenType } from "./themeriverChartConstants"; +import { + hiddenPropertyView, + Section, + sectionNames, +} from "lowcoder-sdk"; +import { trans } from "i18n/comps"; +import { examplesUrl,optionUrl } from "../chartComp/chartConfigs/chartUrls"; + +export function themeriverChartPropertyView( + children: ChartCompChildrenType, + dispatch: (action: CompAction) => void +) { + + const jsonModePropertyView = ( + <> +
+ {children.echartsOption.propertyView({ + label: trans("chart.echartsOptionLabel"), + styleName: "higher", + tooltip: ( + + ), + })} + {children.echartsTitle.propertyView({ label: trans("themeriverChart.title") })} + {children.tooltip.propertyView({label: trans("themeriverChart.tooltip")})} +
+
+ {children.onEvent.propertyView()} +
+
+ {children.style.getPropertyView()} +
+
{hiddenPropertyView(children)}
+ + ); + + const getChatConfigByMode = (mode: string) => { + switch(mode) { + case "json": + return jsonModePropertyView; + } + } + return getChatConfigByMode(children.mode.getView()) +} diff --git a/client/packages/lowcoder-comps/src/comps/themeriverChartComp/themeriverChartUtils.ts b/client/packages/lowcoder-comps/src/comps/themeriverChartComp/themeriverChartUtils.ts new file mode 100644 index 000000000..5d9195e3c --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/themeriverChartComp/themeriverChartUtils.ts @@ -0,0 +1,345 @@ +import { + CharOptionCompType, + ChartCompPropsType, + ChartSize, + noDataAxisConfig, + noDataPieChartConfig, +} from "comps/chartComp/chartConstants"; +import { getPieRadiusAndCenter } from "comps/chartComp/chartConfigs/pieChartConfig"; +import { EChartsOptionWithMap } from "../chartComp/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 "../chartComp/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, + "tooltip": props.tooltip&&{ + "trigger": "axis", + "axisPointer": { + "type": "line", + "lineStyle": { + "color": "rgba(0,0,0,0.2)", + "width": 2, + "type": "solid" + } + } + }, + "singleAxis": { + "type": "time", + "bottom": 50, + "axisTick": {}, + "axisLabel": {}, + "splitLine": {}, + "axisPointer": { + "animation": true, + "label": { + "show": true, + "color": "#fff" + } + }, + "splitNumber": 30 + }, + "series": [ + { + "type": props.echartsConfig.type, + "data": props.echartsOption.data, + "label": { + "show": true, + "position": "top", + "fontSize": 12 + }, + "emphasis": { + "itemStyle": { + "shadowBlur": 20, + "shadowColor": "rgba(0, 0, 0, 0.8)" + } + } + } + ] +} + + 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/treeChartComp/treeChartConstants.tsx b/client/packages/lowcoder-comps/src/comps/treeChartComp/treeChartConstants.tsx new file mode 100644 index 000000000..573cc03db --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/treeChartComp/treeChartConstants.tsx @@ -0,0 +1,299 @@ +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 "../chartComp/chartConfigs/barChartConfig"; +import { XAxisConfig, YAxisConfig } from "../chartComp/chartConfigs/cartesianAxisConfig"; +import { LegendConfig } from "../chartComp/chartConfigs/legendConfig"; +import { EchartsLegendConfig } from "../chartComp/chartConfigs/echartsLegendConfig"; +import { EchartsLabelConfig } from "../chartComp/chartConfigs/echartsLabelConfig"; +import { LineChartConfig } from "../chartComp/chartConfigs/lineChartConfig"; +import { PieChartConfig } from "../chartComp/chartConfigs/pieChartConfig"; +import { ScatterChartConfig } from "../chartComp/chartConfigs/scatterChartConfig"; +import { SeriesListComp } from "../chartComp/seriesComp"; +import { EChartsOption } from "echarts"; +import { i18nObjs, trans } from "i18n/comps"; +import { TreeChartConfig } from "comps/chartComp/chartConfigs/treeChartConfig"; + +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 = { + tree: TreeChartConfig, +}; + +const ChartOptionComp = withType(ChartOptionMap, "bar"); +const EchartsOptionComp = withType(EchartsOptionMap, "tree"); +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.defaultTreeChartOption), + echartsTitle: withDefault(StringControl, trans("treeChart.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 treeChartChildrenMap = { + selectedPoints: stateComp>([]), + lastInteractionData: stateComp | NonUIChartDataType>({}), + onEvent: eventHandlerControl([clickEvent] as const), + ...chartUiModeChildren, + ...chartJsonModeChildren, + ...chartMapModeChildren, +}; + +const chartUiChildrenMap = uiChildren(treeChartChildrenMap); +export type ChartCompPropsType = RecordConstructorToView; +export type ChartCompChildrenType = RecordConstructorToComp; diff --git a/client/packages/lowcoder-comps/src/comps/treeChartComp/treeChartPropertyView.tsx b/client/packages/lowcoder-comps/src/comps/treeChartComp/treeChartPropertyView.tsx new file mode 100644 index 000000000..920062913 --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/treeChartComp/treeChartPropertyView.tsx @@ -0,0 +1,54 @@ +import { CompAction } from "lowcoder-core"; +import { ChartCompChildrenType } from "./treeChartConstants"; +import { + hiddenPropertyView, + Section, + sectionNames, +} from "lowcoder-sdk"; +import { trans } from "i18n/comps"; +import { examplesUrl,optionUrl } from "../chartComp/chartConfigs/chartUrls"; + +export function treeChartPropertyView( + children: ChartCompChildrenType, + dispatch: (action: CompAction) => void +) { + + const jsonModePropertyView = ( + <> +
+ {children.echartsOption.propertyView({ + label: trans("chart.echartsOptionLabel"), + styleName: "higher", + tooltip: ( + + ), + })} + {children.echartsTitle.propertyView({ label: trans("treeChart.title") })} + {children.tooltip.propertyView({label: trans("treeChart.tooltip")})} +
+
+ {children.onEvent.propertyView()} +
+
+ {children.style.getPropertyView()} +
+
{hiddenPropertyView(children)}
+ + ); + + const getChatConfigByMode = (mode: string) => { + switch(mode) { + case "json": + return jsonModePropertyView; + } + } + return getChatConfigByMode(children.mode.getView()) +} diff --git a/client/packages/lowcoder-comps/src/comps/treeChartComp/treeChartUtils.ts b/client/packages/lowcoder-comps/src/comps/treeChartComp/treeChartUtils.ts new file mode 100644 index 000000000..c7719fdb7 --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/treeChartComp/treeChartUtils.ts @@ -0,0 +1,330 @@ +import { + CharOptionCompType, + ChartCompPropsType, + ChartSize, + noDataAxisConfig, + noDataPieChartConfig, +} from "comps/chartComp/chartConstants"; +import { getPieRadiusAndCenter } from "comps/chartComp/chartConfigs/pieChartConfig"; +import { EChartsOptionWithMap } from "../chartComp/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 "../chartComp/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", + "triggerOn": "mousemove" + }, + "series": [ + { + "name": props.echartsConfig.type, + "type": props.echartsConfig.type, + "top": "10%", + "left": "10%", + "bottom": "10%", + "right": "10%", + "symbolSize": 7, + 'data': props.echartsOption.data, + "label": { + "position": "top", + "verticalAlign": "middle", + "align": "right" + }, + "leaves": { + "label": { + "position": "bottom", + "verticalAlign": "middle", + "align": "left" + } + } + } + ] +} + 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/treeChartComp/treechartComp.tsx b/client/packages/lowcoder-comps/src/comps/treeChartComp/treechartComp.tsx new file mode 100644 index 000000000..91bb602e8 --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/treeChartComp/treechartComp.tsx @@ -0,0 +1,318 @@ +import { + changeChildAction, + changeValueAction, + CompAction, + CompActionTypes, + wrapChildAction, +} from "lowcoder-core"; +import { AxisFormatterComp, EchartsAxisType } from "../chartComp/chartConfigs/cartesianAxisConfig"; +import { treeChartChildrenMap, ChartSize, getDataKeys } from "./treeChartConstants"; +import { treeChartPropertyView } from "./treeChartPropertyView"; +import _ from "lodash"; +import { useContext, useEffect, useMemo, useRef, useState } from "react"; +import ReactResizeDetector from "react-resize-detector"; +import ReactECharts from "../chartComp/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/chartComp/chartConfigs/lineChartConfig"; +import { + echartsConfigOmitChildren, + getEchartsConfig, + getSelectedPoints, +} from "./treeChartUtils"; +import 'echarts-extension-gmap'; +import log from "loglevel"; + +let clickEventCallback = () => {}; + +const chartModeOptions = [ + { + label: "ECharts JSON", + value: "json", + } +] as const; + +let TreeChartTmpComp = (function () { + return new UICompBuilder({mode:dropdownControl(chartModeOptions,'json'),...treeChartChildrenMap}, () => null) + .setPropertyViewFn(treeChartPropertyView) + .build(); +})(); + +TreeChartTmpComp = withViewFn(TreeChartTmpComp, (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 option = useMemo(() => { + return getEchartsConfig( + childrenToProps(echartsConfigChildren) as ToViewReturn, + chartSize + ); + }, [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} + theme={mode !== 'map' ? themeConfig : undefined} + 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; +} + +TreeChartTmpComp = class extends TreeChartTmpComp { + 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 TreeChartComp = withExposingConfigs(TreeChartTmpComp, [ + 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 TreeChartCompWithDefault = withDefault(TreeChartComp, { + 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/treemapChartComp/treemapChartComp.tsx b/client/packages/lowcoder-comps/src/comps/treemapChartComp/treemapChartComp.tsx new file mode 100644 index 000000000..fa0d6f078 --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/treemapChartComp/treemapChartComp.tsx @@ -0,0 +1,318 @@ +import { + changeChildAction, + changeValueAction, + CompAction, + CompActionTypes, + wrapChildAction, +} from "lowcoder-core"; +import { AxisFormatterComp, EchartsAxisType } from "../chartComp/chartConfigs/cartesianAxisConfig"; +import { treemapChartChildrenMap, ChartSize, getDataKeys } from "./treemapChartConstants"; +import { treeChartPropertyView } from "./treemapChartPropertyView"; +import _ from "lodash"; +import { useContext, useEffect, useMemo, useRef, useState } from "react"; +import ReactResizeDetector from "react-resize-detector"; +import ReactECharts from "../chartComp/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/chartComp/chartConfigs/lineChartConfig"; +import { + echartsConfigOmitChildren, + getEchartsConfig, + getSelectedPoints, +} from "./treemapChartUtils"; +import 'echarts-extension-gmap'; +import log from "loglevel"; + +let clickEventCallback = () => {}; + +const chartModeOptions = [ + { + label: "ECharts JSON", + value: "json", + } +] as const; + +let TreemapChartTmpComp = (function () { + return new UICompBuilder({mode:dropdownControl(chartModeOptions,'json'),...treemapChartChildrenMap}, () => null) + .setPropertyViewFn(treeChartPropertyView) + .build(); +})(); + +TreemapChartTmpComp = withViewFn(TreemapChartTmpComp, (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 option = useMemo(() => { + return getEchartsConfig( + childrenToProps(echartsConfigChildren) as ToViewReturn, + chartSize + ); + }, [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} + theme={mode !== 'map' ? themeConfig : undefined} + 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; +} + +TreemapChartTmpComp = class extends TreemapChartTmpComp { + 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 TreemapChartComp = withExposingConfigs(TreemapChartTmpComp, [ + 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 TreemapChartCompWithDefault = withDefault(TreemapChartComp, { + 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/treemapChartComp/treemapChartConstants.tsx b/client/packages/lowcoder-comps/src/comps/treemapChartComp/treemapChartConstants.tsx new file mode 100644 index 000000000..a5c302b2b --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/treemapChartComp/treemapChartConstants.tsx @@ -0,0 +1,299 @@ +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 "../chartComp/chartConfigs/barChartConfig"; +import { XAxisConfig, YAxisConfig } from "../chartComp/chartConfigs/cartesianAxisConfig"; +import { LegendConfig } from "../chartComp/chartConfigs/legendConfig"; +import { EchartsLegendConfig } from "../chartComp/chartConfigs/echartsLegendConfig"; +import { EchartsLabelConfig } from "../chartComp/chartConfigs/echartsLabelConfig"; +import { LineChartConfig } from "../chartComp/chartConfigs/lineChartConfig"; +import { PieChartConfig } from "../chartComp/chartConfigs/pieChartConfig"; +import { ScatterChartConfig } from "../chartComp/chartConfigs/scatterChartConfig"; +import { SeriesListComp } from "../chartComp/seriesComp"; +import { EChartsOption } from "echarts"; +import { i18nObjs, trans } from "i18n/comps"; +import { TreemapChartConfig } from "comps/chartComp/chartConfigs/treemapChartConfig"; + +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 = { + treemap: TreemapChartConfig, +}; + +const ChartOptionComp = withType(ChartOptionMap, "bar"); +const EchartsOptionComp = withType(EchartsOptionMap, "treemap"); +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.defaultTreemapChartOption), + echartsTitle: withDefault(StringControl, trans("treemapChart.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 treemapChartChildrenMap = { + selectedPoints: stateComp>([]), + lastInteractionData: stateComp | NonUIChartDataType>({}), + onEvent: eventHandlerControl([clickEvent] as const), + ...chartUiModeChildren, + ...chartJsonModeChildren, + ...chartMapModeChildren, +}; + +const chartUiChildrenMap = uiChildren(treemapChartChildrenMap); +export type ChartCompPropsType = RecordConstructorToView; +export type ChartCompChildrenType = RecordConstructorToComp; diff --git a/client/packages/lowcoder-comps/src/comps/treemapChartComp/treemapChartPropertyView.tsx b/client/packages/lowcoder-comps/src/comps/treemapChartComp/treemapChartPropertyView.tsx new file mode 100644 index 000000000..4d5c543bb --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/treemapChartComp/treemapChartPropertyView.tsx @@ -0,0 +1,54 @@ +import { CompAction } from "lowcoder-core"; +import { ChartCompChildrenType } from "./treemapChartConstants"; +import { + hiddenPropertyView, + Section, + sectionNames, +} from "lowcoder-sdk"; +import { trans } from "i18n/comps"; +import { examplesUrl,optionUrl } from "../chartComp/chartConfigs/chartUrls"; + +export function treeChartPropertyView( + children: ChartCompChildrenType, + dispatch: (action: CompAction) => void +) { + + const jsonModePropertyView = ( + <> +
+ {children.echartsOption.propertyView({ + label: trans("chart.echartsOptionLabel"), + styleName: "higher", + tooltip: ( + + ), + })} + {children.echartsTitle.propertyView({ label: trans("treemapChart.title") })} + {children.tooltip.propertyView({label: trans("treemapChart.tooltip")})} +
+
+ {children.onEvent.propertyView()} +
+
+ {children.style.getPropertyView()} +
+
{hiddenPropertyView(children)}
+ + ); + + const getChatConfigByMode = (mode: string) => { + switch(mode) { + case "json": + return jsonModePropertyView; + } + } + return getChatConfigByMode(children.mode.getView()) +} diff --git a/client/packages/lowcoder-comps/src/comps/treemapChartComp/treemapChartUtils.ts b/client/packages/lowcoder-comps/src/comps/treemapChartComp/treemapChartUtils.ts new file mode 100644 index 000000000..81f033597 --- /dev/null +++ b/client/packages/lowcoder-comps/src/comps/treemapChartComp/treemapChartUtils.ts @@ -0,0 +1,319 @@ +import { + CharOptionCompType, + ChartCompPropsType, + ChartSize, + noDataAxisConfig, + noDataPieChartConfig, +} from "comps/chartComp/chartConstants"; +import { getPieRadiusAndCenter } from "comps/chartComp/chartConfigs/pieChartConfig"; +import { EChartsOptionWithMap } from "../chartComp/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 "../chartComp/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": [], + "tooltip": props.tooltip&&{ + "trigger": "item", + "formatter": "{b}: {c}", + "axisPointer": { + "type": "shadow" + }, + }, + "series": [ + { + "name": props.echartsConfig.type, + "type": props.echartsConfig.type, + 'data': props.echartsOption.data, + "breadcrumb": { + "show": true + } + } + ] + } + 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/i18n/comps/locales/en.ts b/client/packages/lowcoder-comps/src/i18n/comps/locales/en.ts index 3df5edda9..35edde79e 100644 --- a/client/packages/lowcoder-comps/src/i18n/comps/locales/en.ts +++ b/client/packages/lowcoder-comps/src/i18n/comps/locales/en.ts @@ -1,4 +1,112 @@ export const en = { + calendarChart: { + calendarType: 'Calendar Chart Type', + title: 'Title', + defaultTitle: 'Calendar Chart', + tooltip: 'Tooltip', + }, + themeriverChart: { + themeriverType: 'Themeriver Chart Type', + title: 'Title', + defaultTitle: 'Themeriver Chart', + tooltip: 'Tooltip', + }, + sunburstChart: { + sunburstType: 'Sunburst Chart Type', + title: 'Title', + defaultTitle: 'Sunburst Chart', + tooltip: 'Tooltip', + }, + treemapChart: { + treemapType: 'Treemap Chart Type', + title: 'Title', + defaultTitle: 'Treemap Chart', + tooltip: 'Tooltip', + }, + treeChart: { + treeType: 'Tree Chart Type', + title: 'Title', + defaultTitle: 'Tree Chart', + tooltip: 'Tooltip', + }, + graphChart: { + graphType: 'Graph Chart Type', + title: 'Title', + defaultTitle: 'Graph Chart', + tooltip: 'Tooltip', + }, + heatmapChart: { + heatmapType: 'Heatmap Chart Type', + title: 'Title', + defaultTitle: 'Heatmap Chart', + tooltip: 'Tooltip', + }, + radarChart: { + radarType: 'Radar Chart Type', + title: 'Title', + defaultTitle: 'Radar Chart', + tooltip: 'Tooltip', + }, + candleStickChart: { + candleStickType: 'CandleStick Chart Type', + title: 'Title', + defaultTitle: 'CandleStick Chart', + tooltip: 'Tooltip', + }, + sankeyChart: { + sankeyType: 'Sankey Chart Type', + title: 'Title', + defaultTitle: 'Sankey Chart', + tooltip: 'Tooltip', + }, + funnelChart: { + title: 'Title', + defaultTitle: 'Funnel Chart', + funnelType:'Funnel Chart Type', + tooltip: 'Tooltip', + legendVisibility: 'Legend Visibility', + left: 'Left', + defaultLeft:'35', + top: 'Top', + defaultTop:'60', + bottom: 'Bottom', + defaultBottom:'60', + width: 'Width', + defaultWidth:'80', + min: 'Min', + defaultMin:'0', + max: 'Max', + defaultMax:'100', + gap: 'Gap', + defaultGap: '2', + label:'Label', + }, + gaugeChart: { + title: 'Title', + defaultTitle: 'Gauge Chart', + gaugeType: 'Gauge Chart Type', + tooltip: 'Tooltip', + left: 'Left', + defaultLeft:'35', + top: 'Top', + defaultTop:'60', + bottom: 'Bottom', + defaultBottom:'60', + width: 'Width', + defaultWidth:'80', + min: 'Min', + defaultMin:'0', + max: 'Max', + defaultMax:'100', + gap: 'Gap', + defaultGap: '2', + label:'Label', + }, + echarts: { + legendPosition: "Legend Position", + labelPosition: "Label Position", + titlePosition: "Title Position", + }, chart: { delete: "Delete", data: "Data", @@ -14,6 +122,7 @@ export const en = { seriesName: "Series Name", dataColumns: "Data Columns", title: "Title", + tooltip:'Tooltip', xAxisDirection: "X-axis Direction", xAxisName: "X-axis Name", xAxisType: "X-axis Type", diff --git a/client/packages/lowcoder-comps/src/i18n/comps/locales/enObj.tsx b/client/packages/lowcoder-comps/src/i18n/comps/locales/enObj.tsx index 6f82ef845..2e1b9b874 100644 --- a/client/packages/lowcoder-comps/src/i18n/comps/locales/enObj.tsx +++ b/client/packages/lowcoder-comps/src/i18n/comps/locales/enObj.tsx @@ -154,44 +154,246 @@ export const enObj: I18nObjects = { ], defaultEchartsJsonOption: { - title: { - text: "Funnel Chart", - left: "center", - }, - backgroundColor: "#ffffff", - color: chartColorPalette, - tooltip: { - trigger: "item", - formatter: "{a}
{b} : {c}%", - }, - legend: { - data: ["Show", "Click", "Visit", "Query", "Buy"], - top: "bottom", - }, + data: [ + { value: 100, name: "Show",color:'#fc8452' }, + { value: 80, name: "Click" ,color:'#9a60b4'}, + { value: 60, name: "Visit" ,color:'#fac858'}, + { value: 40, name: "Query" ,color:'#ee6666'}, + { value: 20, name: "Buy" ,color:'#3ba272'}, + ], + }, + defaultFunnelChartOption: { + data: [ + { value: 100, name: "Show",color:'#fc8452' }, + { value: 80, name: "Click" ,color:'#9a60b4'}, + { value: 60, name: "Visit" ,color:'#fac858'}, + { value: 40, name: "Query" ,color:'#ee6666'}, + { value: 20, name: "Buy" ,color:'#3ba272'}, + ], + }, + defaultGaugeChartOption: { + data: [ + { value: 60, name: "Completed",color:'#fc8452' } + ] + }, + defaultSankeyChartOption: { + data: [ + {name: "Show"}, + {name: "Click"}, + {name: "Visit"}, + {name: "Query"}, + {name: "Buy"} + ], + links: [ + {source: "Show", target: "Click", value: 80}, + {source: "Click", target: "Visit", value: 60}, + {source: "Visit", target: "Query", value: 40}, + {source: "Query", target: "Buy", value: 20} + ] + }, + defaultCandleStickChartOption: { + xAxis: { + data: ["Day 1", "Day 2", "Day 3", "Day 4", "Day 5"] + }, + data:[ + [100, 200, 50, 150], + [120, 220, 80, 180], + [80, 150, 60, 130], + [130, 230, 110, 190], + [90, 180, 70, 160] + ] + }, + defaultRadarChartOption: { + indicator: [ + { name: "Indicator 1", max: 100 }, + { name: "Indicator 2", max: 100 }, + { name: "Indicator 3", max: 100 }, + { name: "Indicator 4", max: 100 }, + { name: "Indicator 5", max: 100 } + ], series: [ + { + "name": "Data 1", + "data": [ + { + "value": [90, 80, 70, 60, 50], + "name": "Data 1" + } + ] + }, { - name: "Funnel", - type: "funnel", - left: "10%", - top: 60, - bottom: 60, - width: "80%", - min: 0, - max: 100, - gap: 2, - label: { - show: true, - position: "inside", + "name": "Data 2", + "data": [ + { + "value": [70, 60, 50, 40, 30], + "name": "Data 2" + } + ] + } + ] + }, + defaultHeatmapChartOption: { + xAxis: { + "data": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] + }, + yAxis: { + "data": ["Morning", "Afternoon", "Evening"] + }, + data: [ + [0, 0, 10], + [0, 1, 20], + [0, 2, 30], + [1, 0, 40], + [1, 1, 50], + [1, 2, 60], + [2, 0, 70], + [2, 1, 80], + [2, 2, 90], + [3, 0, 100], + [3, 1, 90], + [3, 2, 80], + [4, 0, 70], + [4, 1, 60], + [4, 2, 50], + [5, 0, 40], + [5, 1, 30], + [5, 2, 20], + [6, 0, 10], + [6, 1, 0], + [6, 2, 10] + ] + }, + defaultGraphChartOption: { + categories: [ + {name: "Nodes"}, + {name: "Edges"} + ], + nodes: [ + {name: "Node 1", category: 0}, + {name: "Node 2", category: 0}, + {name: "Node 3", category: 0} + ], + links: [ + {source: "Node 1", target: "Node 2", category: 1}, + {source: "Node 2", target: "Node 3", category: 1} + ] + }, + defaultTreeChartOption: { + data: [{ + name: "Parent", + children: [ + { + name: "Child 1", + children: [ + { name: "Child 1-1" }, + { name: "Child 1-2" } + ] + }, + { + name: "Child 2", + children: [ + { name: "Child 2-1" }, + { name: "Child 2-2" } + ] + } + ] + }] + }, + defaultTreemapChartOption: { + data: [ + { + name: 'nodeA', + value: 10, + children: [ + { + name: 'nodeAa', + value: 4, + }, + { + name: 'nodeAb', + value: 6 + } + ] }, - data: [ - { value: 100, name: "Show" }, - { value: 80, name: "Click" }, - { value: 60, name: "Visit" }, - { value: 40, name: "Query" }, - { value: 20, name: "Buy" }, - ], - }, - ], + { + name: 'nodeB', + value: 20, + children: [ + { + name: 'nodeBa', + value: 20, + children: [ + { + name: 'nodeBa1', + value: 20 + } + ] + } + ] + } + ] + }, + defaultSunburstChartOption: { + data: [ + { + name: "Grandparent", + children: [ + { + name: "Parent A", + children: [ + {name: "Child A1", value: 10}, + {name: "Child A2", value: 20} + ] + }, + { + name: "Parent B", + children: [ + {name: "Child B1", value: 15}, + {name: "Child B2", value: 25} + ] + } + ] + } + ] + }, + defaultCalendarChartOption: { + data:[ + ["2022-01-01", 10], + ["2022-02-05", 30], + ["2022-03-15", 50], + ["2022-04-20", 70], + ["2022-05-25", 90], + ["2022-06-30", 100], + ["2022-07-10", 80], + ["2022-08-20", 60], + ["2022-09-25", 40], + ["2022-10-30", 20], + ["2022-11-05", 5] + ] + }, + defaultThemeriverChartOption: { + data: [ + ["2024-01-01", 10, "Category A"], + ["2024-01-02", 15, "Category A"], + ["2024-01-03", 20, "Category A"], + ["2024-01-04", 25, "Category A"], + ["2024-01-05", 30, "Category A"], + ["2024-01-06", 35, "Category A"], + ["2024-01-07", 40, "Category A"], + ["2024-01-08", 45, "Category A"], + ["2024-01-09", 50, "Category A"], + ["2024-01-10", 55, "Category A"], + ["2024-01-01", 15, "Category B"], + ["2024-01-02", 20, "Category B"], + ["2024-01-03", 25, "Category B"], + ["2024-01-04", 30, "Category B"], + ["2024-01-05", 35, "Category B"], + ["2024-01-06", 40, "Category B"], + ["2024-01-07", 45, "Category B"], + ["2024-01-08", 50, "Category B"], + ["2024-01-09", 55, "Category B"], + ["2024-01-10", 60, "Category B"] + ] }, defaultMapJsonOption: defaultMapData, diff --git a/client/packages/lowcoder-comps/src/i18n/comps/locales/types.tsx b/client/packages/lowcoder-comps/src/i18n/comps/locales/types.tsx index 92f3cc4b6..4f227e3bc 100644 --- a/client/packages/lowcoder-comps/src/i18n/comps/locales/types.tsx +++ b/client/packages/lowcoder-comps/src/i18n/comps/locales/types.tsx @@ -4,6 +4,18 @@ import { XAXisComponentOption } from "echarts"; export type I18nObjects = { defaultDataSource: JSONObject[]; defaultEchartsJsonOption: Record; + defaultGaugeChartOption: Record; + defaultFunnelChartOption: Record; + defaultSankeyChartOption: Record; + defaultCandleStickChartOption: Record; + defaultRadarChartOption: Record; + defaultHeatmapChartOption: Record; + defaultGraphChartOption: Record; + defaultTreeChartOption: Record; + defaultTreemapChartOption: Record; + defaultSunburstChartOption: Record; + defaultCalendarChartOption: Record; + defaultThemeriverChartOption: Record; defaultMapJsonOption: Record; timeXAxisLabel?: XAXisComponentOption["axisLabel"]; imageEditorLocale?: Record; diff --git a/client/packages/lowcoder-comps/src/index.ts b/client/packages/lowcoder-comps/src/index.ts index 9d8097d32..6e54b17a8 100644 --- a/client/packages/lowcoder-comps/src/index.ts +++ b/client/packages/lowcoder-comps/src/index.ts @@ -1,12 +1,38 @@ import { ChartCompWithDefault } from "./comps/chartComp/chartComp"; import { ImageEditorComp } from "./comps/imageEditorComp/index"; import { CalendarComp } from "./comps/calendarComp/calendarComp"; +import { MapCompWithDefault } from "comps/mapComp/mapComp"; +import { FunnelChartCompWithDefault } from "comps/funnelChartComp/funnelChartComp"; +import { GaugeChartCompWithDefault } from "comps/gaugeChartComp/gaugeChartComp"; +import { SankeyChartCompWithDefault } from "comps/sankeyChartComp/sankeyChartComp"; +import { CandleStickChartCompWithDefault } from "comps/candleStickChartComp/candleStickChartComp"; +import { RadarChartCompWithDefault } from "comps/radarChartComp/radarChartComp"; +import { HeatmapChartCompWithDefault } from "comps/heatmapChartComp/heatmapChartComp"; +import { GraphChartCompWithDefault } from "comps/graphChartComp/graphChartComp"; +import { TreeChartCompWithDefault } from "comps/treeChartComp/treechartComp"; +import { TreemapChartCompWithDefault } from "comps/treemapChartComp/treemapChartComp"; +import { SunburstChartCompWithDefault } from "comps/sunburstChartComp/sunburstChartComp"; +import { ThemeriverChartCompWithDefault } from "comps/themeriverChartComp/themeriverChartComp"; + import { MermaidComp } from "comps/mermaidComp"; import { MeetingControllerComp } from "comps/agoraMeetingComp/meetingControllerComp"; import { VideoMeetingStreamComp } from "comps/agoraMeetingComp/videoMeetingStreamComp"; import { VideoSharingStreamComp } from "comps/agoraMeetingComp/videoSharingStreamComp"; + export default { chart: ChartCompWithDefault, + map: MapCompWithDefault, + funnelChart: FunnelChartCompWithDefault, + gaugeChart: GaugeChartCompWithDefault, + sankeyChart: SankeyChartCompWithDefault, + candleStickChart: CandleStickChartCompWithDefault, + radarChart: RadarChartCompWithDefault, + heatmapChart: HeatmapChartCompWithDefault, + graphChart: GraphChartCompWithDefault, + treeChart: TreeChartCompWithDefault, + treemapChart: TreemapChartCompWithDefault, + sunburstChart: SunburstChartCompWithDefault, + themeriverChart: ThemeriverChartCompWithDefault, imageEditor: ImageEditorComp, calendar: CalendarComp, mermaid: MermaidComp, diff --git a/client/packages/lowcoder/src/comps/controls/styleControlConstants.tsx b/client/packages/lowcoder/src/comps/controls/styleControlConstants.tsx index 25e62ca89..15ffa3210 100644 --- a/client/packages/lowcoder/src/comps/controls/styleControlConstants.tsx +++ b/client/packages/lowcoder/src/comps/controls/styleControlConstants.tsx @@ -1406,6 +1406,10 @@ export const DrawerStyle = [getBackground()] as const export const JsonEditorStyle = [LABEL] as const; +export const EchartsStyle = [ + getBackground("primarySurface"), +] as const; + export const CalendarStyle = [ getBackground("primarySurface"), {