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

D3.js中.on("click")事件仅对每个圆点生效一次的问题求助

问题描述

我是D3.js和JavaScript新手,对join()方法与事件处理存在理解误区。开发了一个基于两级绩效评级("satisfactory"和"exemplary")的薪资调整演示程序:D3图表用圆点代表部门员工,X轴为当前薪资,Y轴为年度加薪额。点击圆点应将员工在两个分组间切换,调整其加薪额并保持总加薪额不变。

问题现象:首次点击每个圆点切换分组时一切正常,图表与数据均按预期更新,但切换后的圆点无法再次点击切换回原分组,未点击过的圆点仍可正常响应点击。


完整代码

JavaScript代码(mwe.js)

// Sort of works, 18 Aug 2022, but each dot can be clicked only once.  


function setSal4() {
    var svg = d3.select( "svg" );                        
    var pxX = +svg.attr( "width" );                       
    var pxY = svg.attr( "height" )/2;  // using two half-height plots, with different y-offsets; each one gets half the square
    var paddingfactor = 0.2;  // shrink plot by some factor from all edges

    var minsatpct = 2.2/100 ;   // hardcode raise limits, in percent
    var maxsatpct = 2.9/100;
    var minexpct = 3.2/100;
    var maxexpct = 4.0/100 ;  // used only to set graph y-axis limit; not used in calculations


    // build maps for the two groups, with
    // names as keys, and 2-element arrays containing current
    // salary and dollar raise as values (raise set to 0 at start)
    // all names must be unique
    // use ">0" test to handle inputs with different numbers of sat and exem people

    // initialize values that should be stable during session, to be constructed during map building
    var minsal=Number.MAX_VALUE;  
    var maxsal=0;
    var totalsal = 0;

    // build maps containing current salaries for each group
    var exemplaries = new Map();
    var satisfactories = new Map();
    exemplaries.set("alice",[+68000,+0]);
    exemplaries.set("bob",[+64000,+0]);
    exemplaries.set("carol",[+72000,+0]);
    satisfactories.set("dan",[+74000,+0]);
    satisfactories.set("ellen",[+66000,+0]);
    satisfactories.set("frank",[+62000,+0]);

    // find minimum, maximum, and total salaries
    for (const person of exemplaries) {
    const sal = person[1][0];
    totalsal += sal;
    minsal = Math.min(minsal, sal);
    maxsal = Math.max(maxsal, sal);
    }
    for (const person of satisfactories) {
    const sal = person[1][0];
    totalsal += sal;
    minsal = Math.min(minsal, sal);
    maxsal = Math.max(maxsal, sal);
    }
        
    var raisepool = totalsal*maxsatpct;   // total amount available for raises      
    console.log("min, max, total, pool = ",minsal, maxsal, totalsal, raisepool);

    // establish raises according to policy
    setRaises("flatpct");
    reportGroups();

    // set up scaling objects to map salaries and raises onto screen area
    var scX  = d3.scaleLinear().domain([minsal,maxsal]).range([paddingfactor*pxX, (1.0-paddingfactor)*pxX] ).nice();
    var scYdol =  d3.scaleLinear().domain([minsatpct*minsal,maxexpct*maxsal]).range([(1.0-paddingfactor)*pxY+pxY, paddingfactor *pxY+pxY] ).nice();

    // make groups to contain data points for satisfactory and exemplary groups
    var g1 = svg.append( "g" );                          
    var g2 = svg.append( "g" );


    redrawData();  // joins data to circles and draws all four groups
        
    detectAction("circle");  // install event handler

    // end of main program; function definitions follow

    // event handler for clicking on a dot
    // click on circle to switch person between Satisfactory and Exemplary, adjust remaining Sat salaries, redraw
    function detectAction( selector ) {
    var svg = d3.selectAll( selector )
        .on( "click", function(event,d) {
        console.log("clicked on ", d[0], "salary ", d[1][0]);
        var key = d[0];
        swapGroups(key);
        setRaises("flatpct");
        redrawData();
        reportGroups();
        }
           )
         
    }

    // join and draw circles corresponding to all people in a single dataset
    function drawData( g, dataset, keyfunc, xaccessor, yaccessor, color) {      
    g.selectAll( "circle" )
        .data(dataset,keyfunc)
        .join('circle')
        .attr( "r", 5 )
        .attr( "cx", xaccessor)
        .attr( "cy",  yaccessor );
    g.selectAll( "circle" ).attr( "fill", color );  
    }

    // call DrawData function for all datasets
    function redrawData() {
    drawData( g1, exemplaries, d => d[0], d => scX(d[1][0]), d => scYdol(d[1][1]), "green" );    
    drawData( g2, satisfactories, d => d[0],  d => scX(d[1][0]), d => scYdol(d[1][1]) , "blue");        
    }    

        
    // given map of people with salaries, and constant percent raise, assign raises to each
    function setFlatPercentRaises(cohort,pct) {
    for  (const person of cohort) {
        person[1][1] = pct*person[1][0];
    }
    }

    function setRaises (type) {  // set raises for everyone, using a specific model set by 'type' argument
    if (type == "minpct") {  // will not use all of raise pool
        setFlatPercentRaises(exemplaries, minexpct);
        setFlatPercentRaises(satisfactories, minsatpct);
    }
    
    else if (type == "flatpct") {  // minimum raises to exem, best flat % available to sat
        setFlatPercentRaises(exemplaries, minexpct);
        [exemtot, sattot] = gettotals(exemplaries, satisfactories);
        satpct = (raisepool-exemtot*minexpct)/sattot;  // find remaining amount for sat; should check to see if there's enough
        setFlatPercentRaises(satisfactories, satpct);
    }
    } 


    function gettotals(map1, map2) {
    var tot1 = 0;
    for (const d of map1) {
        tot1+= d[1][0];
    }
    var tot2=0;
    for (const d of map2) {
        tot2+= d[1][0];
    }
    return [tot1,tot2];
    }
        




    function swapGroups(key) {
    console.log("key is ", key);
    console.log("lengths (exem, sat) = ", exemplaries.size, satisfactories.size);
    if (exemplaries.has(key)) {
        satisfactories.set(key,exemplaries.get(key));  // add entry to other group
        exemplaries.delete(key); // remove from this group
        console.log("lengths (exem, sat) = ", exemplaries.size, satisfactories.size)
    }
    else if (satisfactories.has(key)) {
        exemplaries.set(key,satisfactories.get(key)); 
        satisfactories.delete(key);
        console.log("lengths (exem, sat) = ", exemplaries.size, satisfactories.size);
    }
    }

    function reportGroups() {
    for (const d of exemplaries)  {console.log("E: ", d[0], d[1][0], d[1][1])};
    for (const d of satisfactories)  {console.log("S: ",d[0], d[1][0], d[1][1])};
    }    
} 

