3
0
0

私人模板

2026-08-21
2026-08-21
文章摘要
|
<template>
  <div class="component-share">
    <a-row type="flex" class="component-share-row">
      <a-col :span="24" class="component-share-children" :style="sizeStyle">
        <DropDownSlot :style="sizeStyle" :chartId="chartId">
          <div class="chart-container-inner">
            <div class="chart" ref="rMain"></div>
          </div>
        </DropDownSlot>
      </a-col>
    </a-row>
  </div>
</template>

<script setup lang="ts">
import { getGridLayout, getTextWidth } from '@/utils/tools'
import { useD3Edit } from '@/views/stat/details/composables/use-edit-minxin'
import XWCharts from '@xiwicloud/xiwi-charts'
import {
  computed,
  nextTick,
  onBeforeUnmount,
  onMounted,
  shallowRef,
  useTemplateRef,
  watch,
} from 'vue'

interface Scale {
  width?: string
  height?: string
}

const props = withDefaults(
  defineProps<{
    chartData: Record<any, any>
    chartScale?: Scale
    chartId: string
  }>(),
  {
    chartScale: () => ({
      width: '150px',
      height: '150px',
    }),
  },
)

// Refs
const rMain = useTemplateRef('rMain')
const chart = shallowRef<any>(null)

// 使用 mixin 的逻辑
const { minGetInitRender, titleMxinStyle, subTitleMxinStyle, fillOptions } = useD3Edit()

const sizeStyle = computed(() => ({
  width: props.chartScale.width,
  height: props.chartScale.height,
}))

//设置一个变量-例如年月日这种距离底部的高度-
const axisLabelsBottomGap = 70
/**
 * 获取百分比 Top 值的函数
 * @param index
 * @param footerArray footer的数组
 * @returns '%'
 */
const getTopPercent = (index: number, footerArray: any[]): string => {
  // 1. 获取容器总高度(未获取到则默认 400)
  const totalHeight = chart.value?.getHeight() || 400

  // 2. 计算原本的 px 偏移量
  const pxValue = totalHeight - axisLabelsBottomGap + footerArray.length * 10 + 22 * index

  // 3. 计算百分比(保留两位小数)
  const percentage = ((pxValue / totalHeight) * 100).toFixed(2)

  return `${percentage}%`
}
/**
 * 计算百分比 Left 值的函数
 * @param num
 * @return '%'
 */
const getLeftPercent = (num: number): string => {
  // 1. 获取容器总宽度(未获取到则默认 600px)
  const totalWidth = chart.value?.getWidth() || 600

  // 2. 将 15px 转为百分比(保留两位小数)
  const percentage = ((num / totalWidth) * 100).toFixed(2)

  return `${percentage}%` // 例如:宽度为 600 时返回 "2.50%"
}
/**
 * 获取title
 * @param chartData
 */
const getTitle = (chartData: Record<string, any>) => {
  // 定义所有可能存在的标题配置项
  const titleConfigs = [
    {
      text: chartData.labels?.title || chartData.metadata?.title,
      textStyle: titleMxinStyle.value,
      type: 'title',
    },
    {
      text: chartData.labels?.secondTitle,
      textStyle: subTitleMxinStyle.value,
      type: 'subTtile',
    },
    {
      text: chartData.labels?.thirdTitle,
      textStyle: subTitleMxinStyle.value,
      type: 'subTtile',
    },
    {
      text: chartData.panelLabel,
      textStyle: subTitleMxinStyle.value,
      type: 'subTtile',
    },
  ]
  // 过滤掉 text 为空(undefined / null / 空字符串)的项,并动态计算 top
  return titleConfigs
    .filter((item) => Boolean(item.text))
    .map((item, index) => ({
      ...item,
      textAlign: 'center',
      left: 'center',
      top: `${index * 20}px`, // 动态根据索引计算高度:0, 20, 40...
    }))
}
/**
 * 脚注
 * @param chartData
 */
