skip to Main Content

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>

2

Answers


  1. Chosen as BEST ANSWER

    In addition to Mark's excellent solution, I found an Observable Plot-based solution:

    1. First, use Plot.rect instead of Plot.Area. Plot.Area works, but its interpolation can cause strange output for this type of use case.
    2. Render a special data array that merges adjacent data items with the same weather code into a single data item, so x1 is the starting time and x2 is the ending time for a section with that code.
    3. While we're at it, calculate xMiddle to mark the middle of the section.
    4. Sending this data to Plot.text() is pretty straight-forward.
    5. Whether the section is too narrow could not be precalculated, so a function is used for the text property. Method plot.scale('x').apply() is used to convert time units to pixel units and helps determine if the section is too narrow.
    6. I just hard-coded a width of 80 pixels (which works for my labels), but any of the methods Mark suggested could be used to calculate the exact width of the text.
    7. There is a chicken-and-egg problem because plot = Plot.plot() needs to use plot.scale(). I hacked around this problem by calling plot() twice. If not called twice, the scaling doesn't work the first time. Thus the width check doesn't work and labels won't be hidden when the sections are too narrow.

  2. 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>
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search