HTML代码

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<script src="d3.js"></script>
<script src="mwe.js"></script>                      
</head>

<body onload="setSal4()">                                      
  <svg id="demo1" width="600" height="600"
       style="background: lightgrey" />              
</body>
</html>

解决方案

问题根源

你仅在初始化时调用一次detectAction("circle")绑定点击事件,但每次调用redrawData()时,join()会创建新的circle元素(员工切换分组时,原组的circle被移除,新组添加新circle),这些新元素未绑定点击事件,导致无法响应后续点击。

另外,drawData中单独调用g.selectAll("circle").attr("fill", color)会冗余覆盖所有circle颜色,可优化到链式调用中。

修复步骤

  1. 将事件绑定整合到drawData函数:每次join()完成后,直接为元素绑定点击事件,确保所有circle都有监听。
  2. 优化drawData链式调用:把颜色设置并入join()后续操作,避免额外选择器调用。
  3. 移除全局detectAction调用:事件绑定已在drawData中完成,无需单独调用。

修改后的关键代码

调整drawData函数

function drawData( g, dataset, keyfunc, xaccessor, yaccessor, color) {      
  g.selectAll( "circle" )
    .data(dataset,keyfunc)
    .join('circle')
    .attr( "r", 5 )
    .attr( "cx", xaccessor)
    .attr( "cy",  yaccessor )
    .attr("fill", color)
    .on("click", function(event,d) {
      console.log("clicked on ", d[0], "salary ", d[1][0]);
      var key = d[0];
      swapGroups(key);
      setRaises("flatpct");
      redrawData();
      reportGroups();
    });
}

删除初始化时的事件绑定调用

移除这一行代码:

detectAction("circle");  // install event handler

(可选)删除原detectAction函数

该函数已无作用,可直接删除。

修复原理

每次redrawData()执行时,join()会处理数据与元素的匹配:移除离开组的员工对应的circle,添加新进入组的员工对应的circle。通过在join()后直接绑定点击事件,新创建的circle自动获得监听,保留的circle的事件也不会丢失,从而实现所有circle均可反复响应点击。

内容的提问来源于stack exchange,提问作者George McBane

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.22 07:06:32