const getFooter = (chartData: Record<string, any>) => {
  // 定义所有可能存在的脚注配置项
  const footerConfigs = [
    { text: chartData.rowFilterFootnote },
    { text: chartData.labels?.secondFootnote },
    { text: chartData.labels?.footnote },
    { text: chartData.panelLayout?.label }, // 使用可选链防止 panelLayout 为 undefined 时报错
  ]

  // 公共的文本样式
  const footnoteTextStyle = {
    ...subTitleMxinStyle.value,
    fontStyle: 'italic',
  }

  // 过滤掉 text 为空的项,并统一映射属性和计算 bottom
  return footerConfigs
    .filter((item) => Boolean(item.text))
    .map((item, index) => ({
      ...item,
      textStyle: footnoteTextStyle,
      textAlign: 'center',
      left: '0',
      bottom: 10 + index * 15, // 利用索引动态计算:10, 25, 40...
      type: 'footnote',
    }))
}

/**
 *
 * @param chartData
 * @param param1 - { chartCount, chartColumns, chartList }
 * @return  每个元素的位置信息 {left, top, width, height}
 */
const getGridConfigs = ({
  chartCount,
  chartColumns,
  chartList,
}): { left: string; top: string; width: string; height: string }[] => {
  return getGridLayout(
    chartCount,
    chartColumns,
    [0, chartList.length > 0 ? 20 : 0],
    { top: 20, right: 20, bottom: 20, left: 20 },
    { width: chart.value?.getWidth(), height: chart.value?.getHeight() },
  ) as { left: string; top: string; width: string; height: string }[]
}

/**
 *
 * @param param0 - { gridConfig, panelItem }
 * @return  返回单个grid配置
 */
const getGrid = ({ gridConfig, panelItem }) => {
  // 头部的展示逻辑
  const nameTopBox = (panelLabel) => {
    if (panelLabel) {
      return {
        name: panelItem.panelLabel || '',
        nameTextStyle: {
          show: true,
          fontSize: 11,
          height: 16,
          color: 'var(--colorTextSecondary, #505363)',
          padding: [0, 0, 4, 0],
        },
      }
    } else {
      return {}
    }
  }
  return {
    left: gridConfig.left,
    top: gridConfig.top,
    width: gridConfig.width,
    height: gridConfig.height,
    containLabel: true,
    show: true,
    borderWidth: [
      panelItem.xaxis?.highDisplay?.[0] ?? 1,
      panelItem.yaxis?.highDisplay?.[0] ?? 1,
      panelItem.xaxis?.lowDisplay?.[0] ?? 1,
      panelItem.yaxis?.lowDisplay?.[0] ?? 1,
    ],
    borderColor: 'var(--chart-axis-line, #E2E4E9)',
    ...nameTopBox(panelItem.panelLabel),
  }
}

