Search code examples
javascripthtmljquerycssgraph

Beeswarm plot not collapsing


I'm trying to make my own beeswarm plot, which is a plot of points across one dimension where the points move up as needed in order to not overlap with other points.

Mine has labels for each point, and I'm trying to work out the javascript to move them up from the baseline only if they collide with a previous point. However, they're moving up regardless of if they collide or not.

Desired behavior: In the first example (depending on your screen width) ● Juliet Francis should be down at the baseline instead of way up near the top, because it can exist at the baseline without colliding with any preceding points (ordered from right to left).

I've tested the collision function, and that seems to be working fine. The problem seems to be either in swarm() or collidesAll() functions.

I've been poking at this for a couple days and haven't been able to get it working. A second pair of eyes would be appreciated.

$(function() {
  
  let $graphs = $('.graph')
  let $firstSetOfNames = $('.graph:first-child .graph-dd')
  
  const setup = () => {
    let longest = 0
    $firstSetOfNames.each(function() {
      longest = ($(this).width() > longest) ? $(this).width() : longest
    })
    $graphs.css('padding-right', longest + 'px')
  }
  
  const collides = ($e1, $e2) => {
    let e1x1 = $e1.offset().left
    let e1x2 = e1x1.x1 + $e1.outerWidth( true )
    let e2x1 = $e2.offset().left
    let e2x2 = e2x1.x1 + $e2.outerWidth( true )
    let x = ((e1x1 < e2x1) && (e2x1 < e1x2)) || ((e2x1 < e1x1) && (e1x1 < e2x2))
    let y = parseInt($e1.css('--y'), 10) === parseInt($e2.css('--y'), 10)
    return !!(x || y)
  }
  
  const collidesAll = ($people, $person, j) => {
    for (let i = 0; i < j; i++) {
      if (collides($person, $people.eq(i))) {
        return true
      }
    }
    return false
  }
  
  const swarm = () => {
    $graphs.each(function(i) {
      let $graph = $(this)
      let $people = $($graph.find('.graph-dd').get().reverse())
      $people.each(function(j) {
        let $person = $(this)
        let n = 1
        if (0 === j) {
          $person.css('--y', 1)
        } else {
          do {
            $person.css('--y', n++)
          } while (collidesAll($people, $person, j))
          $graph.css('--yMax', n)
        }
      })
    })
  }
  
  setup()
  swarm()
  
})
.graph {
  margin: 2rem;
  padding: calc(calc(var(--yMax, 0) * 1.1em) + 1rem) 1rem 1rem;
  border: 1px solid black;
  overflow: hidden;
}

.graph-dl {
  position: relative;
  display: flex;
  margin: 0;
  justify-content: space-between;
}
.graph-dl::before {
  content: "";
  position: absolute;
  display: block;
  height: 1px;
  top: calc(50% - 0.5px);
  left: 0;
  right: 0;
  background: gray;
  z-index: -1;
}

.graph-dt {
  background: black;
  width: 1px;
  height: 1em;
  border: 0.5em solid white;
  margin: 0 -0.5em;
}
.graph-dt span {
  display: none;
}

