javascriptd3.jsmathml

How can I use MathML tags on a d3 axis?


My d3 axis labels are using natural numbers, and I want them to be in a prettier form, and so I am trying to use MathML via MathJax.

When I try to use MathML tags as an argument to tickFormat, it treats the tags as text, and it doesn't actually display it in MathML form.

In my JavaScript, I can get the desired fraction form to show up correctly in a paragraph...

d3.select('.main').append('p').html('<math><mrow><mfrac><mn>$</mn> <mn>day</mn></mfrac></mrow></math>');

... but when I try to apply the same text to my axis, like so...

graph.selectAll('#axisY')
     .data([null])
     .join('g')
     .attr('id', 'axisY')
     .call(d3.axisLeft(y)
             .tickFormat((d) => '<math><mrow><mfrac><mn>$</mn> <mn>day</mn></mfrac></mrow></math>')
     )
     .attr('transform', `translate(${margin.left}, 0)`);

... It spells out all those tags like text, instead of actually displaying the fraction.

So, how can I get tickFormat to parse the tags instead of treating them just as text? ... or is there an easier way to have pretty fractions on d3 axes?


Solution

  • tickFormat expects that the axis tick labels are text elements whereas you want to use html instead (per your working example).

    Consider a few things to get this working:

    1. Use of tickValues in order to control the number of values shown on the axis - if you leave this automatic the fractions can overlap as the rendering of the fraction is simply larger than the decimal from a screen real estate point of view.

    2. Use of a foreignObject for each tick on the axis instead of a text element. Once you use a foreignObject you can add html and therefore can use

    3. Conversion of a decimal to smallest fraction - there is an implementation suggested here.

    Working example below - but note the hard-coding of sizes and placement adjustments which you will need to adjust for your circumstances.

    // prep
    const a1 = [0, 0.33, 0.66, 1];
    const a2 = [0, 0.25, 0.5, 0.75, 1];
    const width = 500;
    const height = 180;
    const x = d3.scaleLinear().range([0, 200]).domain([0, 1])
    const y = d3.scaleLinear().range([150, 10]).domain([0, 1])
    const svg = d3.select("body").append("svg").attr("width", width).attr("height", height);
    
    // set up axes - note setting label as ""
    const axis1 = d3.axisLeft(y).tickValues(a1).tickFormat(d => "");
    const axis2 = d3.axisBottom(x).tickValues(a2).tickFormat(d => "");
    
    // render axis and mathml conversion for fraction label
    const gAxis1 = svg
      .append("g")
      .attr("id", "axis1")
      .attr("transform", "translate(80, 10)")
      .style("font-size", 20)
      .call(axis1)
      .call(mathmlAxis, true);
    
    const gAxis2 = svg
      .append("g")
      .attr("id", "axis2")
      .attr("transform", "translate(150, 90)")
      .style("font-size", 20)
      .call(axis2)
      .call(mathmlAxis, false);
    
    function mathmlAxis(ax, vertical) {
      ax.selectAll("g")
        .append("svg:foreignObject")
        .attr("width", 40)
        .attr("height", 40)
        .attr("x", vertical ? -45 : -10)
        .attr("y", vertical ? -16 : 16)
        .append("xhtml:div")
        .html((d, i) => mathmlFractionFromDec(d));
    }
    
    // https://stackoverflow.com/questions/14002113/how-to-simplify-a-decimal-into-the-smallest-possible-fraction
    function mathmlFractionFromDec(x0) {
      var eps = 1.0E-15;
      var h, h1, h2, k, k1, k2, a, x;
    
      x = x0;
      a = Math.floor(x);
      h1 = 1;
      k1 = 0;
      h = a;
      k = 1;
    
      while (x-a > eps*k*k) {
          x = 1/(x-a);
          a = Math.floor(x);
          h2 = h1; h1 = h;
          k2 = k1; k1 = k;
          h = h2 + a*h1;
          k = k2 + a*k1;
      }
    
      return `<math><mrow><mfrac><mn>${h}</mn><mn>${k}</mn></mfrac></mrow></math>`;
    }
    <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.6.0/d3.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"></script>