/**获取legend数据 注:缺少一个计算最大宽度的逻辑 */
const getLegend = (chartData) => {
  let legendWidth = 0
  const getlegendFormatter = () => {
    const icons = []
    const data = chartData.legend.items.map((it, index) => {
      icons.push({
        name: it.symbol,
        dataIndex: index,
        itemWidth: iconWH[it.symbol]?.w,
        itemHeight: iconWH[it.symbol]?.h,
        itemStyle: {
          rotate: it.symbolRotate || 0,
          color: it.symbolColor,
          borderColor: it.symbolColor,
        },
      })
      icons.push({
        name: 'line',
        dataIndex: index,
        itemStyle: {
          color: it.lineColor,
          borderType: it.lineDash?.length > 0 ? it.lineDash : 'solid',
        },
      })
      const obj = {}
      it.seriesNames.forEach((seriesName, seriesNameIndex) => {
        obj[`key${seriesNameIndex}`] = seriesName
      })
      return obj
    })
    const resObj: Record<string, any> = {
      columns: [{ icons: [...icons], title: '' }],
      data: data,
    }
    chartData.legend.titles.forEach((title, titleIndex) => {
      resObj.columns.push({
        title: title,
        key: `key${titleIndex}`,
        customCell: () => {
          return {
            style: {
              'text-align': 'left',
              'max-width': '102px',
            },
          }
        },
      })
    })

    const validRows = data

    // 修改部分:遍历每列标题,同时比较表头 title 与该列所有行数据的文本宽度
    const totalColumnsTextWidth = (chartData.legend.titles || []).reduce(
      (sumWidth, title, titleIndex) => {
        const key = `key${titleIndex}`

        // 1. 计算当前列表头 title 的宽度
        const titleWidth = getTextWidth(title)

        // 2. 计算当前列所有数据行的最大宽度
        const maxRowWidth = Math.max(0, ...validRows.map((item) => getTextWidth(item[key])))

        // 3. 取表头和行数据中的较大者作为该列的宽度
        const colMaxWidth = Math.max(titleWidth, maxRowWidth)

        return sumWidth + colMaxWidth
      },
      0,
    )
    // 3. 动态计算总宽度:图标列(约25px) + 所有列文本最宽之和 + 单元格 Padding (列数+1)*8px
    const columnCount = (chartData.legend.titles?.length || 0) + 1 // +1 是因为包含图标列
    const computedWidth = Math.ceil(25 + totalColumnsTextWidth + columnCount * 8)

    // 4. 返回表格结构对象,附带 computedWidth
    return {
      ...resObj,
      computedWidth: computedWidth + 10,
    }
  }

  const iconWH = {
    square: { w: 8, h: 8 },
    circle: { w: 32, h: 32 },
    diamond: { w: 16, h: 12 },
    triangle: { w: 14, h: 14 },
  }

  if (chartData.legend?.items && chartData.legend.items.length) {
    const legendFormatter = getlegendFormatter()
    return {
      list: [
        {
          top: 50,
          right: 0,
          padding: [0, 0, 0, 10],
          width: legendFormatter.computedWidth,
          nameTextStyle: { show: true, fontSize: 12 },
          itemStyle: { borderWidth: 1, borderColor: 'var(--colorTextSecondary,#505363)' },
          formatter: () => {
            return legendFormatter
          },
        },
      ],
      width: legendWidth,
    }
  }
  return {
    list: [],
    width: legendWidth,
  }
}

/**
 * 获取X轴ticks
 * @param customXaxis
 */
const xAxisAxisTick = (customXaxis, chartData) => {
  const targetXaxis = customXaxis || chartData?.xaxis
  const obj = { show: true }
  if (targetXaxis?.ticks && targetXaxis.ticks.length > 0) {
    obj['customValues'] = targetXaxis.ticks.map((t) => t.value)
  }
  return obj
}
/**
 * 获取Y轴ticks
 * @param customYaxis
 */
