Back to list

Spiral

Create a chart that spirals out from the center — great for showing yearly trends, not great for accurate comparisons. Inspired by this NYTimes chart from Gus Wezerek and Sara Chodosh.

Spiral.svelte
See on Github
<script>
  import { scaleLinear,scaleSqrt } from "d3-scale";
  import { extent, range } from "d3-array";
  import { format } from "d3-format";
  import { interpolateHclLong } from "d3-interpolate";
  import { timeDay,timeYear } from "d3-time";
  import { timeFormat } from "d3-time-format";
  import { fade } from "svelte/transition";

  export let data = [];
  export let metricAccessor = (d) => d['x'];
  export let timeAccessor = (d) => d['date'];

  const progressInYearAccessor = (d) => {
    const date = timeAccessor(d);
    console.log('date',date, timeDay.count(timeYear.floor(date), date));

    return timeDay.count(timeYear.floor(date), date);
  };
  const formatNumber = format(',.1s');
  const formatDate = timeFormat('%b %-d, %Y');

  let width = 100;
  $: height = width

  $: metricDomain = [0, 500];
  $: timeDomain = extent(data, timeAccessor);

  $: colorScale = scaleLinear()
    .domain([0, metricDomain[1] / 3, (metricDomain[1] / 3) * 2, metricDomain[1]])
    .range(['#f4f4f4', '#89BC97', '#5D2EDD', '#000'])
    .interpolate(interpolateHclLong);

  const maxLength = 8;
  $: scaleScale = scaleSqrt().domain(metricDomain).range([0, maxLength]);
  $: colorScaleTicks = colorScale.ticks(6).map((d) => {
    const scale = scaleScale(d);
    const tickHeight = (height / 100) * scale;

    return {
      tickHeight,
      value: d,
      percent: (d * 100) / metricDomain[1],
      color: colorScale(d)
    };
  });
  $: radiusScale = scaleLinear().domain(timeDomain).range([20, 40]);
  $: yearRadiusScale = scaleLinear()
    .domain(timeDomain)
    .range([height * 0.21, height * 0.41]);
  $: strokeWidthScale = scaleLinear().domain(timeDomain).range([0.3, 0.65]);
  $: angleScale = scaleLinear().domain([0, 365]).range([0, 360]);
  const getPositionFromDistanceAndAngle = (distance, angle) => {
    const x = distance * Math.cos((angle * Math.PI) / 180);
    const y = distance * Math.sin((angle * Math.PI) / 180);
    return { x, y };
  };
  const getTransformFromDistanceAndAngle = (distance, angle) => {
    const { x, y } = getPositionFromDistanceAndAngle(distance, angle);
    return `translate(${x}, ${y})`;
  };
  const getPathForValue = (value) => {
    const height = scaleScale(value);
    return ['M', 0, -height / 2, 'L', 0, height / 2].join(' ');
  };
  const monthNames = range(0,12).map(i => timeFormat('%b')(new Date(2000, i, 1)));
  $: months = monthNames.map((month, i) => {
    const angle = (360 / 12) * i - 360 / 4;
    const { x, y } = getPositionFromDistanceAndAngle(width * (i ? 0.38 + i * 0.009 : 0.49), angle);
    return {
      i,
      name: month,
      angle,
      x: x + width / 2,
      y: y - height / 2
    };
  });
  $: years = timeYear.range(timeYear.floor(timeDomain[0]), timeDomain[1])
      .map((year) => {
        const r = yearRadiusScale(year);
        const { x, y } = getPositionFromDistanceAndAngle(r, -360 / 4);
        return {
          name: year.getFullYear(),
          x: x + width / 2,
          y: y - height / 2
        };
      });
</script>

