彻底解决echats的X轴和Y轴的文字遮挡问题

1,864 阅读2分钟

关键配置:

// 页面配置

xAxis.triggerEvent: true // 启用鼠标事件
yAxis.triggerEvent: true // 启用鼠标事件

series: [{
  label: {
    formatter: (params) => {
      return params.value.length > 8 ? params.value.substr(0, 8) + '...' : params.value
    }
  }
}]
// 鼠标悬浮提示

// 封装设置坐标轴悬浮提示的方法
    setupAxisHoverExtension(myChart) {
      const elementDiv = this.createOrGetExtensionDiv();

      myChart.on("mouseover", (params) => {
        if (["xAxis", "yAxis"].includes(params.componentType)) {
          this.showAxisTooltip(elementDiv, params.value);
        }
      });

      myChart.on("mouseout", (params) => {
        if (["xAxis", "yAxis"].includes(params.componentType)) {
          this.hideAxisTooltip(elementDiv);
        }
      });

      document.querySelector("html").onmousemove = (event) => {
        if (elementDiv.style.display !== "none") {
          this.updateTooltipPosition(elementDiv, event);
        }
      };
    },
    // 创建或获取悬浮提示的 div 元素
    createOrGetExtensionDiv() {
      let elementDiv = document.getElementById("extension");
      if (!elementDiv) {
        elementDiv = document.createElement("div");
        elementDiv.setAttribute("id", "extension");
        elementDiv.style.display = "none";
        document.querySelector("html").appendChild(elementDiv);
      }
      return elementDiv;
    },
    // 显示坐标轴悬浮提示
    showAxisTooltip(elementDiv, value) {
      const elementStyle =
        "position: absolute;z-index: 99999;color: #fff;font-size: 12px;padding: 5px;display: inline;border-radius: 4px;background-color: #303133;box-shadow: rgba(0, 0, 0, 0.3) 2px 2px 8px";
      elementDiv.style.cssText = elementStyle;
      elementDiv.innerHTML = value;
    },
    // 隐藏坐标轴悬浮提示
    hideAxisTooltip(elementDiv) {
      elementDiv.style.display = "none";
    },
    // 更新悬浮提示的位置
    updateTooltipPosition(elementDiv, event) {
      const xx = event.pageX + 15;
      const yy = event.pageY + 15;
      elementDiv.style.top = `${yy}px`;
      elementDiv.style.left = `${xx}px`;
    },

场景完整代码:

(我当前的场景是用数据库的数据去喂n8n,让n8n根据数据生成apach-echats的option配置,然后前端根据配置进行渲染)

    getChatHtml2(answerStr = "") {
      // 如果已经有缓存,直接返回缓存结果
      if (this.chartDataCache[answerStr]) {
        return this.chartDataCache[answerStr];
      }

      let htmlStr = [];
      const chartsTypeList = [
        "bar",
        "line",
        "scatter",
        "area",
        "heatmap",
        "boxplot",
        "candlestick",
      ];

      try {
        let data = JSON.parse(answerStr);
        // 非流式
        // htmlStr = JSON.parse(data.data[0].html);
        // 流式
        htmlStr = JSON.parse(JSON.parse(data.data)[0].html);
      } catch (error) {
        console.log("getChatHtml2-error:", error);
        return false;
      }

      if (!Array.isArray(htmlStr) && typeof htmlStr === "object") {
        htmlStr = [htmlStr];
      }

      // htmlStr = TEST5;

      // 为数组每一项添加唯一的 uuid
      let newArray = htmlStr.map((item) => {
        return {
          ...item,
          id: uuidv4(),
        };
      });

      this.$nextTick(() => {
        try {
          newArray.forEach((item) => {
            // 基于准备好的dom,初始化echarts实例
            const myChart = echarts.init(this.$refs[`chartRef_${item.id}`][0]);

            // 判断是否为需要 xAxis 和 yAxis 配置的图表类型(使用笛卡尔直角坐标系的图表)
            const isCartesianChart = () => {
              if (Array.isArray(item.series)) {
                return item.series.some((series) => {
                  return chartsTypeList.includes(series.type);
                });
              } else if (
                typeof item.yAxis === "object" &&
                item.yAxis !== null
              ) {
                return chartsTypeList.includes(item.series.type);
              } else {
                return false;
              }
            };

            const isShowLine = () => {
              if (isCartesianChart()) {
                return {
                  splitLine: {
                    show: true, // 显示网格线
                    opacity: 0.1, // 坐标轴网格线透明度设置为 0.1
                  },
                };
              } else {
                return {};
              }
            };

            // 使用笛卡尔直角坐标系的图表没有{d}参数,所以tooltip的formatter需要特殊处理
            if (isCartesianChart()) {
              if (Array.isArray(item.tooltip)) {
                item.tooltip = item.tooltip.map((tooltipItem) => ({
                  ...(tooltipItem || {}),
                  trigger: "item",
                  formatter: "{a} <br/>{b}: {c} ({d}%)",
                  confine: true, // 防止超出可视区域
                }));
              } else {
                item.tooltip = {
                  ...(item.tooltip || {}),
                  trigger: "item",
                  formatter: "{a} <br/>{b}: {c} ({d}%)",
                  confine: true, // 防止超出可视区域
                };
              }
            } else {
              item.tooltip = {
                ...(item.tooltip || {}),
                trigger: "item",
                formatter: "{a} <br/>{b}: {c}%",
                confine: true, // 防止超出可视区域
              };
            }

            item.title = {
              ...(item.title || {}),
              top: 0, // 让标题上对齐
              left: "left", //让标题左对齐
              textStyle: {
                fontSize: 14, //将标题字体大小设为 14px
              },
            };

            if (Array.isArray(item.legend)) {
              item.legend = item.legend.map((legendItem) => ({
                ...(legendItem || {}),
                textStyle: {
                  fontSize: 10, //将图例文字大小设为 10px。
                },
                top: 36, //保证其在标题下方且不重叠
                type: "scroll", //启用滚动条,当图例项过多时,用户可以通过滚动条查看。
                itemGap: 20,
              }));
            } else {
              item.legend = {
                ...(item.legend || {}),
                textStyle: {
                  fontSize: 10, //将图例文字大小设为 10px。
                },
                top: 36, //保证其在标题下方且不重叠
                type: "scroll", //启用滚动条,当图例项过多时,用户可以通过滚动条查看。
                itemGap: 20,
              };
            }

            if (isCartesianChart()) {
              if (Array.isArray(item.xAxis)) {
                item.xAxis = item.xAxis.map((xAxisItem) => ({
                  ...(xAxisItem || {}),
                  axisLabel: {
                    ...(xAxisItem.axisLabel || {}),
                    fontSize: 10, //将 x 轴标签的字体大小设为 10px。
                    // overflow: "truncate", //当标签内容过长时,将其截断显示。
                    // width: 72, // 指定标签宽度,超出部分显示为省略号,可按需调整
                  },
                  triggerEvent: true, // 启用鼠标事件
                  ...isShowLine(),
                }));
              } else {
                item.xAxis = {
                  ...(item.xAxis || {}),
                  axisLabel: {
                    ...(item.axisLabel || {}),
                    fontSize: 10, //将 x 轴标签的字体大小设为 10px。
                    // overflow: "truncate", //当标签内容过长时,将其截断显示。
                    // width: 72, // 指定标签宽度,超出部分显示为省略号,可按需调整
                  },
                  triggerEvent: true, // 启用鼠标事件
                  ...isShowLine(),
                };
              }

              // 多个y轴,是数组,需要遍历
              if (Array.isArray(item.yAxis)) {
                item.yAxis = item.yAxis.map((yAxisItem) => {
                  return {
                    ...(yAxisItem || {}),
                    axisLabel: {
                      ...(yAxisItem.axisLabel || {}),
                      fontSize: 10, //将 x 轴标签的字体大小设为 10px。
                      // overflow: "truncate", //当标签内容过长时,将其截断显示。
                      // width: 72, // 指定标签宽度,超出部分显示为省略号,可按需调整
                    },
                    triggerEvent: true, // 启用鼠标事件
                    ...isShowLine(),
                  };
                });
              } else {
                // 单个y轴,是对象
                item.yAxis = {
                  ...(item.yAxis || {}),
                  axisLabel: {
                    ...(item.axisLabel || {}),
                    fontSize: 10, //将 x 轴标签的字体大小设为 10px。
                    // overflow: "truncate", //当标签内容过长时,将其截断显示。
                    // width: 72, // 指定标签宽度,超出部分显示为省略号,可按需调整
                  },
                  triggerEvent: true, // 启用鼠标事件
                  ...isShowLine(),
                };
              }
            }

            item.dataZoom = [
              {
                type: "slider", //滑动条
                show: true, //开启
                yAxisIndex: [0],
                left: "93%", //滑动条位置
                start: 1,
                end: 50,
              },
              {
                type: "slider", //滑动条
                show: true, //开启
                xAxisIndex: [0],
                left: "93%", //滑动条位置
                start: 1, //初始化时,滑动条宽度开始标度
                end: 50, //初始化时,滑动条宽度结束标度
              },
            ];

            myChart.setOption(item);

            if (isCartesianChart()) {
              this.setupAxisHoverExtension(myChart);
            }

            // 防止重复添加同一个实例
            const existingChart = this.chartInstances.find(
              (chart) => chart === myChart
            );
            if (!existingChart) {
              this.chartInstances.push(myChart); // 将实例添加到数组
            }
          });
        } catch (error) {
          console.log("图表初始化失败:", error);
        }
      });

      // 过滤掉暂无数据的
      newArray = newArray.filter((item) => {
        return !item.error;
      });

      // 缓存结果
      this.chartDataCache[answerStr] = newArray;

      return newArray;
    },
    // 封装设置坐标轴悬浮提示的方法
    setupAxisHoverExtension(myChart) {
      const elementDiv = this.createOrGetExtensionDiv();

      myChart.on("mouseover", (params) => {
        if (["xAxis", "yAxis"].includes(params.componentType)) {
          this.showAxisTooltip(elementDiv, params.value);
        }
      });

      myChart.on("mouseout", (params) => {
        if (["xAxis", "yAxis"].includes(params.componentType)) {
          this.hideAxisTooltip(elementDiv);
        }
      });

      document.querySelector("html").onmousemove = (event) => {
        if (elementDiv.style.display !== "none") {
          this.updateTooltipPosition(elementDiv, event);
        }
      };
    },
    // 创建或获取悬浮提示的 div 元素
    createOrGetExtensionDiv() {
      let elementDiv = document.getElementById("extension");
      if (!elementDiv) {
        elementDiv = document.createElement("div");
        elementDiv.setAttribute("id", "extension");
        elementDiv.style.display = "none";
        document.querySelector("html").appendChild(elementDiv);
      }
      return elementDiv;
    },
    // 显示坐标轴悬浮提示
    showAxisTooltip(elementDiv, value) {
      const elementStyle =
        "position: absolute;z-index: 99999;color: #fff;font-size: 12px;padding: 5px;display: inline;border-radius: 4px;background-color: #303133;box-shadow: rgba(0, 0, 0, 0.3) 2px 2px 8px";
      elementDiv.style.cssText = elementStyle;
      elementDiv.innerHTML = value;
    },
    // 隐藏坐标轴悬浮提示
    hideAxisTooltip(elementDiv) {
      elementDiv.style.display = "none";
    },
    // 更新悬浮提示的位置
    updateTooltipPosition(elementDiv, event) {
      const xx = event.pageX + 15;
      const yy = event.pageY + 15;
      elementDiv.style.top = `${yy}px`;
      elementDiv.style.left = `${xx}px`;
    },