const yAxisAxisTick = (customYaxis, chartData) => {
  const targetYaxis = customYaxis || chartData?.yaxis
  const obj = { show: true }
  if (targetYaxis?.ticks && targetYaxis.ticks.length > 0) {
    obj['customValues'] = targetYaxis.ticks.map((t) => t.value)
  }
  return obj
}
// 抽离单组 X轴 逻辑,支持传入 gridIndex 并赋予特定的 panelXaxis 节点
const createXAxisConfig = ({ targetXaxis, gridIndex, footerArray = [], chartData }) => {
  const array = []
  //轴的显示与隐藏
  const lowMask = targetXaxis.lowDisplay || [true, true, true, false]
  const highMask = targetXaxis.highDisplay || [true, true, true, false]
  const { min: xmin, max: xmax } = targetXaxis
  const baseXAxisFields = {
    type: 'value',
    min: xmin,
    max: xmax,
    ...(gridIndex !== undefined ? { gridIndex } : {}),
  }
  //X轴有多种刻度
  if (targetXaxis.categoryLevelValues && targetXaxis.categoryLevelValues.length > 0) {
    const labels = targetXaxis.categoryLevels
    let isNameExit = false
    /**
     * 获取nameTextStyle
     * @param index
     */
    const getNameTextStyle: (index: number) => {
      nameTextStyle?: Record<string, any>
      nameLocation: string
    } = (index) => {
      if (targetXaxis.categoryLevelValues.length > 1) {
        return {
          nameLocation: gridIndex !== undefined ? 'center' : 'left',
          nameTextStyle:
            gridIndex !== undefined
              ? { color: '#666', fontSize: 12 }
              : {
                  left: getLeftPercent(15),
                  top: getTopPercent(index, footerArray),
                  color: '#666',
                  fontSize: 12,
                },
        }
      } else {
        return {
          nameLocation: 'center',
        }
      }
    }
    targetXaxis.categoryLevelValues.forEach((e, index) => {
      if (lowMask.some((v) => v)) {
        isNameExit = true
        array.push({
          ...baseXAxisFields,
          position: 'bottom',
          axisLine: { show: !!lowMask[0] },
          axisTick: lowMask[1] ? xAxisAxisTick(targetXaxis, chartData) : { show: false },
          axisLabel: {
            show: !!lowMask[2],
            _baseLineLabels: chartData.timeLabels,
            formatter: function (value) {
              const res = targetXaxis?.ticks.find((e) => {
                return e.value == value
              })
              if (res) {
                return e[res.index]
              }
            },
            margin: 20 * index,
          },
          name: labels[index],
          splitLine: { show: targetXaxis?.showGrid },
          nameGap: gridIndex !== undefined ? 25 : 0,
          ...getNameTextStyle(index),
        })
      }
      if (highMask.some((v) => v)) {
        array.push({
          ...baseXAxisFields,
          position: 'top',
          axisLine: { show: !!highMask[0] },
          axisTick: highMask[1] ? xAxisAxisTick(targetXaxis, chartData) : { show: false },
          axisLabel: {
            show: !!highMask[2],
            formatter: function (value) {
              const res = chartData?.xaxis?.ticks.find((e) => {
                return e.value == value
              })
              if (res) {
                return e[res.index]
              }
            },
            margin: 20 * index,
          },
          name: isNameExit ? '' : labels[index],
          splitLine: { show: false },
          nameGap: gridIndex !== undefined ? 25 : 0,
          ...getNameTextStyle(index),
        })
      }
    })
  } else {
    if (lowMask.some((v) => v)) {
      array.push({
        ...baseXAxisFields,
        position: 'bottom',
        axisLine: { show: !!lowMask[0] },
        axisTick: lowMask[2] ? xAxisAxisTick(targetXaxis, chartData) : { show: false },
        axisLabel: {
          show: !!lowMask[1],
          _baseLineLabels: chartData.timeLabels,
          formatter: function (value) {
            const res = chartData?.xaxis?.ticks.find((e) => {
              return e.value == value
            })
            if (res) {
              return chartData.timeLabels[res.index]
            }
          },
        },
        name: chartData.metadata?.xaxisLabel,
        nameLocation: gridIndex !== undefined ? 'center' : 'center',
        nameGap: gridIndex !== undefined ? 0 : 10,
        splitLine: { show: targetXaxis?.showGrid },
      })
    }
    if (highMask.some((v) => v)) {
      array.push({
        ...baseXAxisFields,
        position: 'top',
        axisLine: { show: !!highMask[0] },
        axisTick: highMask[2] ? xAxisAxisTick(targetXaxis, chartData) : { show: false },
        axisLabel: {
          show: !!highMask[1],
          formatter: function (value) {
            const res = chartData?.xaxis?.ticks.find((e) => {
              return e.value == value
            })
            if (res) {
              return chartData.timeLabels[res.index]
            }
          },
        },
        splitLine: { show: false },
      })
    }
  }
  if (array.length === 0) {
    array.push({
      type: 'value',
      show: false,
      name: chartData.metadata?.xaxisLabel,
      ...(gridIndex !== undefined ? { gridIndex } : {}),
    })
  }
  return array
}

/**
 * 获取markLine
 * @param chartData
 */
