2

I'm on d3.js v.7.

I need to build a linear gradient with hard stops (i.e., color 0 from 0 to x, color 1 from x to y, ... color n from z to 1). stop list has an arbitrary length and it is defined by a matrix like the following:

const matrix = [
  [{start: 0, stop:0.5, color: 'blue'}, {start: 0.5, stop:1, color: 'teal'}],
  [{start: 0, stop:0.2, color: 'blue'}, {start: 0.2, stop:1, color: 'teal'}]
];

that should be rendered as follows:

<defs>
    <lineargradient id="grad_0">
        <!--stop 0 -->
        <stop offset="0" stop-color="blue"></stop>
        <stop offset="0.5" stop-color="blue"></stop>
        <!--stop 1 -->
        <stop offset="0.5" stop-color="teal"></stop>
        <stop offset="1" stop-color="teal"></stop>
    </lineargradient>
    <lineargradient id="grad_1">
        <!--stop 0 -->
        <stop offset="0" stop-color="blue"></stop>
        <stop offset="0.2" stop-color="blue"></stop>
        <!--stop 1 -->
        <stop offset="0.2" stop-color="teal"></stop>
        <stop offset="1" stop-color="teal"></stop>
    </lineargradient>
</defs>

NB: look at the stop-color elements: stops follow the matrix nesting

Starting from Mike Bostock's Nested Selections, I tried with this solution:

const defs = d3.select('body').append('defs'); 

const linearGradients = defs
.selectAll('linearGradient')
.data(matrix)
.join('linearGradient')
.attr('id', (d, i) => `grad_${i}`);

linearGradients
  .selectAll('stop')
  .data(d => d) //matrix[j]
  .join((enter) => {
  enter
    .append('stop')
    .attr('offset', (d) => d.start)
    .attr('stop-color', (d) => d.color);

  enter
    .append('stop')
    .attr('offset', (d) => d.stop)
    .attr('stop-color', (d) => d.color);

});

However, stops do not follow the matrix nesting and they are not intercalated. This is what I get with the code above:

<defs>
    <lineargradient id="grad_0">
        <stop offset="0" stop-color="blue"></stop>
        <stop offset="0.5" stop-color="teal"></stop>
        <stop offset="0.5" stop-color="blue"></stop>
        <stop offset="1" stop-color="teal"></stop>
    </lineargradient>
    <lineargradient id="grad_1">
        <stop offset="0" stop-color="blue"></stop>
        <stop offset="0.2" stop-color="teal"></stop>
        <stop offset="0.2" stop-color="blue"></stop>
        <stop offset="1" stop-color="teal"></stop>
    </lineargradient>
</defs>

...and it's wrong!

I have already checked out answers like this one, but they do not work anymore with newer versions of d3.

Here is a working example to play with: https://codepen.io/floatingpurr/pen/RwpvvPq

Is there any idiomatic and working solution?

floatingpurr
  • 7,749
  • 9
  • 46
  • 106
  • 1
    What is the purpose of the span? I hadn't thought a span was a valid child of a tr, but knowing what its purpose is *might* help with an answer. – Andrew Reid Jun 17 '21 at 15:57
  • Hi @AndrewReid, there is no real purpose for such spans. It was just a convenient way to build an example to explain my problem. However, you are right: this sounds pretty awkward. Gonna refactor the example in a more "linear" way. Thanx for pointing this out. – floatingpurr Jun 17 '21 at 16:00
  • Ok @AndrewReid, I have just refactored all the question (and the example). Let me know if it seems more clear to you and if you have any hints. – floatingpurr Jun 17 '21 at 16:35
  • 1
    Looks good - just one last question, I'm assuming you will need to update the stops for each gradient with new data, is that correct? – Andrew Reid Jun 17 '21 at 17:06
  • @AndrewReid yes, correct. This code is part of a bigger picture that should behave idempontently – floatingpurr Jun 17 '21 at 17:17
  • 1
    Will have some time to take a look later today. The easiest and most canonical solution would be to modify the data - D3 is really designed for [one data item to one DOM element](https://stackoverflow.com/a/10820082/7106086), but that isn't always workable, [other options](https://stackoverflow.com/q/50122762/7106086) exist. – Andrew Reid Jun 17 '21 at 18:02
  • @AndrewReid ok thanx. My problem here is appending elements in an alternated fashion. – floatingpurr Jun 17 '21 at 18:33
  • 1
    I realize - and I'll have something later tonight, the first link just a supporting quote mostly from the creator of D3, the second an example of appending siblings in an alternating fashion (where they don't need to be updated though). – Andrew Reid Jun 17 '21 at 18:38
  • Ok great. I’ll check your pointers out. My comment was just to say that I can’t figure out a data modification to get what I need with the general update pattern. I’m not an expert, so I might be wrong. – floatingpurr Jun 17 '21 at 18:45
  • Just read your links but, as you mentioned, they don’t address this problem (ie. Mapping data to a pattern of sibling elements) – floatingpurr Jun 17 '21 at 22:22