<div class="wrapper">
  <div bind:clientWidth={width}>
    <svg width="100%" viewBox="-50 -50 100 100">
        {#each data || [] as d,i (d.date)}
          <path
            class="transition"
            in:fade={{delay: i * 3}}
            d={getPathForValue(metricAccessor(d))}
            r={scaleScale(metricAccessor(d))}
            stroke={colorScale(metricAccessor(d))}
            stroke-width={strokeWidthScale(timeAccessor(d))}
            stroke-linecap="round"
            transform={getTransformFromDistanceAndAngle(
              radiusScale(timeAccessor(d)),
              angleScale(progressInYearAccessor(d)) - 360 / 4
            ) + `rotate(${angleScale(progressInYearAccessor(d))})`}
					>
					<title>{formatDate(timeAccessor(d))}: {metricAccessor(d)}</title>
					</path>
        {/each}
    </svg>

    <!-- these are overlaid in HTML to prevent from warping the text -->
    {#each months as { name, x, y }}
      <div
        class="label"
        style="transform: translate({x}px, {y}px)"
      >
        {name}
      </div>
    {/each}
    {#each years as { name, x, y }}
      <div
        class="label"
        style="transform: translate({x}px, {y}px)"
      >
        <div style="-webkit-text-stroke: 6px white">
          {name}
        </div>
        <div style="position: absolute">
          {name}
        </div>
      </div>
    {/each}
  </div>

  <div class="legend">
      <div class="legend__title">
        <slot name="legend-title" />
      </div>
      <div class="legend__ticks">
        {#each colorScaleTicks as { value, tickHeight, color } (value)}
          <div class="legend__tick">
            <div
              class="legend__bar-wrapper"
              style="height: {(height / 100) * maxLength}px"
            >
              <div
                class="legend__bar"
                style="height: {tickHeight}px; background-color: {color}"
              />
            </div>

            <div class="legend__value">
              {formatNumber(value)}{value === 500 ? '+' : ''}
            </div>
          </div>
        {/each}
      </div>
    </div>
</div>

<style>
  .wrapper {
    position: relative;
    width: 100%;
    max-width: 80vh;
    padding: 2%;
    margin-bottom: 1em;
  }
  .label {
    position: absolute;
    width: 0;
    font-size: 0.8em;
    text-transform: uppercase;
    letter-spacing: 0.1em;
    display: flex;
    align-items: center;
    justify-content: center;
  }
  .legend {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
  }
  .legend__title {
    text-align: center;
    font-size: 0.8em;
    text-transform: uppercase;
    letter-spacing: 0.1em;
    line-height: 1.2em;
    color: #999;
		max-width: 24em;
  }
  .legend__ticks {
    display: flex;
  }
  .legend__tick {
    display: flex;
    flex-direction: column;
    margin: 0 0.2em;
    transition: all 0.6s ease-out;
  }
  .legend__bar-wrapper {
    display: flex;
    justify-content: center;
    align-items: center;
  }
  .legend__bar {
    width: 0.6em;
    border-radius: 1em;
  }
  .legend__value {
    font-size: 0.8em;
    color: #999;
    text-align: center;
  }
  .transition {
    transition: all 0.6s ease-out;
  }
</style>

Usage example:

SpiralWrapper.svelte
See on Github
<script>
  import { csv } from "d3-fetch";
  import { timeParse } from "d3-time-format";
  import Spiral from "./Spiral.svelte";
  import { onMount } from "svelte";
  import countryNamesMap from "./country-names.json";

  const source =
    'https://raw.githubusercontent.com/owid/covid-19-data/master/public/data/owid-covid-data.csv';
  let allData = []
  let isLoading = true
  let countries = [];
  let country = 'USA';

  const getData = async () => {
    const res = await csv(source);
    allData = res
    isLoading = false
    countries = [...new Set(res.map((d) => d.iso_code))];
  };
  onMount(getData);
  $: filteredData = allData.filter((d) => d.iso_code === country);
  const parseDate = timeParse('%Y-%m-%d');
</script>

<div class="wrapper">
  {#if isLoading}
    <div class="loading">Loading...</div>

  {:else if filteredData.length}
    <select bind:value={country}>
      {#each countries as c}
        <option value={c}>{countryNamesMap[c] || c}</option>
      {/each}
    </select>

    <Spiral
      data={filteredData}
      metricAccessor={(d) => +d['new_cases_smoothed_per_million']}
      timeAccessor={(d) => parseDate(d['date'])}
    >
    <span slot="legend-title">
      New COVID-19 cases
      <br />per 1M people in
      <br />{countryNamesMap[country] || country}
      <br /><span style="opacity: 0.6">(smoothed across 7-days)</span>
    </span>
    </Spiral>

  {:else}
    <p>No data</p>
  {/if}

  <div class="info">
    <div>Data from Our World in Data</div>
    <a href="https://github.com/owid/covid-19-data">https://github.com/owid/covid-19-data</a>
  </div>
</div>

<style>
  .wrapper {
    width: 100%;
    min-height: 100vh;
    display: flex;
    flex-direction: column;
    align-items: center;
    padding-top: 2em;
  }
  select {
    padding: 0.3em 1em;
  }
  .loading {
    flex: 1;
    display: flex;
    justify-content: center;
    align-items: center;
    font-size: 0.8em;
    color: #999;
    font-style: italic;
  }
  .info {
    margin-top: -3em;
    font-size: 0.8em;
    color: #999;
    line-height: 1.5em;
    font-style: italic;
    text-align: center;
  }
</style>
Loading...
Data from Our World in Data
https://github.com/owid/covid-19-data