const getMarkLine = (chartData: any) => {
  const markLine = []
  //参考线
  if (chartData.referenceLines && chartData.referenceLines.length > 0) {
    chartData.referenceLines.forEach((e) => {
      //X轴参考线
      if (e.axis === 'X') {
        markLine.push({
          symbol: ['none', 'none'],
          silent: true,
          label: [
            {
              show: !!e.label,
              formatter: e.label,
              position: 'endTop',
              color: '#DF5E5E',
            },
          ],
          lineStyle: {
            color: '#DF5E5E',
            width: 1,
            type: 'dashed',
          },
          data: [
            {
              xAxis: e.value,
              yAxis: chartData.yaxisMax,
            },
            {
              xAxis: e.value,
              yAxis: chartData.yaxisMin, // 跟y轴的min一样
            },
          ],
        })
        //Y轴参考线
      } else if (e.axis === 'Y') {
        markLine.push({
          symbol: ['none', 'none'],
          silent: true,
          label: [
            {
              show: !!e.label,
              formatter: e.label,
              offset: [-23, 0],
              position: 'start',
              color: '#DF5E5E',
            },
          ],
          lineStyle: {
            color: '#DF5E5E',
            width: 1,
            type: 'dashed',
          },
          data: [{ yAxis: e.value }, { xAxis: 'max' }],
        })
      }
    })
  }

  return markLine
}
/**
 * 根据 graphType 处理数据返回 series
 * @param data dataList 结构如:[[[x1,y1], [x2,y2]], [[x1,y1], [x2,y2]]]
 * @returns series 数组
 */
const transDataListToSeries = (
  data: Array<[string, number][]>,
  chartData: any,
  gridIndex?: number,
) => {
  if (!data || data.length === 0) {
    return []
  }

  const array: any[] = []

  data.forEach((e, index) => {
    //如果有数据标签就是用这个
    const getLabel = () => {
      if (chartData.labelType === 'Y_VALUE') {
        return {
          show: true,
          ...subTitleMxinStyle.value,
          formatter: function (params) {
            return params[1]
          },
        }
      } else if (chartData.labelType === 'ROW_NUMBER') {
        return {
          show: true,
          ...subTitleMxinStyle.value,
          formatter: function (params) {
            return params[0]
          },
        }
      } else if (chartData.labelType === 'FROM_COLUMN') {
        return {
          show: true,
          ...subTitleMxinStyle.value,
          formatter: function () {
            return chartData.labelColumnValues[index]
          },
        }
      } else {
        return {}
      }
    }
    const currSeries = chartData.series[index]
    // 存储断开的null分开
    let currentGroup = []
    let nameExit = false //如果有null断开的,防止后续加入name
    e.forEach((arr) => {
      if (arr[1] !== null) {
        // 如果值不为 null,就持续加到当前这组数据里
        currentGroup.push(arr)
      } else {
        // 遇到 null 了!如果当前组里有数据,说明前面有一段连续的线则放入array
        if (currentGroup.length > 0) {
          pushSeriesData(currentGroup, index)
          currentGroup = [] // 清空,准备接下一段线
        }
      }
    })

    if (currentGroup.length > 0) {
      pushSeriesData(currentGroup, index)
    }
    function pushSeriesData(dataSegment, index) {
      const sItem: any = {
        data: dataSegment,
        type: 'line',
        symbol: currSeries.symbol,
        symbolRotate: currSeries.symbolRotate ?? 0,
        symbolSize: 6,
        showAllSymbol: true,
        label: getLabel(),
        name: nameExit ? undefined : currSeries['seriesName'],
        params: {
          seriesIndex: index,
          //这个参数是让这个线在图形编辑添加label
          _type: 'line',
          //这个参数用于图形编辑行标签
          _rvalues: currSeries.rvalues,
        },
        lineStyle: {
          color: currSeries.color,
          width: 1,
          type: currSeries.lineDash?.length > 0 ? currSeries.lineDash : 'solid',
        },
        itemStyle: {
          color: currSeries.color,
          borderColor: currSeries.color,
        },
        markLine: getMarkLine(chartData),
      }
      if (gridIndex !== undefined) {
        sItem.xAxisIndex = gridIndex * 2
        sItem.yAxisIndex = gridIndex * 2
      }
      array.push(sItem)
      nameExit = true
    }
  })
  return array
}

/**
 *
 * @param data props.chartData
 * @returns option
 * @description 根据传入的数据生成图表选项
 */
