You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

ChartJS禁用nearest模式下,如何高效获取鼠标悬停线段信息?

问题:Chart.js折线图线段悬停性能优化

我需要在鼠标悬停折线图的**线段(连接两点的线条)**而非数据点时,获取该线段的相关信息。Chart.js原生仅支持数据点的悬停功能,没有线段悬停的内置方法。由于图表用于展示地理位置,空白区域有重要意义,因此不能使用nearest交互模式。

我自己实现了一套自定义方案,但鼠标移动时会触发大量计算,导致性能急剧下降,现寻求更优实现方式。


当前实现代码

// CONFIG
interaction: {
    mode: 'dataset', // 'nearest', 'point', 'index', 'x', 'y', 'dataset'
    // intersect: false;
    // axis: 'xy'
    // includeInvisible: false;
},
events: ['mousemove', 'click', 'mouseleave'],

onHover: (event: any, activeElements: any, chart: any) => {
    this.mousePosition = { x: event.native.clientX, y: event.native.clientY };
    const canvasPosition = getRelativePosition(event, chart);

    const dataX = chart.scales.x.getValueForPixel(canvasPosition.x);
    const dataY = chart.scales.y.getValueForPixel(canvasPosition.y);
    let lineTouchingMouseOrUndefined: any;
    let hoverData: any;
    lineTouchingMouseOrUndefined = this.getLineIfTouchingMouse(chart, event.x, event.y);

    hoverData = { y: this.yLabelsMap[dataY], x: new Date(dataX), convoy: lineTouchingMouseOrUndefined?.convoy };

    this.hoverEmitter.emit(hoverData)

    if (lineTouchingMouseOrUndefined.changedPoints) {
        this.hadUpdates = true;
    }

    if (this.hadUpdates) {
        clearTimeout(this.updateTimeout);
        this.updateTimeout = setTimeout(() => {
            chart.update();
            this.hadUpdates = false;
        }, this.updateThreshold)
    }

}

//HELPER FUNCTIONS

getLineIfTouchingMouse(chart: any, mouseX: any, mouseY: any) {
    let selectedLine: any;
    let convoyIndex;
    let indexPoint;
    let indexNextPoint;

    let removedSelectionFromOldPoint;
    let removedSelectionFromOldNextPoint;

    for (let i = 0; i < chart.data.datasets.length - 1; ++i) {
        let convoyPath = chart.data.datasets[i].data;

        for (let j = 1; j < convoyPath.length - 1; ++j) {
            let point = convoyPath[j - 1];
            let nextPoint = convoyPath[j];
            let convoy = convoyPath[j - 1].allData;


            if (this.previousSelectedPoint?.id == point.id) {
                removedSelectionFromOldPoint = true
            }
            if (this.previousSelectedNextPoint?.id == nextPoint.id) {
                removedSelectionFromOldNextPoint = true;
            }
            point.selected = false;
            nextPoint.selected = false;

            const closestY1 = this.extractKmFromY(this.getClosestYPoint(this.extractKmFromY(point.y), point.segmentId))
            const closestY2 = this.extractKmFromY(this.getClosestYPoint(this.extractKmFromY(nextPoint.y), nextPoint.segmentId))
            const y1labelIndex = this.yLabelsMap.findIndex(f => f.value == closestY1)
            const y2labelIndex = this.yLabelsMap.findIndex(f => f.value == closestY2)

            let line = {
                x1: chart.scales.x.getPixelForValue(point.x),
                x2: chart.scales.x.getPixelForValue(nextPoint.x),
                y1: chart.scales.y.getPixelForValue(y1labelIndex),
                y2: chart.scales.y.getPixelForValue(y2labelIndex),
                point,
                nextPoint
            }

            if (this.checkLineContainsMousePoint(mouseX, mouseY, line)) {
                convoyIndex = i
                indexPoint = j - 1;
                indexNextPoint = j;
                selectedLine = { line, convoy };
            }
        }
    }
    if (convoyIndex && indexPoint && indexNextPoint) { // when there was a hovered line
        if (this.previousSelectedPoint?.id == chart.data.datasets[convoyIndex].data[indexPoint].id) {
            removedSelectionFromOldPoint = false;
        }
        if (this.previousSelectedNextPoint?.id == chart.data.datasets[convoyIndex].data[indexNextPoint].id) {
            removedSelectionFromOldNextPoint = false;
        }

        if (removedSelectionFromOldPoint || removedSelectionFromOldNextPoint) {
            selectedLine.changedPoints = true
        } else {
            selectedLine.changedPoints = false
        }
        chart.data.datasets[convoyIndex].data[indexPoint].selected = true;
        chart.data.datasets[convoyIndex].data[indexNextPoint].selected = true;

        this.previousSelectedPoint = chart.data.datasets[convoyIndex].data[indexPoint]
        this.previousSelectedNextPoint = chart.data.datasets[convoyIndex].data[indexNextPoint]
    } else {
        selectedLine = {
            changedPoints: (this.previousSelectedNextPoint || this.previousSelectedPoint) ? true : false
        }
        this.previousSelectedNextPoint = null;
        this.previousSelectedPoint = null;
    }
    return selectedLine;
}

