Search code examples
javascriptd3.jsplotobservable-plot

How to filter Observable Plot text mark when "section" is too narrow


Using Observable Plot, I wish to recreate the https://merrysky.net weather timeline with colored bands and text labels.

I have gotten an initial version working, but it has some flaws:

  • The labels are often longer than the "section" they are labeling, so the text overlaps with the next label.
  • The labels are not centered on the section. (I know how to fix this, except when adding icons to the left of the labels.)
  • Live demo
  • Relevant source code

Questions:

  • First of all: is there a better way to create these colored & labeled sections in Observable Plot? My code uses Plot.areaY
  • How do I filter out the labels that are too wide for their sections? Currently my code is a simple filter that removes repeated weather codes. The filter function has access to the data item, but the only information related to the X axis is time. I am not sure how to convert this time into pixels on the X axis. (Nor calculate the width of the label itself in pixels.)
  • Finally, I would like to add a small image mark of the weather code icon to the left of the text label. When the section is too narrow, only the icon would display (if there is enough width.)

Of course, I am open to solutions using the underlying D3 API, as well.


Screenshot of current progress:

enter image description here


UPDATE: added simple example:

const data = [{
    text: 'Heavy Freezing Drizzle',
    utc: new Date('2024-06-16'), end: new Date('2024-06-17'),
  }, {
    text: 'Light Snow Showers',
    utc: new Date('2024-06-17'), end: new Date('2024-06-18'),
  }, {
    text: 'Clear',
    utc: new Date('2024-06-18'), end: new Date('2024-06-22'),
  }, {
    text: 'Thunderstorms with light hail',
    utc: new Date('2024-06-22'), end: new Date('2024-06-23'),
  }]
  
  const plot = Plot.plot({
    height: 100,
    marks: [
        Plot.rectY(data, {
            x1: (d) => d.utc,
                x2: (d) => d.end,
                y: 1,
                fill: (d) => d.text == 'Clear' ? 'lightgray': 'SkyBlue'
            }),
      Plot.text(data, {
        x: (d) => (Number(d.utc) + Number(d.end))/2,
        text: 'text'
      }),
    ],
  });

  const div = document.querySelector('#myplot');
  div.append(plot);
<!DOCTYPE html>
<script src="https://cdn.jsdelivr.net/npm/d3@7"></script>
<script src="https://cdn.jsdelivr.net/npm/@observablehq/[email protected]"></script>

"Clear" is the only text mark that should be rendered because the other text marks overflow their section rect marks.


<div id="myplot"></div>


Solution

  • You are essentially asking how to do overlap detection. Here's a quick algorithm that I stuffed into the same style "render" method we used in your last question. I've tried to comment it well:

    <!DOCTYPE html>
    <div id="myplot"></div>
    <script src="https://cdn.jsdelivr.net/npm/d3@7"></script>
    <script src="https://cdn.jsdelivr.net/npm/@observablehq/[email protected]"></script>
    <script type="module">
      const div = document.querySelector('#myplot');
    
      const codes = ['tiny', 'very, very, very, very, very, large'];
      const data = [];
      for (let i = 0; i < 20; i++) {
        data.push({
          x: i,
          y: Math.random() * 100,
          code: codes[Math.floor(Math.random() * codes.length)]  //<-- random label of varying size
        });
      }
    
      const plot = Plot.plot({
        marks: [
          Plot.text(data, {
            x: 'x',
            y: 100,
            textAnchor: 'start',
            text: function (d) {
              return d.code;
            },
            render: (i, s, v, d, c, next) => {
              const g = next(i, s, v, d, c),
                textLabels = d3.select(g).selectAll('text').nodes();
              setTimeout(() => {  //<-- run in setTimeout as we need the text elements to render to get their size
                for (let i = 0; i < textLabels.length - 1; i++) { //<-- loop the labels
                  const cu = textLabels[i],
                    cuBBox = cu.getBoundingClientRect();  //<-- get the size of the current label
                  for (let j = i + 1; j < textLabels.length; j++) { //<-- loop the next N adjacent labels
                    const ne = textLabels[j],
                      neBBox = ne.getBoundingClientRect();
                    if (cuBBox.x + cuBBox.width > neBBox.x) {
                      d3.select(ne).remove();  //<-- if the adjacent label overlaps current, remove adjacent
                    } else {
                      i = j - 1; //<-- we've found the first adjacent that doesn't overlap, return to top loop and let this one become our next "current"
                      break;
                    }
                  }
                }
              },0);
    
              return g;
            },
          }),
          Plot.line(data, { x: 'x', y: 'y' }),
        ],
      });
      div.append(plot);
    </script>

    Edits Based on Comments

    Here's a modification that "ellipses" the text based on the size of the sibling rects.

    <!DOCTYPE html>
    <script src="https://cdn.jsdelivr.net/npm/d3@7"></script>
    <script src="https://cdn.jsdelivr.net/npm/@observablehq/[email protected]"></script>
    
    "Clear" is the only text mark that should be rendered because the other text
    marks overflow their section rect marks.
    
    <div id="myplot"></div>
    
    <script>
      const data = [
        {
          text: 'Heavy Freezing Drizzle',
          utc: new Date('2024-06-16'),
          end: new Date('2024-06-17'),
        },
        {
          text: 'Light Snow Showers',
          utc: new Date('2024-06-17'),
          end: new Date('2024-06-18'),
        },
        {
          text: 'Clear',
          utc: new Date('2024-06-18'),
          end: new Date('2024-06-22'),
        },
        {
          text: 'Thunderstorms with light hail',
          utc: new Date('2024-06-22'),
          end: new Date('2024-06-23'),
        },
      ];
    
      const plot = Plot.plot({
        height: 100,
        marks: [
          Plot.rectY(data, {
            x1: (d) => d.utc,
            x2: (d) => d.end,
            y: 1,
            fill: (d) => (d.text == 'Clear' ? 'lightgray' : 'SkyBlue'),
          }),
          Plot.text(data, {
            x: (d) => (Number(d.utc) + Number(d.end)) / 2,
            text: 'text',
            render: (i, s, v, d, c, next) => {
              const g = next(i, s, v, d, c);
              setTimeout(() => {
                const textLabels = d3.select(g).selectAll('text').nodes(),
                  rects = d3.select(g.previousSibling).selectAll('rect').nodes();
                for (let i = 0; i < textLabels.length; i++) {
                  const t = textLabels[i],
                    r = rects[i],
                    rW = r.getBBox().width;
                  let txt = v.text[i],
                      tW = t.getComputedTextLength();
                  while (tW > rW && txt.length > 0) {
                    txt = txt.slice(0, -1);
                    t.textContent = txt + "...";
                    tW = t.getComputedTextLength();
                  }              
                }
              }, 10);
              return g;
            },
          }),
        ],
      });
    
      const div = document.querySelector('#myplot');
      div.append(plot);
    </script>