1 Answers1

1

As noted in the links: D3 generally expects one item in a data array to one element in the DOM. This is part of the foundation of its data binding method. This approach still allows:

  1. multiple children to be appended to parents where the parent datum contains an array (nested data), or
  2. multiple different types of children (eg: label, node) that have a one to one relationship with their parent.

We can't do one because our nested data doesn't contain an array of all the elements we wish to enter.

We also can't do two because we don't have a one to one relationship between parent and child - we have nested data after all.

Sometimes you can cheat and reuse the enter selection to double elements or use classes to enter elements many times, but these approaches are predicated on indifference to the order of the elements in the DOM.

But, when order matters, and we need multiple elements in the DOM for each item in the data array, we need a different approach. As noted in the comments, an equivalent problem is update/exiting/entering pairs of <dd> and <dt> elements since strictly speaking a described list doesn't allow any parent that could group these pairs for ordering and organization. Same as <stop> elements in a <linearGradient>, as is your case.

There are four classes of solution that come to mind:

  1. Modify the data so one element corresponds to one item in the data array
  2. Abuse d3-selection
  3. Get creative in sorting
  4. Use selection.html() to manually add the child elements

Modify the data so one element corresponds to one item in the data array.

The first is probably the most straightforward in that it would be the most canonical and require little consideration about how to modify the D3 code. Below I use a function to take your input data and create one data array item for each stop - essentially cracking each existing item into two. To do so we should probably use common properties, so instead of start and end properties we'll just have offset values in all:

const matrix = [
  [{start: 0, stop:0.5, color: 'blue'}, {start: 0.5, stop:1, color: 'teal'}],
  [{start: 0, stop:0.2, color: 'blue'}, {start: 0.2, stop:1, color: 'teal'}]
];

// produce new data array:
function processData(data) {
  let newData = data.map(function(gradient) {  
    let stops = [];
    gradient.forEach(function(stop) {
      stops.push({offset: stop.start, color: stop.color})
      stops.push({offset: stop.stop, color: stop.color})
    })
    return stops;
  })
  return newData;
}

console.log(processData(matrix));

Here it is in work below with some random data that is constantly updated:

// produce new data array:
function processData(data) {
  let newData = data.map(function(gradient) {  
    let stops = [];
    gradient.forEach(function(stop) {
      stops.push({offset: stop.start, color: stop.color})
      stops.push({offset: stop.stop, color: stop.color})
    })
    return stops;
  })
  return newData;
}


//Basic set up:
var defs = d3.select("defs");
var svg = d3.select("svg");

update();
setInterval(update, 1000);
// Update with new data:
function update() {
  var data = randomGradients();
  data = processData(data);

  // update gradients:
  var grads = defs.selectAll("linearGradient")
     .data(data)
     .join("linearGradient")
     .attr("id",(d,i)=>"grad_"+i);

  var pairs = grads.selectAll("stop")
      .data(d=>d)  
      .join("stop")
      .transition()
      .attr("stop-color",d=>d.color)
      .attr("offset",d=>d.offset);

   // update rectangles:
   svg.selectAll("rect")
     .data(grads.data())
     .join("rect")
     .attr("y", (d,i)=>i*60+5)
     .attr("x", 10)
     .attr("width", 360)
     .attr("height", 50)
     .attr("fill",(d,i)=>"url(#grad_"+i+")");

}