.graph-dd {
  position: absolute;
  display: block;
  top: 0.5em;
  margin: 0 0 0 calc(-0.5ex - 1px);
  left: calc(var(--percent) * 1%);
  white-space: nowrap;
  transform: translateY(calc(var(--y, 0) * -1.1em));
  transition: transform 0.3s;
}
.graph-dd::before {
  content: "";
  display: inline-block;
  background: blue;
  width: 1ex;
  height: 1ex;
  vertical-align: baseline;
  border-radius: 100%;
  margin-right: 0.2em;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>

<figure class="graph">
  <dl class="graph-dl">
    <dt class="graph-dt"><span>0</span></dt>
    <dd class="graph-dd" data-value="9" style="--percent: 9; --y: 1;">Zaccaria Osmant</dd>
    <dt class="graph-dt"><span>10</span></dt>
    <dd class="graph-dd" data-value="11" style="--percent: 11; --y: 1;">Nelli Dunge</dd>
    <dd class="graph-dd" data-value="12" style="--percent: 12; --y: 1;">Ethelind Evers</dd>
    <dd class="graph-dd" data-value="16" style="--percent: 16; --y: 1;">Juliet Francis</dd>
    <dt class="graph-dt"><span>20</span></dt>
    <dt class="graph-dt"><span>30</span></dt>
    <dt class="graph-dt"><span>40</span></dt>
    <dd class="graph-dd" data-value="48" style="--percent: 48; --y: 1;">Myles Burdoun</dd>
    <dt class="graph-dt"><span>50</span></dt>
    <dd class="graph-dd" data-value="55" style="--percent: 55; --y: 1;">Gregory Beade</dd>
    <dt class="graph-dt"><span>60</span></dt>
    <dd class="graph-dd" data-value="60" style="--percent: 60; --y: 1;">Trenna Vigne</dd>
    <dd class="graph-dd" data-value="61" style="--percent: 61; --y: 1;">Dulcia Koubu</dd>
    <dt class="graph-dt"><span>70</span></dt>
    <dd class="graph-dd" data-value="70" style="--percent: 70; --y: 1;">Amberly Wrightham</dd>
    <dd class="graph-dd" data-value="73" style="--percent: 73; --y: 1;">Barney Rawstorn</dd>
    <dt class="graph-dt"><span>80</span></dt>
    <dt class="graph-dt"><span>90</span></dt>
    <dd class="graph-dd" data-value="91" style="--percent: 91; --y: 1;">Nealson Helstrip</dd>
    <dd class="graph-dd" data-value="92" style="--percent: 92; --y: 1;">Asa Langwade</dd>
    <dd class="graph-dd" data-value="93" style="--percent: 93; --y: 1;">Malvin Imlaw</dd>
    <dd class="graph-dd" data-value="96" style="--percent: 96; --y: 1;">Joanie Clooney</dd>
    <dt class="graph-dt"><span>100</span></dt>
    <dd class="graph-dd" data-value="100" style="--percent: 100; --y: 1;">Kristo Biskupski</dd>
  </dl>
</figure>
<figure class="graph">
  <dl class="graph-dl">
    <dt class="graph-dt"><span>0</span></dt>
    <dt class="graph-dt"><span>10</span></dt>
    <dt class="graph-dt"><span>20</span></dt>
    <dt class="graph-dt"><span>30</span></dt>
    <dt class="graph-dt"><span>40</span></dt>
    <dd class="graph-dd" data-value="44" style="--percent: 44; --y: 1;">Nelli Dunge</dd>
    <dd class="graph-dd" data-value="48" style="--percent: 48; --y: 1;">Myles Burdoun</dd>
    <dt class="graph-dt"><span>50</span></dt>
    <dd class="graph-dd" data-value="51" style="--percent: 51; --y: 1;">Zaccaria Osmant</dd>
    <dt class="graph-dt"><span>60</span></dt>
    <dd class="graph-dd" data-value="61" style="--percent: 61; --y: 1;">Trenna Vigne</dd>
    <dd class="graph-dd" data-value="61" style="--percent: 61; --y: 1;">Dulcia Koubu</dd>
    <dd class="graph-dd" data-value="65" style="--percent: 65; --y: 1;">Ethelind Evers</dd>
    <dt class="graph-dt"><span>70</span></dt>
    <dd class="graph-dd" data-value="70" style="--percent: 70; --y: 1;">Amberly Wrightham</dd>
    <dd class="graph-dd" data-value="73" style="--percent: 73; --y: 1;">Barney Rawstorn</dd>
    <dd class="graph-dd" data-value="74" style="--percent: 74; --y: 1;">Kristo Biskupski</dd>
    <dd class="graph-dd" data-value="75" style="--percent: 75; --y: 1;">Joanie Clooney</dd>
    <dd class="graph-dd" data-value="77" style="--percent: 77; --y: 1;">Juliet Francis</dd>
    <dd class="graph-dd" data-value="77" style="--percent: 77; --y: 1;">Gregory Beade</dd>
    <dd class="graph-dd" data-value="79" style="--percent: 79; --y: 1;">Malvin Imlaw</dd>
    <dt class="graph-dt"><span>80</span></dt>
    <dd class="graph-dd" data-value="85" style="--percent: 85; --y: 1;">Asa Langwade</dd>
    <dt class="graph-dt"><span>90</span></dt>
    <dd class="graph-dd" data-value="91" style="--percent: 91; --y: 1;">Nealson Helstrip</dd>
    <dt class="graph-dt"><span>100</span></dt>
  </dl>
</figure>


Solution

  • There are some problems with your code:

    const collides = ($e1, $e2) => {
      let e1x1 = $e1.offset().left
      let e1x2 = e1x1.x1 + $e1.outerWidth( true )
      let e2x1 = $e2.offset().left
      let e2x2 = e2x1.x1 + $e2.outerWidth( true )
      let x = ((e1x1 < e2x1) && (e2x1 < e1x2)) || ((e2x1 < e1x1) && (e1x1 < e2x2))
      let y = parseInt($e1.css('--y'), 10) === parseInt($e2.css('--y'), 10)
      return !!(x || y)
    }
    
    1. In collides function, e1x1 and e2x1 is already a number so e1x1.x1 and e2x1.x1 will be undefined. That's cause e1x2 and e2x2 become NaN.
    2. The graph is already sorted by value so you just need no check if a point collide with a point right before it, not all previous points (For example: Just neeed to check if Juliet Francis collide with Myles Burdoun in the first graph). And because of that, you don't need to check for y value anymore.

    This is a modified collides function:

    const collides = ($e1, $e2) => {
      let e1x1 = $e1.offset().left;
      let e2x1 = $e2.offset().left;
      let e1Width = $e1.outerWidth( true );
      let e2Width = $e2.outerWidth( true );
      let e1x2 = e1x1 + e1Width;
      let e2x2 = e2x1 + e2Width;
      if (e2x2 >= e1x1) return (e2x2 - e1x1) < (e1Width + e2Width);
      else return (e1x2 - e2x1) < (e1Width + e2Width);
    }
    

    I also change how to calculte --yMax of the graph to adopt with that modification.

    Codepen

    UPDATE: I've misunderstood a bit. If you want an item to sit at the baseline if it had enough room, you can keep your logic, just need to make a little modification in collides function and --yMax calculation like the codepen below:

    Updated Codepen