Appearance
堆叠条形图模拟甘特图
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 },
},
],问题现象
在黄色条上悬浮时:
- 黄色条本身直接消失;
- tooltip 正常出现;
- 图像其它部分变得非常透明;
- 轻微移动鼠标,指针样式在箭头与手指之间反复切换,tooltip 闪烁;
- 同一行的透明条上显示手指(不正确),但没有 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]不支持 oklch,lift() 解析失败直接返回 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 颜色验证这一点。
自持振荡循环
mousemove命中黄色条(normal,有 fill)→mouseover→ 标记 emphasis;- 下一帧应用 emphasis 状态:fill 被覆盖为
undefined→ 黄色条不可命中; - 下一次
mousemove(鼠标位置几乎没动)→findHover返回空 → 光标变 default → 派发mouseout; - 元素退回 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 配置。
zrender/lib/Handler.js(findHover/isHover) ↩︎zrender/lib/graphic/Path.js(contain/hasFill) ↩︎echarts/lib/util/states.js(setStatesStylesFromModel、createEmphasisDefaultState、blurSeries、createBlurDefaultState) ↩︎ ↩︎ ↩︎ ↩︎echarts/lib/model/mixin/itemStyle.js(ITEM_STYLE_KEY_MAP) ↩︎zrender/lib/tool/color.js(parse不支持 oklch;lift解析失败返回undefined) ↩︎zrender/lib/graphic/Displayable.js(_applyStateObj用Object.assign合并覆盖状态样式) ↩︎echarts/lib/component/tooltip/TooltipView.js(_tryShow/hideDelay) ↩︎