const getOption = (chartData: Record<any, any>): any => {
  /**当前图表的布局模式 */
  const layoutMode = 'SEPARATE_PANELS'
  //接口的基础数据
  const chartList = [
    {
      columns: 2,
      rows: 3,
      count: 5,
      plane: [
        {
          series: [
            {
              type: 'line',
              data: [
                [1, 1],
                [2, 1],
              ],
              symbol: 'none',
            },
          ],
        },
        {
          series: [
            {
              type: 'line',
              data: [
                [1, 1],
                [2, 1],
              ],
              symbol: 'none',
            },
          ],
        },
        {
          series: [
            {
              type: 'line',
              data: [
                [1, 1],
                [2, 1],
              ],
              symbol: 'none',
            },
          ],
        },
        {
          series: [
            {
              type: 'line',
              data: [
                [1, 1],
                [2, 1],
              ],
              symbol: 'none',
            },
          ],
        },
        {
          series: [
            {
              type: 'line',
              data: [
                [1, 1],
                [2, 1],
              ],
              symbol: 'none',
            },
          ],
        },
      ],
    },
    // {
    //   columns: 5,
    //   rows: 1,
    //   count: 5,
    //   plane: [
    //     {
    //       series: [
    //         {
    //           type: 'line',
    //           data: [
    //             [1, 1],
    //             [2, 1],
    //           ],
    //           symbol: 'none',
    //         },
    //       ],
    //     },
    //     {
    //       series: [
    //         {
    //           type: 'line',
    //           data: [
    //             [1, 1],
    //             [2, 1],
    //           ],
    //           symbol: 'none',
    //         },
    //       ],
    //     },
    //     {
    //       series: [
    //         {
    //           type: 'line',
    //           data: [
    //             [1, 1],
    //             [2, 1],
    //           ],
    //           symbol: 'none',
    //         },
    //       ],
    //     },
    //     {
    //       series: [
    //         {
    //           type: 'line',
    //           data: [
    //             [1, 1],
    //             [2, 1],
    //           ],
    //           symbol: 'none',
    //         },
    //       ],
    //     },
    //     {
    //       series: [
    //         {
    //           type: 'line',
    //           data: [
    //             [1, 1],
    //             [2, 1],
    //           ],
    //           symbol: 'none',
    //         },
    //       ],
    //     },
    //   ],
    // },
  ]

  /**图形总数 */
  const chartCount = chartList.reduce((pre, cur) => cur.count + pre, 0)
  /**列数取第一个就行 */
  const chartColumns = chartList[0].columns
  /**行数 */
  const cahrtRows = chartList.reduce((pre, cur) => cur.rows + pre, 0)
  const gridConfigs = getGridConfigs({
    chartCount,
    chartColumns,
    chartList,
  })
  console.log('gridConfigs', gridConfigs)
  const finalGrids = []
  const finalXAxes = []
  const finalYAxes = []
  const finalSeries = []
  let globalIndex = 0
  chartList.forEach((chartItem) => {
    chartItem.plane.forEach((panelItem) => {
      finalGrids.push(getGrid({ gridConfig: gridConfigs[globalIndex], panelItem }))

      globalIndex++ // 每个panelItem对应一个gridConfig
    })
  })
  console.log('finalGrids', finalGrids)
}

// 初始化图表
const init = async () => {
  if (!rMain.value) return

  if (chart.value) {
    chart.value.dispose()
  }

  chart.value = new XWCharts(rMain.value)
  const option = getOption(props.chartData) // 假设你有一个函数来生成图表选项
  console.log('option', option)
  minGetInitRender({
    graph: chart.value,
    options: option,
    chartId: props.chartId,
  })

  chart.value?.setOption(fillOptions(option))
}

// 监听数据变化
watch(
  () => props.chartData,
  () => {
    init()
  },
)

onMounted(() => {
  nextTick(async () => {
    await init()
  })
})

const disposeChart = () => {
  if (chart.value) {
    chart.value.dispose()
    chart.value = null
  }
}

onBeforeUnmount(() => {
  disposeChart()
})

// 暴露常用方法给外部
defineExpose({
  init,
  disposeChart,
  chart,
})
</script>

<style scoped lang="scss">
.component-share {
  .component-share-row {
    .component-share-children {
      margin-top: 0px !important;
    }
  }
}
.chart-container-inner {
  height: 100%;
  width: 100%;
}
.chart {
  width: 100%;
  height: 100%;
}
</style>

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或者给予支持!

评论