Search code examples
d3.jssvgposition

D3 triangle positioning to an exact point


Good Image Bad Image

I am trying to create a D3 graph which looks like the Illustrator created design (Good Image 1), but the closest in terms of positioning I have been able to get is the second (Bad Image 2).

I'm really new to D3 and creating SVGs in general, so I may be going about this all wrong. The code below is what I've been able to figure out / find online. It looks like I can't directly adjust positioning of the elements themselves using css positioning? I tried adding classes via the html and also in JQuery with $(.myClass).css..., but everything I do has exactly zero effect. The only thing that seems to work is transform, but it's ugly, as can be seen in the second pic.

var margin = { left:10, right:10, top:10, bottom:10 };

var width = 400 - margin.left - margin.right,
    height = 450 - margin.top - margin.bottom;

var g = d3.select("#pyramid-chart-area")
    .append("svg")
        .attr("width", width + margin.left + margin.right)
        .attr("height", height + margin.top + margin.bottom)
    .append("g")
        .attr("transform", "translate(" + margin.left 
            + ", " + margin.top + ")");

d3.json("../data/pyramid_hp.json").then(function(data){
    data.forEach(function(d){
        d.hp = +d.hp;
    });

    var x = d3.scaleBand()
        .domain(data.map(function(d){ return d.hp; }))
        .range([0, width])
        .paddingInner(0.3)
        .paddingOuter(0.3);

    var y = d3.scaleLinear()
        .domain([0, d3.max(data, function(d){
            return d.hp;
        })])
        .range([height, 0]);

    var xAxisCall = d3.axisBottom(x);
    g.append("g")
        .attr("class", "x axis")
        .attr("transform", "translate(0, " + height + ")")
        .call(xAxisCall)
        .selectAll("text")
            .attr("y", "10")
            .attr("x", "-5")
            .attr("text-anchor", "end")
            .attr("transform", "rotate(-40)");

    var yAxisCall = d3.axisLeft(y)
        .ticks(3)
        .tickFormat(function(d){
            return d;
        });


    g.append("g")
        .attr("class", "y-axis")
        .call(yAxisCall);


    var arc = d3.symbol().type(d3.symbolTriangle)
                .size(function(d){ return scale(d.hp); });

    var scale = d3.scaleLinear()
        .domain([0, 5])
        .range([0, width]);

    var colors = d3.scaleLinear()
    .domain(d3.extent(data, function(d) {return d.hp}))
    .range([
        '#ffffff',        
        '#303030'        
    ]);

    var group = g.append('g')
                .attr('transform','translate('+ 192 +','+ 320 +')')
                .attr('class', 'triangle-container');


    var line = group.selectAll('path')
            .data(data)
            .enter()
            .append('path')
            .attr('d', arc)
            // .attr('fill',function(d){ return colorscale(d.hp); })
            .attr('fill', d => colors(d.hp))
            .attr('stroke','#000')
            .attr('stroke-width', 1)
            .attr('class', 'triangle')
            .attr('transform',function(d,i){ return "translate("+ (i * 20) +","+(i * 10)+")"; });

Solution

  • You can position the symbols, but its tricky - symbol size represents area and as rioV8 notes symbols are positioned by their center. But if you can figure out the properties of the triangle we can place it relatively easily.

    In the case of a equilateral triangle, you'll want to know the length of a given side as well as the height of that centroid (which is triangle height/3). So these functions will likely be useful:

    // get length of a side/width of upright equilateral triangle from area:
    function getWidth(a) {
        return Math.sqrt(4 * a / Math.sqrt(3));
    }
    // get height of the triangle from length of a side
    function getHeight(l) {
        return Math.sqrt(3)*l/2;
    }
    

    Using the height of the centroid we can position the circle where we want with something like:

    y = SVGheight - SymbolHeight/3 - marginBottom;
    

    No need for scaling here.

    The x values of each symbol do need some scaling to arrange them to your liking. Below I use a linear scale with a range of [width/10,0] arbitrarily, the denominator will change the horizontal skew in this case, there are probably better ways to fine tune this.

    With this you can achieve the desired result:

    enter image description here

    For simplicity's sake, below I'm using data (since you don't show any) that represents pixel area - scaling must be factored into the height and width calculations if scaling areas. I've also included circles on the top of each triangle for possible label use, since we know the dimensions of the triangle this is trivial now

    var margin = { left:10, right:10, top:10, bottom:10 };
    
    var width = 400 - margin.left - margin.right,
        height = 300 - margin.top - margin.bottom;
    
    var g = d3.select("body")
      .append("svg")
         .attr("width", width + margin.left + margin.right)
         .attr("height", height + margin.top + margin.bottom)
      .append("g")
         .attr("transform", "translate(" + margin.left + ", " + margin.top + ")")
    
    
    var data = [
    	{a: 40000},
    	{a: 30000},
    	{a: 20000},
    	{a: 10000}
    ];
    
    function getWidth(a) {
    	return Math.sqrt(4 * a / Math.sqrt(3));
    }
    function getHeight(l) {
    	return Math.sqrt(3)*l/2;
    }
    
    data.forEach(function(d) {
    	d.w = getWidth(d.a);
    	d.h = getHeight(d.w);
    })
    
    var x = d3.scaleLinear()
      .domain(d3.extent(data, function(d){ return d.w; }) )
      .range([width/10,0]);
    
    
    var arc = d3.symbol().type(d3.symbolTriangle)
       .size(function(d){ return d.a; });
    
    var colors = d3.scaleLinear()
     .domain(d3.extent(data, function(d) {return d.a}))
     .range(['#ffffff','#303030']);
    
    var group = g.append('g')
     .attr('transform','translate('+ width/2 +',0)') 
     .attr('class', 'triangle-container');
    
    var line = group.selectAll('path')
      .data(data)
      .enter()
      .append('path')
      .attr('d', arc)
      .attr('fill', d => colors(d.a))
      .attr('class', 'triangle')
      .attr('transform',function(d,i){ return "translate("+ x(d.w) +","+ (height - d.h/3 - margin.bottom )  +")"; });
    
    var circles =  group.selectAll("circle")
      .data(data)
      .enter()
      .append("circle")
      .attr("cx", function(d) { return x(d.w); })
      .attr("cy", function(d) { return height - d.h - margin.bottom; })
      .attr("r", 3);
     <script src='https://d3js.org/d3.v5.min.js' type='text/javascript'></script>

    axes could present a bit of a challenge