// Random Data Generator Functions:
function createStop(a,b,c) {
  return {start: a, stop: b, color: c};
}

function randomData(n) {
  let colorSchemes = [
    d3.schemeBlues,
    d3.schemeReds,
    d3.schemePurples,
    d3.schemeGreens,
    d3.schemeGreys,
    d3.schemeOranges
  ];
  let points = d3.range(n-1).map(function(i) {
    return Math.random()  
  }).sort()
  points.push(1);
  let stops = [];
  let start = 0;
  let end = 1;
  let r = Math.floor(Math.random()*(colorSchemes.length));
  let colors = colorSchemes[r][n];
  for (let i = 0; i < n; i++) {
    stops.push(createStop(start,points[i],colors[i]));
    start = points[i];
  }
  return stops;
}

function randomGradients() {
  let gradients = [];
  let n = Math.floor(Math.random()*5)+1
  let m = Math.floor(Math.random()*3)+3;
  for(var i = 0; i < n; i++) {
     gradients.push(randomData(m));
  }
  return gradients;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.0.0/d3.min.js"></script>

<svg width="500" height="400"> 
  <defs>

  </defs>
  <rect width="300" height="50" fill="url(#grad_0)">
  </rect>
</svg>

This works when we have the same type of element to append for each item in the data array (stop) elements, but we'd need to be more creative if we had different types of items as in the dd/dt case. append() does allow variable tags (fourth approach listed in linked answer) to be appended, but things will get a bit more complicated when updating.

Abuse d3-selection

I add this for completeness and because it is an interesting case, you should in most circumstances change the data structure as in the option above, though extending D3 can at times be the clearly preferred option

You could do this in a number of ways. I've decided to create a new d3-selection method, but this isn't necessary. This approach requires a bit more care and may have some possible unintentional consequences (I think I've done well in avoiding most) - but preserves your data structure. I've created a method selection.joinPair() which takes a single parameter: the type of element we'd like to have two of for each item in the data array. It returns a selection of all the elements, but also has two methods: first() and second() which return the first or second set of elements respectively. It assumes the parent's datum is the data array for the child elements as is our case here. (I may revise the snippet below later to make it a bit to be a bit more flexible, but it is provided more as a demonstration rather than as a final product or potential d3 module.

// New d3 method to join pairs:
d3.selection.prototype.joinPairs = function(type) {
  let parents = this; // selection this is called on.
  // select every even, odd element in two selections, set data
  let a = parents.selectAll(type+":nth-child(2n+1)")
     .data((d,i)=>parents.data()[i]);
  let b = parents.selectAll(type+":nth-child(2n+2)")
      .data((d,i)=>parents.data()[i]);

  // remove unneeded children:
  a.exit().remove();
  b.exit().remove();

  // enter, as we enter in pairs, we can use selection.clone()
  // which enters a new element immediately after the cloned node in the DOM.
  enterA = a.enter().append(type);
  enterB = enterA.clone();
  
  // return the selection of all elements, but allow access to odds/evens separately:
  let sel = parents.selectAll(type);
  sel.first=()=>a.merge(enterA);
  sel.second=()=>b.merge(enterB);
  return sel;
}

And here we have at work:

// New d3 method to join pairs:
d3.selection.prototype.joinPairs = function(type) {
  let parents = this;
  let a = parents.selectAll(type+":nth-child(2n+1)")
     .data((d,i)=>parents.data()[i]);
  let b = parents.selectAll(type+":nth-child(2n+2)")
      .data((d,i)=>parents.data()[i]);

  a.exit().remove();
  b.exit().remove();

  enterA = a.enter().append(type);
  enterB = enterA.clone();
  
  let sel = parents.selectAll(type);
  sel.first=()=>a.merge(enterA);
  sel.second=()=>b.merge(enterB);
  return sel;
}

//Basic set up:
var defs = d3.select("defs");
var svg = d3.select("svg");

update();
setInterval(update, 1000);
// Update with new data:
function update() {
  // update gradients:
  var grads = defs.selectAll("linearGradient")
     .data(randomGradients())
     .join("linearGradient")
     .attr("id",(d,i)=>"grad_"+i);

  var pairs = grads.joinPairs("stop")
         .attr("stop-color",d=>d.color)

  // set first element in pair's offset:
  pairs.first().transition()
        .attr("offset",d=>d.start)
       
  // set second element in pair's offset:
  pairs.second().transition()
       .attr("offset",d=>d.stop)
     
   // update rectangles:
   svg.selectAll("rect")
     .data(grads.data())
     .join("rect")
     .attr("y", (d,i)=>i*60+5)
     .attr("x", 10)
     .attr("width", 360)
     .attr("height", 50)
     .attr("fill",(d,i)=>"url(#grad_"+i+")");

}

// Random Data Generator Functions:
function createStop(a,b,c) {
  return {start: a, stop: b, color: c};
}

function randomData(n) {
  let colorSchemes = [
    d3.schemeBlues,
    d3.schemeReds,
    d3.schemePurples,
    d3.schemeGreens,
    d3.schemeGreys,
    d3.schemeOranges
  ];
  let points = d3.range(n-1).map(function(i) {
    return Math.random()  
  }).sort()
  points.push(1);
  let stops = [];
  let start = 0;
  let end = 1;
  let r = Math.floor(Math.random()*(colorSchemes.length));
  let colors = colorSchemes[r][n];
  for (let i = 0; i < n; i++) {
    stops.push(createStop(start,points[i],colors[i]));
    start = points[i];
  }
  return stops;
}

function randomGradients() {
  let gradients = [];
  let n = Math.floor(Math.random()*5)+1
  let m = Math.floor(Math.random()*3)+3;
  for(var i = 0; i < n; i++) {
     gradients.push(randomData(m));
  }
  return gradients;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.0.0/d3.min.js"></script>

<svg width="500" height="400"> 
  <defs>

  </defs>
  <rect width="300" height="50" fill="url(#grad_0)">
  </rect>
</svg>

This way is way less portable though, unless further developed.

Get creative in sorting

This way fits within standard D3 methods, but isn't the most typical approach. It might get messy in certain circumstances, but in this use case is rather clean - though with many items might be slower than others.

We'll enter/update/exit data based on classes, and sort data based on a property of the element. However, it is a bit challenging in that we can't use the datum to store an index as the datum is shared between elements. Luckily, as we append the "start" stop first and the "stop" stop second sorting by d.start will work as long as we append all the "starts" before all the "stops":

//Basic set up:
var defs = d3.select("defs");
var svg = d3.select("svg");

update();
setInterval(update, 1000);
// Update with new data:
function update() {
  // update gradients, same as before:
  var grads = defs.selectAll("linearGradient")
     .data(randomGradients())
     .join("linearGradient")
     .attr("id",(d,i)=>"grad_"+i);

  // starts:
  grads.selectAll(".start")
    .data(d=>d)
    .join("stop")
    .transition()
    .attr("stop-color",d=>d.color)
    .attr("offset",d=>d.start)
    .attr("class","start");
  // ends:
  grads.selectAll(".end")
    .data(d=>d)
    .join("stop")
    .transition()
    .attr("stop-color",d=>d.color)
    .attr("offset",d=>d.stop)
    .attr("class","end")
    
  // sort based on start value  (where they are the same, first appended item is first):
  grads.each(function() {
    d3.select(this).selectAll("stop")
      .sort(function(a,b) { return a.start-b.start })
  })
  
   // update rectangles:
   svg.selectAll("rect")
     .data(grads.data())
     .join("rect")
     .attr("y", (d,i)=>i*60+5)
     .attr("x", 10)
     .attr("width", 360)
     .attr("height", 50)
     .attr("fill",(d,i)=>"url(#grad_"+i+")");

}

// Random Data Generator Functions:
function createStop(a,b,c) {
  return {start: a, stop: b, color: c};
}

function randomData(n) {
  let colorSchemes = [
    d3.schemeBlues,
    d3.schemeReds,
    d3.schemePurples,
    d3.schemeGreens,
    d3.schemeGreys,
    d3.schemeOranges
  ];
  let points = d3.range(n-1).map(function(i) {
    return Math.random()  
  }).sort()
  points.push(1);
  let stops = [];
  let start = 0;
  let end = 1;
  let r = Math.floor(Math.random()*(colorSchemes.length));
  let colors = colorSchemes[r][n];
  for (let i = 0; i < n; i++) {
    stops.push(createStop(start,points[i],colors[i]));
    start = points[i];
  }
  return stops;
}

function randomGradients() {
  let gradients = [];
  let n = Math.floor(Math.random()*5)+1
  let m = Math.floor(Math.random()*3)+3;
  for(var i = 0; i < n; i++) {
     gradients.push(randomData(m));
  }
  return gradients;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.0.0/d3.min.js"></script>

<svg width="500" height="400"> 
  <defs>

  </defs>
  <rect width="300" height="50" fill="url(#grad_0)">
  </rect>
</svg>

I'm not going to go into depth on this option, it should be relatively straightforward as is.

Use selection.html() to manually add the child elements

This is really outside the D3 idiom, but at times such an approach may be justifiable. We'll join the linearGradients as normal, then use selection.html to create the child stops. I include it only for completeness, it is shown below:

This is even transitionable! (barely)

//Basic set up:
var defs = d3.select("defs");
var svg = d3.select("svg");

update();
setInterval(update,1000);
// Update with new data:
function update() {
  // update gradients:
  var grads = defs.selectAll("linearGradient")
     .data(randomGradients())
     .join("linearGradient")
     .attr("id",(d,i)=>"grad_"+i);

   grads.transition().attrTween("anything", function(d) {
      var el = this;
      var end = "";
      d.forEach(function(s) {
        end += '<stop offset="'+s.start+'" stop-color="'+d3.color(s.color).formatRgb()+'"></stop>';
        end += '<stop offset="'+s.stop+'" stop-color="'+d3.color(s.color).formatRgb()+'"></stop>';
      })
      var start = d3.select(this).html() || end;
      function interpolator(t) {
        var i = d3.interpolate(start,end)(t);
        d3.select(el).html(i);
        return 1;
      }
      return interpolator
    });
   
   
   // update rectangles:
   svg.selectAll("rect")
     .data(grads.data())
     .join("rect")
     .attr("y", (d,i)=>i*60+5)
     .attr("x", 10)
     .attr("width", 360)
     .attr("height", 50)
     .attr("fill",(d,i)=>"url(#grad_"+i+")");

}


// Random Data Generator Functions:
function createStop(a,b,c) {
  return {start: a, stop: b, color: c};
}

function randomData(n) {
  let colorSchemes = [
    d3.schemeBlues,
    d3.schemeReds,
    d3.schemePurples,
    d3.schemeGreens,
    d3.schemeGreys,
    d3.schemeOranges
  ];
  let points = d3.range(n-1).map(function(i) {
    return Math.random()  
  }).sort()
  points.push(1);
  let stops = [];
  let start = 0;
  let end = 1;
  let r = Math.floor(Math.random()*(colorSchemes.length));
  let colors = colorSchemes[r][n];
  for (let i = 0; i < n; i++) {
    stops.push(createStop(start,points[i],colors[i]));
    start = points[i];
  }
  return stops;
}

function randomGradients() {
  let gradients = [];
  let n = Math.floor(Math.random()*5)+1
  let m = Math.floor(Math.random()*3)+3;
  for(var i = 0; i < n; i++) {
     gradients.push(randomData(m));
  }
  return gradients;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.0.0/d3.min.js"></script>

<svg width="500" height="400"> 
  <defs>

  </defs>
  <rect width="300" height="50" fill="url(#grad_0)">
  </rect>
</svg>

Options not included

I haven't included any options where you can't easily transition from old data to new data, so anything that uses selection.remove() to wipe the slate, or part of the slate clean in order to build from scratch is not included here.

marc_s
  • 732,580
  • 175
  • 1,330
  • 1,459
Andrew Reid
  • 37,021
  • 7
  • 64
  • 83
  • This answer is amazing! Thank you very much for the clarity and the completeness of your explanation. Great job. This'll help many understand how to play with d3. Thanks again! – floatingpurr Jun 18 '21 at 06:16