checkLineContainsMousePoint(mouseX: any, mouseY: any, line: any) {
    if (mouseX < line.x1 || mouseX > line.x2) {
        return false;
    }
    var linepoint = this.linepointNearestMouse(line, mouseX, mouseY);
    var dx = mouseX - linepoint.x;
    var dy = mouseY - linepoint.y;
    var distance = Math.abs(Math.sqrt(dx * dx + dy * dy));
    if (distance < 5) {
        return true;
    } else {
        return false;
    }
}

linepointNearestMouse(line: any, x: any, y: any) {
    var dx = line.x2 - line.x1;
    var dy = line.y2 - line.y1;
    var t = ((x - line.x1) * dx + (y - line.y1) * dy) / (dx * dx + dy * dy);
    var lineX = this.lerp(line.x1, line.x2, t);
    var lineY = this.lerp(line.y1, line.y2, t);
    return ({ x: lineX, y: lineY });
}

private lerp = function (a: any, b: any, x: any) { return (a + x * (b - a)); };

优化方案

1. 减少不必要的循环与计算

  • 提前终止循环:找到匹配的线段后立即跳出嵌套循环,无需遍历所有线段。可在checkLineContainsMousePoint返回true后,直接return结果,避免后续无意义的遍历。
  • 缓存像素坐标:在图表初始化或数据更新时,提前计算所有数据点的像素坐标(x、y)并存储在点对象中,避免每次鼠标移动时重复调用getPixelForValue。

2. 优化碰撞检测逻辑

  • 简化距离计算:去掉开平方运算(Math.sqrt),直接比较距离的平方与阈值的平方(5*5=25),平方运算比开平方快很多,且不影响大小判断:
    // 替换原距离计算部分
    const dxSquared = (mouseX - linepoint.x) ** 2;
    const dySquared = (mouseY - linepoint.y) ** 2;
    return dxSquared + dySquared < 25;
    
  • 快速过滤无关线段:除了X范围判断,增加Y范围过滤——如果鼠标Y坐标不在线段Y的最小/最大值范围内,直接跳过该线段,减少后续计算。

3. 优化数据查找效率

  • 缓存Y标签索引映射:将yLabelsMap转换为键值对对象,替换findIndex调用,对象查找时间复杂度为O(1),远快于数组遍历的O(n):
    // 初始化时创建映射
    this.yLabelIndexMap = this.yLabelsMap.reduce((map, item, index) => {
        map[item.value] = index;
        return map;
    }, {});
    // 使用时直接取值
    const y1labelIndex = this.yLabelIndexMap[closestY1];
    const y2labelIndex = this.yLabelIndexMap[closestY2];
    

4. 减少图表更新与事件开销

  • 精准控制图表更新:仅当选中的线段发生变化时才标记需要更新,避免无意义的chart.update()调用。
  • 节流mousemove事件:使用节流函数限制mousemove事件的触发间隔(比如每50ms处理一次),避免短时间内触发过多计算。
  • 防抖事件发射:将hoverEmitter.emit与防抖逻辑结合,减少事件发射频率,降低不必要的组件通信开销。

5. 预计算线段数据

  • 利用beforeRender钩子:在图表渲染前提前计算所有线段的像素坐标、Y索引等数据,存储在数据集的自定义属性中,避免在onHover中重复计算。

内容的提问来源于stack exchange,提问作者Matheus Kieling

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.01 12:57:02