Reputation: 41
I'm new to both d3 and javascript, and am clearly suffering from misconceptions around join() and event handling.
The application is a demonstration of a salary increase program based on a two-level performance rating category, "satisfactory" and "exemplary". A d3 plot shows a dot for each person in the department, with x as current salary and y as annual raise. Clicking on a dot should move the person from one category to the other, changing his or her raise and adjusting the raises of the other people in the "satisfactory" group so the sum of all raises remains the same. My sample code is long because of the mechanics of computing the new raises, but the d3 stuff is relatively compact.
My problem: all works fine the first time I change the category for each person. Clicking on any dot moves it to the other category and the raises adjust as expected. When I inspect the now-modified dot, it has the properties I expect. The console log shows that the number of people in each category has changed as expected, and the graph updates as expected. But I can no longer click on that dot and move it back where it came from. Why? Why does my event handler no longer recognize the click on the new dot? It's still running, because it still works on the dots I haven't clicked yet.
Advice, pointers, constructive insults all welcome.
// 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])};
}
}
<!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>
Upvotes: 2
Views: 70
Reputation: 41
I now think I understand. The event handler was installed on all the original elements. When a click occurred, and one person was moved from one group to the other, the new element (dot) was appended, but the handler was not then attached to it. Making .on("click") apply to the enter() selection following .join, rather than applying just once to the original elements with detectAction() as I had done, ensures that the handler is installed on new elements as they appear, so they behave like the original ones.
I now have
function drawData( g, dataset, keyfunc, xaccessor, yaccessor, color) {
g.selectAll( "circle" )
.data(dataset,keyfunc)
// .join('circle')
.join(function(enter){
return enter.append('circle')
.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();
})})
.attr( "r", 5 )
.attr( "cx", xaccessor)
.attr( "cy", yaccessor )
.attr( "fill", color );
}
and I don't call detectAction() at all.
Apologies for the gray-screen-only sample code. I presume that happened because I have the d3.js library installed in the working directory, and I had simply pasted my code into the question. Of course it did work here, but unintentionally wasted peoples' time. I will try jfiddle next time.
Upvotes: 2