Skip to content

堆叠条形图模拟甘特图

AI 生成

本文由 AI 生成,可能存在不准确之处,请谨慎参考。

背景

某项目中的甘特图组件 src/components/common/GanttChart.vue(用 ECharts 渲染任务起止时间轴)用 ECharts 的堆叠条形图stack: 'gantt')模拟甘特图:

  • offset 系列:透明占位段,占据 [0, startDay),负责把彩色条“推”到正确位置;
  • duration 系列:实际彩色条,占据 [startDay, startDay + duration)
ts
series: [
  {
    name: 'offset',
    type: 'bar',
    stack: 'gantt',
    data: offsetData,
    barWidth,
    itemStyle: { color: 'transparent', borderColor: 'transparent' },
    tooltip: { show: false },
  },
  {
    name: 'duration',
    type: 'bar',
    stack: 'gantt',
    data: barData,
    barWidth,
    itemStyle: { borderRadius: 4 },
  },
],

问题现象

在黄色条上悬浮时:

  1. 黄色条本身直接消失;
  2. tooltip 正常出现;
  3. 图像其它部分变得非常透明;
  4. 轻微移动鼠标,指针样式在箭头与手指之间反复切换,tooltip 闪烁;
  5. 同一行的透明条上显示手指(不正确),但没有 tooltip(正确)。

在线演示

把鼠标移到黄色条上并轻微移动,对比「错误版本」与「修复版本」的交互差异(建议桌面端体验)。

排查过程

1. 布局层面:透明段与黄色段不重叠

堆叠语义下两段按值累计排布,透明段 [0, startDay) 与黄色段 [startDay, startDay+duration) 首尾相接,共享 startDay 边界,rect 不交叠、也无间隙。因此“黄色区域重叠”的猜测在布局上不成立。

但两段是两个独立 bar 元素:

  • 共享边界那条线上,命中测试取最上层元素(后画的黄色段胜出);
  • 黄色段有 borderRadius: 4、透明段没有,黄色段左右圆角处各有一小块(约 4×4px)既不属于透明段也不属于黄色段路径的“命中死区”;
  • 透明段本身是普通 bar(fill 为 transparent),默认 cursor 就是 pointer,因此显示手指;其 tooltip 被 tooltip: { show: false } 关闭。

这些能解释“透明条显示手指、无 tooltip”,但解释不了光标在箭头/手指间的反复切换:透明段和黄色段的光标都是 pointer,单纯在交界处来回不会出现箭头。

2. 最小复现页:同样的 option 不闪烁

用近似的 option 建了 gantt-debug.html(4 行、小 tooltip),但条形颜色用的是 hex(zrender 可解析):

  • “黄色条消失 + 全图变透明”可以复现;
  • “光标反复切换 + tooltip 闪烁”不复现。

结论:闪烁不是这份 option 在静止条件下的直接结果,一定有运行时/交互层面的因素。结合下文的根因,debug 页不复现闪烁还有更充分的原因:hex 颜色可被 zrender 解析,liftColor 成功、emphasis 时仍保有 fill,振荡循环根本不会启动——「同样的 option」其实并不完全一样,差异就在颜色。

3. 排除项

  • DOM 覆盖层elementsFromPoint 实测黄色条区域顶层就是 canvas,没有透明层盖住条形;
  • 组件版本:dev server(5174)分发的就是工作区当前的 GanttChart.vue,与复现页同源;
  • 数据几何:真实项目 6 行、条高约 33px、行间空隙约 22px,条端点贴绘图区左右边界,几何无异常。

4. 运行时取证(关键证据)

在组件里挂临时只读 hook,监听三层事件:

  • DOM canvas 的 mousemove(不依赖图表实例,保证有日志);
  • zrender 的 mousemove / mouseout / globalout(拿到命中元素);
  • 40ms 定时采样 handler.findHover[1](lastX, lastY)鼠标不动也持续采,只在状态变化时输出

逐像素移动鼠标时,日志出现如下循环(同一坐标上反复出现;字段含义:id 命中元素标识、type 元素类型、fill 当前样式填充、states 当前状态、cursor 对应光标):

zr-move   id:1  type:rect  fill:"oklch(0.82 0.16 80)"  states:""        cursor:pointer
dom-move  id:1  type:rect  fill:null                   states:"emphasis" cursor:pointer
zr-out    id:1  type:rect  fill:null                   states:"emphasis" cursor:default
zr-move   id:null                                    cursor:default
poll      id:null                                    cursor:default
poll      id:1  type:rect  fill:"oklch(0.82 0.16 80)"  states:""        cursor:default
zr-move   id:1  type:rect  fill:"oklch(0.82 0.16 80)"  states:""        cursor:pointer
dom-move  id:1  type:rect  fill:null                   states:"emphasis" cursor:pointer
zr-out    id:1  type:rect  fill:null                   states:"emphasis" cursor:default
...

关键事实:

  • id:1(黄色条)在 states:""(normal)与 states:"emphasis" 之间反复切换;
  • 进入 emphasis 后 fill 变成 null
  • 同一坐标上 poll 时而命中 id:1、时而命中不到;
  • 鼠标没有离开 canvas(dom-move 持续触发),说明这是原地振荡,不是鼠标滑出条形。

根因

命中测试要求元素必须有 fill

zrender 的 Path.prototype.contain[2]

ts
if (rect.contain(x, y)) {
  // ...
  if (this.hasFill()) {
    return pathContain.contain(pathProxy, x, y);
  }
}
return false;

hasFill() 要求 style.fill 非空且非 'none'。条形没有 stroke,只有 fill,一旦 fill 缺失就完全不可命中

emphasis 状态样式丢失 fill(关键:oklch 颜色解析失败)

