Archived

Documentation for Layer Cake v10, before Svelte 5 runes. Current docs at layercake.graphics

Menu

BeeswarmForce.html.svelte component

Generates an HTML beeswarm chart, using a d3-force simulation to spread the circles apart.

Param Default Required Description
r number 4
no
The circle radius in pixels.
strokeWidth number 1
no
The circle's stroke width in pixels.
stroke string '#fff'
no
The circle's stroke color.
xStrength number 0.95
no
How hard each circle is pulled toward its x value. See forceX.strength.
yStrength number 0.075
no
How hard each circle is pulled toward the vertical middle of the chart. See forceY.strength.
getTitle (d: any) => string None
no
Returns hover text for a row, shown in a .title div on hover.
<!--
  @component
  Generates an HTML beeswarm chart, using a [d3-force simulation](https://github.com/d3/d3-force) to spread the circles apart.
 -->
<script>
  import { forceSimulation, forceX, forceY, forceCollide } from 'd3-force';
  import { getLayerCakeContext } from 'layercake';

  const k = getLayerCakeContext();

  /**
   * @typedef {Object} Props
   * @property {number} [r=4] - The circle radius in pixels.
   * @property {number} [strokeWidth=1] - The circle's stroke width in pixels.
   * @property {string} [stroke='#fff'] - The circle's stroke color.
   * @property {number} [xStrength=0.95] - How hard each circle is pulled toward its x value. See [forceX.strength](https://github.com/d3/d3-force#x_strength).
   * @property {number} [yStrength=0.075] - How hard each circle is pulled toward the vertical middle of the chart. See [forceY.strength](https://github.com/d3/d3-force#y_strength).
   * @property {(d: any) => string} [getTitle] - Returns hover text for a row, shown in a `.title` div on hover.
   */

  /** @type {Props} */
  let {
    r = 4,
    strokeWidth = 1,
    stroke = '#fff',
    xStrength = 0.95,
    yStrength = 0.075,
    getTitle
  } = $props();

  // Run the simulation to the end up front so the circles come out settled.
  // The simulation mutates its nodes, so it gets copies of the rows.
  /** @type {Array<any>} */
  let nodes = $derived.by(() => {
    if (!k.width || !k.height || !k.data.length) return [];

    const simulation = forceSimulation(k.data.map(d => ({ ...d })))
      .force('x', forceX().x(k.xGet).strength(xStrength))
      .force(
        'y',
        forceY()
          .y(k.height / 2)
          .strength(yStrength)
      )
      .force('collide', forceCollide(r))
      .stop();

    // Tick as many times as the simulation would on its own before it cools off
    const iterations = Math.ceil(
      Math.log(simulation.alphaMin()) / Math.log(1 - simulation.alphaDecay())
    );
    for (let i = 0; i < iterations; i += 1) {
      simulation.tick();
    }

    return simulation.nodes();
  });
</script>

<div class="bee-group">
  {#each nodes as node}
    <div
      class="bee"
      style="
        left:{node.x}px;
        top: {node.y}px;
        width: {r * 2}px;
        height: {r * 2}px;
        background: {k.cGet?.(node) ?? '#ccc'};
        border-width: {strokeWidth}px;
        border-color: {stroke};
        "
    >
      {#if getTitle}
        <div class="title">{getTitle(node)}</div>
      {/if}
    </div>
  {/each}
</div>

<style>
  .bee {
    position: absolute;
    border-style: solid;
    border-radius: 50%;
    transform: translate(-50%, -50%);
  }
  .title {
    display: none;
    white-space: nowrap;
    padding: 0 3px;
    border-radius: 3px;
    font-size: 12px;
    pointer-events: none;
    position: absolute;
    top: -15px;
    left: 5px;
    z-index: 9999;
  }
  .bee:hover .title {
    display: block;
  }
</style>