场景方案一:n8n根据数据库的数据生成图表html字符串给前端,前端去把html字符串拆成dom、css、js、js脚本这四个部分,然后在前端分别渲染

// chat.vue
-<template>
-  <div class="chart-container">
-    <!-- 循环渲染每个图表项 -->
-    <div v-for="(item, index) in chatData" :key="`bi_chat_${index}`">
-      <div>问题改写:{{ item.question }}</div>
-      <div v-html="getDaemionData(item.daemion)"></div>
-      <!-- 右上角按钮区域 -->
-      <div class="chart-container__button-group">
-        <el-button
-          @click="downloadData"
-          class="chart-container__button--download"
-          >下载数据明细</el-button
-        >
-        <el-button
-          @click="showSqlDialog(index)"
-          class="chart-container__button--sql"
-          >SQL</el-button
-        >
-        <svg-icon
-          icon-class="maximize"
-          class-name="chart-container__button--maximize"
-          @click="showMaximize(index)"
-        />
-      </div>
-      <!-- 渲染 HTML 内容 -->
-      <!-- 动态创建报表容器,添加唯一类名 -->
-      <div class="chart-container__content">
-        <div
-          :class="[
-            'chart-container__report-wrapper',
-            `chart-container__report-wrapper--${index}`,
-          ]"
-        >
-          <div :id="item.id + '-report-container-' + index"></div>
-        </div>
-      </div>
-    </div>
-
-    <!-- SQL 弹窗 -->
-    <el-dialog
-      :visible.sync="isSqlDialogVisible"
-      class="chart-container__sql-dialog"
-    >
-      <div slot="title" class="chart-container__sql-dialog-title">
-        <span> 查看SQL代码 </span>
-        <el-link type="primary" @click="handleCopySql(currentSql)"
-          >复制</el-link
-        >
-      </div>
-      <pre><code>
-          {{currentSql}}
-        </code></pre>
-    </el-dialog>
-
-    <el-dialog
-      v-if="isMaximizeDialogVisible"
-      :visible.sync="isMaximizeDialogVisible"
-      title="最大化折线图"
-      width="90%"
-      class="chart-container__maximize-dialog"
-    >
-      <MaximizeChart
-        :chat-data="chatData"
-        :currentIndex="currentMaximizeIndex"
-      />
-    </el-dialog>
-  </div>
-</template>
-
-<script>
-// 尝试使用默认导入
-import { data } from "./const";
-import { extractHtmlParts } from "./utils";
-import MarkdownIt from "markdown-it";
-import markdownItFootnote from "markdown-it-footnote";
-import markdownItTaskLists from "markdown-it-task-lists";
-import markdownItAbbr from "markdown-it-abbr";
-import markdownItContainer from "markdown-it-container";
-import hljs from "highlight.js";
-import markdownItHighlightjs from "markdown-it-highlightjs";
-import { handleCopy } from "@/utils/copy";
-import MaximizeChart from "./MaximizeChart.vue"; // 引入组件
-
-export default {
-  components: {
-    MaximizeChart,
-  },
-  data() {
-    return {
-      // 移除 showSqlDialog 定义
-      showMaximizeDialog: false,
-      md: new MarkdownIt({
-        html: true, // 启用HTML标签解析
-      })
-        .use(markdownItFootnote)
-        .use(markdownItTaskLists, { enabled: true })
-        .use(markdownItAbbr)
-        .use(markdownItContainer, "warning")
-        .use(markdownItHighlightjs, { hljs }), // 添加 markdown-it-highlightjs 插件
-      normalChartInstances: [], // 存储普通图表实例
-      isSqlDialogVisible: false,
-      currentSql: "",
-      isMaximizeDialogVisible: false,
-      currentMaximizeIndex: -1,
-    };
-  },
-  props: {
-    chatData: {
-      type: Array,
-      default: () => [],
-    },
-  },
-  watch: {
-    chatData: {
-      handler(newVal) {
-        if (!newVal.length) {
-          return;
-        }
-        newVal.forEach((item) => {
-          if (item.html) {
-            this.renderHtml(item.html, item.id);
-          }
-        });
-      },
-      deep: true,
-      immediate: true,
-    },
-  },
-  methods: {
-    renderHtml(reportItems, id) {
-      const resData = extractHtmlParts(reportItems, id);
-      console.log("html拆分后的结构:", resData);
-
-      // 检查 resData 是否有 scriptJsSrc
-      // 这里不检查了,统一加载本地的脚本文件
-      this.$nextTick(() => {
-        // 没有 scriptJsSrc 时直接调用渲染方法
-        this.renderReports([resData]);
-      });
-    },
-    getDaemionData(data = "") {
-      let dataStr = data.replace(/\n/g, "<br>");
-      return this.md.render(dataStr);
-    },
-    getHtmlData(data = "") {
-      return [extractHtmlParts(data)];
-    },
-    // 提取渲染报表的逻辑到单独方法
-    renderReports(reportItems) {
-      const allScripts = [];
-      const styleElements = [];
-
-      reportItems.forEach((item, index) => {
-        // 销毁旧的图表实例
-        const oldChartInstance = this.normalChartInstances[index];
-        if (oldChartInstance && oldChartInstance.destroy) {
-          oldChartInstance.destroy();
-          this.normalChartInstances[index] = null;
-        }
-
-        // 动态创建容器
-        const reportContainer = document.getElementById(
-          `${item.id}-report-container-${index}`
-        );
-        // 插入 HTML 内容,检查 item.dom 是否存在
-        if (item.dom) {
-          reportContainer.innerHTML = item.dom;
-        }
-
-        // 插入 CSS 样式,添加唯一类名前缀,模拟scoped效果,防止style全局污染
-        if (item.style) {
-          const uniqueClass = `${item.id}-report-container-${index}`;
-          const scopedStyle = item.style.replace(
-            /([^\r\n,{}]+)(,|\s*{)/g,
-            `#${uniqueClass} $1$2`
-          );
-          const styleElement = document.createElement("style");
-          styleElement.textContent = scopedStyle;
-          document.head.appendChild(styleElement);
-          styleElements.push(styleElement);
-        }
-
-        // 收集 item.js 代码
-        if (item.js) {
-          const scriptElement = document.createElement("script");
-          scriptElement.textContent = item.js;
-          scriptElement.type = "module"; // 设置为模块类型,隔离作用域
-          document.body.appendChild(scriptElement);
-        }
-      });
-    },
-    downloadData() {
-      this.$message.info("敬请期待!");
-    },
-    showSqlDialog(index) {
-      this.currentSql = this.chatData[index].sql;
-      this.isSqlDialogVisible = true;
-    },
-    showMaximize(index) {
-      this.currentMaximizeIndex = index;
-      this.isMaximizeDialogVisible = true;
-    },
-    handleCopySql(sql) {
-      handleCopy(
-        sql,
-        () => {
-          this.$message.success("内容已复制");
-        },
-        () => {
-          this.$message.success("复制失败,请手动复制");
-        }
-      );
-    },
-  },
-};
-</script>
-
-<style lang="scss" scoped>
-.chart-container {
-  position: relative;
-  padding-bottom: 20px;
-  line-height: 1.5;
-
-  &__button-group {
-    top: 10px;
-    right: 10px;
-    z-index: 1;
-    display: flex;
-    align-items: center;
-    justify-content: flex-end;
-    margin-top: 20px;
-  }
-
-  &__content {
-    padding-top: 40px;
-  }
-
-  &__button {
-    &--maximize {
-      font-size: 26px;
-      cursor: pointer;
-    }
-
-    &--maximize:hover {
-      color: #1890ff;
-    }
-
-    &--sql {
-      margin: 0 4px;
-    }
-  }
-
-  &__sql-dialog {
-    word-wrap: break-word !important;
-    word-break: break-all !important;
-    line-height: 1.5;
-
-    ::v-deep .el-dialog__body {
-      height: 600px;
-      max-height: 600px;
-      overflow-x: hidden;
-      overflow-y: auto;
-    }
-
-    // 保留代码部分的制表符、空格、空行,并且自动换行
-    pre,
-    code {
-      white-space: pre-wrap;
-    }
-
-    &-title {
-      display: flex;
-      justify-content: space-between;
-      align-items: center;
-      padding-right: 40px;
-    }
-  }
-
-  &__maximize-dialog {
-    ::v-deep .el-dialog__body {
-      height: 800px;
-      max-height: 800px;
-      overflow-x: hidden;
-      overflow-y: auto;
-    }
-  }
-}
-</style>



// util.js
// 提取 HTML 中的不同部分
-export const extractHtmlParts = (html, id) => {
-    const domRegex = /<body>([\s\S]*?)<\/body>/;
-    const styleRegex = /<style>([\s\S]*?)<\/style>/;
-    const jsRegex = /<script>([\s\S]*?)<\/script>/g;
-    const scriptSrcRegex = /<script\s+src="([^"]+)"\s*><\/script>/g;
-
-    let dom = '';
-    let style = '';
-    let js = '';
-    let scriptJsSrc = [];
-
-    // 提取 DOM 部分
-    const domMatch = domRegex.exec(html);
-    if (domMatch) {
-        dom = domMatch[1].trim();
-        // 移除 style 标签
-        dom = dom.replace(/<style>([\s\S]*?)<\/style>/gi, '');
-        // 移除 script 标签
-        dom = dom.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
-        // 移除外部 script 标签
-        dom = dom.replace(/<script\s+src="([^"]+)"\s*><\/script>/gi, '');
-    }
-
-    // 提取样式部分
-    const styleMatch = styleRegex.exec(html);
-    if (styleMatch) {
-        style = styleMatch[1].trim();
-    }
-
-    // 提取 JS 代码部分
-    let jsMatch;
-    while ((jsMatch = jsRegex.exec(html)) !== null) {
-        js += jsMatch[1].trim() + '\n';
-    }
-
-    // 提取外部脚本链接
-    let scriptSrcMatch;
-    while ((scriptSrcMatch = scriptSrcRegex.exec(html)) !== null) {
-        scriptJsSrc.push(scriptSrcMatch[1]);
+/**
+ * 替换所有外部脚本引用为本地Chart.js路径
+ * @param {string} html - 原始HTML字符串 
+ * @returns {string} 处理后的HTML字符串
+ */
+export const replaceAllExternalScripts = (html = "") => {
+  try {
+    if (typeof html !== 'string' || !html) {
+      return html;
     }
 
-    return { dom, style, js, scriptJsSrc, id };
-}
\ No newline at end of file
+    // 去除代码块标记,防止大模型不遵从规则仍然给出代码块
+    html = html.replace(/```html/g, '').replace(/```/g, '');
+    
+    // 更精确的正则匹配,确保只匹配有效的script标签,替换成本地Chart.js路径
+    return html.replace(
+      /<script\b(?:\s+[^>]*)?\bsrc\s*=\s*(["'])(?:(?!\1).)*\1(?:\s+[^>]*)?\s*><\/script>/gi,
+      '<script src="/chart.umd.min.js"></script>'
+    );
+  } catch (error) {
+    console.error('替换脚本出错:', error);
+    return html; // 出错时返回原内容
+  }
+};

场景方案二:n8n根据数据库的数据生成图表html字符串给前端,前端去把html字符串放到iframe进行渲染

                  <!-- <iframe
                    :srcdoc="getChatHtml(message.questionAnswer)"
                    frameborder="0"
                    style="width: 90%"
                    :ref="'iframeRef_' + index"
                    @load="handleIframeLoad(index)"
                  ></iframe> -->



    // 处理批量iframe加载完成事件
    handleIframeLoad(index) {
      this.$nextTick(() => {
        const iframe = this.$refs[`iframeRef_${index}`][0];
        if (iframe) {
          try {
            const iframeDoc =
              iframe.contentDocument || iframe.contentWindow.document;
            const height = iframeDoc.body.scrollHeight + 40;
            iframe.style.height = `${height}px`;
            // 设置iframe内部body的margin和padding为0
            iframeDoc.body.style.margin = "0";
            iframeDoc.body.style.padding = "0";
            iframeDoc.body.style.overflow = "hidden";
          } catch (e) {
            console.error("获取iframe高度失败:", e);
          }
        }
      });
    },

场景方案三:n8n根据数据库的数据生成图表的option配置给前端,前端去初始化echats