diff --git a/core/frontend/src/icons/svg/stock-line.svg b/core/frontend/src/icons/svg/stock-line.svg new file mode 100644 index 0000000000..b241a48968 --- /dev/null +++ b/core/frontend/src/icons/svg/stock-line.svg @@ -0,0 +1,11 @@ + diff --git a/core/frontend/src/lang/en.js b/core/frontend/src/lang/en.js index 6764dc5008..baa7c3ac88 100644 --- a/core/frontend/src/lang/en.js +++ b/core/frontend/src/lang/en.js @@ -1412,6 +1412,7 @@ export default { chart_bar_stack_horizontal: 'Stack Horizontal Bar', chart_percentage_bar_stack_horizontal: 'Horizontal Percentage Stack Bar', chart_bidirectional_bar: 'Bidirectional Bar', + chart_stock_line: 'Stock Line', chart_line: 'Base Line', chart_line_stack: 'Stack Line', chart_pie: 'Pie', diff --git a/core/frontend/src/lang/tw.js b/core/frontend/src/lang/tw.js index 466cf44667..2914c48904 100644 --- a/core/frontend/src/lang/tw.js +++ b/core/frontend/src/lang/tw.js @@ -1411,6 +1411,7 @@ export default { chart_bar_stack_horizontal: '橫嚮堆疊柱狀圖', chart_percentage_bar_stack_horizontal: '橫嚮百分比柱狀圖', chart_bidirectional_bar: '對稱柱狀圖', + chart_stock_line: 'K 線圖', chart_line: '基礎摺線圖', chart_line_stack: '堆疊摺線圖', chart_pie: '餅圖', diff --git a/core/frontend/src/lang/zh.js b/core/frontend/src/lang/zh.js index 70508fc6fa..7a3ac36706 100644 --- a/core/frontend/src/lang/zh.js +++ b/core/frontend/src/lang/zh.js @@ -1408,6 +1408,7 @@ export default { chart_bar_stack_horizontal: '横向堆叠柱状图', chart_percentage_bar_stack_horizontal: '横向百分比柱状图', chart_bidirectional_bar: '对称柱状图', + chart_stock_line: 'K 线图', chart_line: '基础折线图', chart_line_stack: '堆叠折线图', chart_pie: '饼图', diff --git a/core/frontend/src/views/chart/chart/bar/bar_antv.js b/core/frontend/src/views/chart/chart/bar/bar_antv.js index ec183ee775..809d81e0f4 100644 --- a/core/frontend/src/views/chart/chart/bar/bar_antv.js +++ b/core/frontend/src/views/chart/chart/bar/bar_antv.js @@ -1,4 +1,4 @@ -import { Column, Bar, BidirectionalBar } from '@antv/g2plot' +import {Column, Bar, BidirectionalBar, Mix} from '@antv/g2plot' import { getTheme, getLabel, @@ -17,6 +17,14 @@ import { import { antVCustomColor, getColors, handleEmptyDataStrategy, hexColorToRGBA, handleStackSort } from '@/views/chart/chart/util' import { cloneDeep, find, groupBy, each } from 'lodash-es' import { formatterItem, valueFormatter } from '@/views/chart/chart/formatter' +import { + calculateMinMax, + calculateMovingAverage, + configXAxis, configYAxis, + configBasicStyle, + configTooltip, + registerEvent, customConfigEmptyDataStrategy +} from "@/views/chart/chart/bar/stock_line_util"; export function baseBarOptionAntV(container, chart, action, isGroup, isStack) { // theme @@ -587,3 +595,201 @@ export function baseBidirectionalBarOptionAntV(container, chart, action, isGroup configPlotTooltipEvent(chart, plot) return plot } + +export function stockLineOptionAntV(container, chart, action) { + if (!chart.data?.data?.length) { + return + } + const xAxis = JSON.parse(chart.xaxis) + const yAxis = JSON.parse(chart.yaxis) + if (yAxis.length !== 4) { + return + } + const theme = getTheme(chart) + const legend = getLegend(chart) + const basicStyle = JSON.parse(chart.customAttr).color + const colors = [] + const alpha = basicStyle.alpha + basicStyle.colors.forEach(ele => { + colors.push(hexColorToRGBA(ele, alpha)) + }) + const data = cloneDeep(chart.data?.tableRow ?? []) + + // 时间字段 + const xAxisDataeaseName = xAxis[0].dataeaseName + const averages = [5, 10, 20, 60, 120, 180] + const legendItems = [ + { + name: '日K', + value: 'k', + marker: { + symbol: (x, y, r) => { + const width = r * 1 + const height = r + return [ + // 矩形框 + ['M', x - width - 1 / 2, y - height / 2], + ['L', x + width + 1 / 2, y - height / 2], + ['L', x + width + 1 / 2, y + height / 2], + ['L', x - width - 1 / 2, y + height / 2], + ['Z'], + // 中线 + ['M', x, y + 10 / 2], + ['L', x, y - 10 / 2] + ] + }, + style: { fill: 'red', stroke: 'red', lineWidth: 2 } + } + } + ] + // 计算均线数据 + const averagesLineData = new Map() + averages.forEach(item => { + averagesLineData.set('ma' + item, calculateMovingAverage(data, item, chart)) + }) + + // 将均线数据设置到主数据中 + data.forEach((item) => { + const date = item[xAxisDataeaseName] + for (const [key, value] of averagesLineData) { + item[key] = value.find(m => m[xAxisDataeaseName] === date)?.value + } + }) + + const averageLines = [] + let index = 0 + const start = 0.5 + const end = 1 + const startIndex = Math.floor(start * data.length) + const endIndex = Math.ceil(end * data.length) + const filteredData = data.slice(startIndex, endIndex) + const { maxValue, minValue } = calculateMinMax(filteredData) + for (const key of averagesLineData.keys()) { + index++ + averageLines.push({ + type: 'line', + top: true, + options: { + smooth: false, + xField: xAxisDataeaseName, + yField: key, + color: colors[index - 1], + xAxis: null, + yAxis: { + label: false, + min: minValue, + max: maxValue, + grid: null, + line: null + }, + lineStyle: { + lineWidth: 2 + } + } + }) + legendItems.push({ + name: key.toUpperCase(), + value: key, + marker: { symbol: 'hyphen', style: { stroke: colors[index - 1], lineWidth: 2 } } + }) + } + const axis =JSON.parse(chart.xaxis) ?? [] + let dateFormat + const dateSplit = axis[0]?.datePattern === 'date_split' ? '/' : '-' + switch (axis[0]?.dateStyle) { + case 'y': + dateFormat = 'YYYY' + break + case 'y_M': + dateFormat = 'YYYY' + dateSplit + 'MM' + break + case 'y_M_d': + dateFormat = 'YYYY' + dateSplit + 'MM' + dateSplit + 'DD' + break + case 'y_M_d_H': + dateFormat = 'YYYY' + dateSplit + 'MM' + dateSplit + 'DD' + ' HH' + break + case 'y_M_d_H_m': + dateFormat = 'YYYY' + dateSplit + 'MM' + dateSplit + 'DD' + ' HH:mm' + break + case 'y_M_d_H_m_s': + dateFormat = 'YYYY' + dateSplit + 'MM' + dateSplit + 'DD' + ' HH:mm:ss' + break + default: + dateFormat = 'YYYY-MM-dd HH:mm:ss' + } + let option = { + data, + theme, + appendPadding: getPadding(chart), + slider: { + start: 0.5, + end: 1 + }, + plots: [ + ...averageLines, + { + type: 'stock', + top: true, + options: { + meta: { + [xAxisDataeaseName]: { + mask: dateFormat + } + }, + stockStyle: { + stroke: 'black', + lineWidth: 0.5 + }, + xField: xAxisDataeaseName, + yField: [ + yAxis[0].dataeaseName, + yAxis[1].dataeaseName, + yAxis[2].dataeaseName, + yAxis[3].dataeaseName + ], + legend: !legend?false:{ + position: 'top', + custom: true, + items: legendItems + }, + fallingFill: hexColorToRGBA('#ef5350', alpha), + risingFill: hexColorToRGBA('#26a69a', alpha), + } + } + ] + } + option = configBasicStyle(chart, option) + option = configXAxis(chart, option) + option = configYAxis(chart, option) + option = configTooltip(chart, option) + option = customConfigEmptyDataStrategy(chart,option) + const plot = new Mix(container, option) + registerEvent(data, plot, averagesLineData) + plot.on('schema:click', evt => { + const selectSchema = evt.data.data[xAxisDataeaseName] + const paramData = cloneDeep(chart.data?.data ?? []) + const selectData = paramData.filter(item => item.field === selectSchema) + const quotaList = [] + selectData.forEach(item => { + quotaList.push({ ...item.quotaList[0], value: item.value }) + }) + if (selectData.length) { + const param = { + x: evt.x, + y: evt.y, + data: { + data: { + ...evt.data.data, + value: quotaList[0].value, + name: selectSchema, + dimensionList: selectData[0].dimensionList, + quotaList: quotaList + } + } + } + action(param) + } + }) + return plot +} diff --git a/core/frontend/src/views/chart/chart/bar/stock_line_util.js b/core/frontend/src/views/chart/chart/bar/stock_line_util.js new file mode 100644 index 0000000000..03be84d0d5 --- /dev/null +++ b/core/frontend/src/views/chart/chart/bar/stock_line_util.js @@ -0,0 +1,423 @@ +import {getXAxis, getYAxis} from '@/views/chart/chart/common/common_antv' +import { valueFormatter } from '@/views/chart/chart/formatter' +import {cloneDeep} from "lodash"; +import {handleEmptyDataStrategy} from "@/views/chart/chart/util"; + +/** + * 计算收盘价平均值 + * @param data + * @param dayCount + * @param chart + */ +export const calculateMovingAverage = (data, dayCount, chart) => { + const xAxis = JSON.parse(chart.xaxis) + const yAxis = JSON.parse(chart.yaxis) + // 时间字段 + const xAxisDataeaseName = xAxis[0].dataeaseName + // 收盘价字段 + const yAxisDataeaseName = yAxis[1].dataeaseName + const result = [] + for (let i = 0; i < data.length; i++) { + if (i < dayCount) { + result.push({ + [xAxisDataeaseName]: data[i][xAxisDataeaseName], + value: null + }) + } else { + const sum = data + .slice(i - dayCount + 1, i + 1) + .reduce((sum, item) => sum + item[yAxisDataeaseName], 0) + result.push({ + [xAxisDataeaseName]: data[i][xAxisDataeaseName], + value: parseFloat((sum / dayCount).toFixed(3)) + }) + } + } + return result +} + +/** + * 获取数据集合中对象属性值的最大最小值 + * @param data + */ +export const calculateMinMax = data => { + return data.reduce( + (acc, current) => { + // 获取 current 对象的所有属性值 + const values = Object.values(current) + // 过滤出数字值 + const numericValues = values.filter(value => typeof value === 'number') ?? [] + // 找到 current 对象的数字属性值中的最大值和最小值 + // 如果存在数字值,则计算当前对象的最大值和最小值 + if (numericValues.length > 0) { + const currentMax = Math.max(...numericValues) + const currentMin = Math.min(...numericValues) + // 更新全局最大值和最小值 + acc.maxValue = Math.max(acc.maxValue, currentMax) + acc.minValue = Math.min(acc.minValue, currentMin) + } + return acc + }, + { maxValue: Number.NEGATIVE_INFINITY, minValue: Number.POSITIVE_INFINITY } + ) +} + +/** + * 注册图表事件 + * @param data + * @param plot + * @param averagesLineData + */ +export const registerEvent = (data, plot, averagesLineData) => { + // 监听图例点击事件,显示隐藏 + let risingVisible = true + plot.on('legend-item:click', evt => { + const { value } = evt.target.get('delegateObject').item + if (value === 'k') { + risingVisible = !risingVisible + plot.chart.geometries.forEach(geom => { + if (geom.type === 'schema') { + geom.changeVisible(risingVisible) + } + }) + } else { + const lines = plot.chart.geometries.filter(item => item.type === 'line') + const points = plot.chart.geometries.filter(item => item.type === 'point') + let lineIndex = 0 + for (const key of averagesLineData.keys()) { + lineIndex++ + if (key === value) { + lines[lineIndex - 1].changeVisible(!lines[lineIndex - 1].visible) + points[lineIndex - 1].changeVisible(!points[lineIndex - 1].visible) + } + } + } + }) + // 监听图表渲染事件 + plot.on('afterrender', e => { + let first = false + if (plot.chart.options.slider.start === 0.5 && plot.chart.options.slider.end === 1) { + first = true + } + if (e.view?.options?.scales) { + const startIndex = Math.floor(0.5 * data.length) + const endIndex = Math.ceil(1 * data.length) + const filteredData = data.slice(startIndex, endIndex) + const { maxValue, minValue } = calculateMinMax( + first ? filteredData : e.view.filteredData + ) + const a = e.view.options.scales + Object.keys(a).forEach(item => { + if (a[item].max) { + a[item].max = maxValue + } + if (a[item].min) { + a[item].min = minValue + } + }) + } + }) + // 监听图例组点击事件,设置缩放 + plot.on('legend-item-group:click', e => { + if (e.view?.options?.scales) { + const { maxValue, minValue } = calculateMinMax(e.view.filteredData) + const a = e.view.options.scales + Object.keys(a).forEach(item => { + if (a[item].max) { + a[item].max = maxValue + } + if (a[item].min) { + a[item].min = minValue + } + }) + } + }) + // 监听滑块事件,设置缩放 + plot.on('slider:valuechanged', e => { + const start = e.gEvent.currentTarget.cfg.component.cfg.start + const end = e.gEvent.currentTarget.cfg.component.cfg.end + plot.chart.options.slider.start = start + plot.chart.options.slider.end = end + const startIndex = Math.floor(start * data.length) + const endIndex = Math.ceil(end * data.length) + const filteredData = data.slice(startIndex, endIndex) + const { maxValue, minValue } = calculateMinMax(filteredData) + const a = e.view.options.scales + Object.keys(a).forEach(item => { + if (a[item].max) { + a[item].max = maxValue + } + if (a[item].min) { + a[item].min = minValue + } + }) + }) +} + +export const configBasicStyle = (chart, options) => { + // size + const customAttr = JSON.parse(chart.customAttr) + const s = JSON.parse(JSON.stringify(customAttr.size)) + const smooth = s.lineSmooth + const point = { + size: s.lineSymbolSize, + shape: s.lineSymbol + } + const lineStyle = { + lineWidth: s.lineWidth + } + const plots = [] + options.plots.forEach(item => { + if (item.type === 'line') { + plots.push({ ...item, options: { ...item.options, smooth, point, lineStyle } }) + } + if (item.type === 'stock') { + plots.push({ ...item }) + } + }) + return { + ...options, + plots + } +} + +export const configTooltip = (chart, options)=> { + const tooltipAttr = JSON.parse(chart.customAttr).tooltip + const newPlots = [] + const linePlotList = options.plots.filter(item => item.type === 'line') + linePlotList.forEach(item => { + newPlots.push(item) + }) + const stockPlot = options.plots.filter(item => item.type === 'stock')[0] + if (!tooltipAttr.show) { + const stockOption = { + ...stockPlot.options, + tooltip: { + showContent: false + } + } + newPlots.push({ ...stockPlot, options: stockOption }) + return { + ...options, + plots: newPlots + } + } + + const showFiled = chart.data.fields + const yAxis = cloneDeep(JSON.parse(chart.yaxis)) + const customTooltipItems = originalItems => { + const formattedItems = originalItems.map(item => { + const fieldObj = showFiled.find(q => q.dataeaseName === item.name) + const displayName = fieldObj?.chartShowName || fieldObj?.name || item.name + const formattedName = displayName.startsWith('ma') ? displayName.toUpperCase() : displayName + if(!yAxis[0].formatterCfg){ + yAxis[0].formatterCfg = { + type: 'value', // auto,value,percent + unit: 1, // 换算单位 + suffix: '', // 单位后缀 + decimalCount: 3, // 小数位数 + thousandSeparator: true// 千分符 + } + } + if(yAxis[0].formatterCfg.type === 'auto'){ + yAxis[0].formatterCfg.type = 'value' + yAxis[0].formatterCfg.decimalCount = 3 + } + const formattedValue = valueFormatter(item.value, yAxis[0].formatterCfg) + return { + ...item, + name: formattedName, + value: formattedValue, + color: item.color + } + }) + + const hasKLine = formattedItems.some(item => !item.name.startsWith('MA')) + const kLines = formattedItems.filter(item => !item.name.startsWith('MA')) + return hasKLine + ? [ + { name: '日K', value: '', marker: true, color: kLines[0]?.color }, + ...kLines, + ...formattedItems.filter(item => item.name.startsWith('MA')) + ] + : formattedItems + } + const formatTooltipItem = (item) => { + const size = item.name.startsWith('MA') || !item.value ? 10 : 5 + const markerMarginRight = item.name.startsWith('MA') || !item.value ? 5 : 9 + const markerMarginLeft = item.name.startsWith('MA') || !item.value ? 0 : 2 + return ` +