ECharts 的 setStatesStylesFromModel[3]getItemStyle() 生成 emphasis 状态样式,其字段映射为[4]

ts
ITEM_STYLE_KEY_MAP = [['fill', 'color'], ['stroke', 'borderColor'], ...]

也就是说状态样式里的 fill 只来自配置里的 color。原配置:

ts
emphasis: { focus: 'self', itemStyle: { opacity: 0.05 } }

没有 color,因此 getItemStyle() 产出的 emphasis 样式只有 {opacity: 0.05}

但真正把 fill「干掉」的是 ECharts 的默认高亮补色逻辑 createEmphasisDefaultState[3:1]:状态样式没有 fill 时,它会用 liftColor(元素当前 fill) 补一个提亮色。而本项目的条形 fill 是 oklch(...)(CSS Color 4 的颜色空间)——zrender 的颜色解析器[5]不支持 oklchlift() 解析失败直接返回 undefined

ts
export function lift(color, level) {
  var colorArr = parse(color);
  if (colorArr) {
    // 提亮并返回新颜色
  }
  // parse 失败:没有 return,返回 undefined
}

于是 emphasis 状态样式实际是 {opacity: 0.05, fill: undefined}。zrender 应用状态样式时用 Object.assign 合并覆盖到元素样式上(Displayable._applyStateObj[6] 的非过渡分支),fill: undefined 直接把原来的黄色 fill 覆盖掉——日志里 emphasis 时 fill: null 即由此而来。处于 emphasis 的黄色条因此完全不可命中

推论

若条形 fill 是 zrender 可解析的颜色(如 hex),liftColor 会成功返回提亮色,emphasis 时元素仍有 fill、依然可命中——此时只剩「条淡出 + 其它元素变透明」的视觉症状,不会振荡。振荡依赖「不可解析的颜色」(本例为 oklch)。在线演示中可切换 oklch / hex 颜色验证这一点。

自持振荡循环

  1. mousemove 命中黄色条(normal,有 fill)→ mouseover → 标记 emphasis;
  2. 下一帧应用 emphasis 状态:fill 被覆盖为 undefined → 黄色条不可命中;
  3. 下一次 mousemove(鼠标位置几乎没动)→ findHover 返回空 → 光标变 default → 派发 mouseout
  4. 元素退回 normal,fill 恢复 → 下一次 mousemove 再次命中 → 再次 emphasis……

循环依赖持续到来的 mousemove 维持,因此:

  • 鼠标静止时,循环停在不变化的状态上,看不出闪烁——这是「静止不闪、微动才闪」的直接原因;
  • 鼠标轻微移动时,每 1~2px 就触发一轮循环,光标 pointer/default 反复切换,tooltip 随 hideDelay[7] 的节奏闪烁(默认 100ms)。

其它现象的同一根源

  • “黄色条直接消失”:emphasis 自身 itemStyle.opacity: 0.05
  • “图像其它部分变得非常透明”:focus: 'self' 触发 blurSeries[3:2],其余元素被 blur 到 10% 透明度(createBlurDefaultState[3:3]fromState.opacity * 0.1);
  • “透明条显示手指”:透明段是普通 bar 元素,cursor 默认为 pointer
  • “透明条无 tooltip”:tooltip: { show: false }

修复

src/components/common/GanttChart.vue 中:

ts
series: [
  {
    name: 'offset',
    type: 'bar',
    stack: 'gantt',
    data: offsetData,
    barWidth,
    itemStyle: { color: 'transparent', borderColor: 'transparent' },
    silent: true, // 透明占位段不参与交互:不再显示手指、不触发 tooltip
    tooltip: { show: false },
  },
  {
    name: 'duration',
    type: 'bar',
    stack: 'gantt',
    data: barData,
    barWidth,
    itemStyle: { borderRadius: 4 },
    emphasis: { diabled: true }, // 原先拼错,实际无效
    emphasis: { disabled: true }, // 悬浮不改状态:不丢 fill、不淡出、不 blur 其它元素
  },
],

要点:

  • emphasis: { disabled: true } 从根上消除振荡循环——元素永远保有 fill,命中稳定;tooltip 不受影响;
  • silent: true 让透明段不参与命中,透明部分不再出现手指光标;
  • 删除原先拼错的 emphasis: { diabled: true }
  • 清理全部临时调试代码(DOM/zrender 监听、40ms 采样器),类型检查通过,dev server 已分发。

验证

硬刷新后:

  • 悬浮黄色条:保持原色、其余部分不变、tooltip 稳定;
  • 轻微移动鼠标:光标与 tooltip 不再闪烁;
  • 透明部分:显示默认箭头(不再手指)、无 tooltip。

后续可选方向

若希望“透明占位段也能悬停出本行 tooltip”,可改为按行命中的 trigger: 'axis' + axisPointer: { type: 'shadow' }(组件里曾注释过该方案),并在 formatter 中按 seriesIndex === 1 过滤掉 offset 系列。注意该方案同样不应携带任何会改动状态样式/透明度的 emphasis 配置。


  1. zrender/lib/Handler.jsfindHover / isHover↩︎

  2. zrender/lib/graphic/Path.jscontain / hasFill↩︎

  3. echarts/lib/util/states.jssetStatesStylesFromModelcreateEmphasisDefaultStateblurSeriescreateBlurDefaultState↩︎ ↩︎ ↩︎ ↩︎

  4. echarts/lib/model/mixin/itemStyle.jsITEM_STYLE_KEY_MAP↩︎

  5. zrender/lib/tool/color.jsparse 不支持 oklch;lift 解析失败返回 undefined↩︎

  6. zrender/lib/graphic/Displayable.js_applyStateObjObject.assign 合并覆盖状态样式) ↩︎

  7. echarts/lib/component/tooltip/TooltipView.js_tryShow / hideDelay↩︎