/ src / components / ActivityDiagram.svelte
ActivityDiagram.svelte
  1  <script lang="ts">
  2    import type { WeeklyActivity } from "@app/lib/commit";
  3  
  4    import { onMount } from "svelte";
  5  
  6    export let id: string;
  7    export let activity: WeeklyActivity[];
  8    export let viewBoxHeight: number;
  9    export let styleColor: string;
 10  
 11    const strokeWidth = 3;
 12    const viewBoxWidth = 600;
 13  
 14    // The path strings to be inserted into the svg <path>.
 15    let path = "";
 16    let areaPath = "";
 17  
 18    const heightWithPadding = viewBoxHeight + 16;
 19  
 20    // The latest point on the x axis, starting at 0 until `viewBoxWidth`.
 21    let lastWidthPoint = viewBoxWidth;
 22  
 23    // The amount of points on the x axis.
 24    const widthIteration = viewBoxWidth / 52;
 25  
 26    // The highest value on the y axis.
 27    const commitCountArray: number[] = [];
 28  
 29    // The minimal amplitude shown e.g. commitCount = 1 => `minimalHeight`
 30    // points of height in the SVG.
 31    const minimalHeight = 5;
 32  
 33    let week = 0;
 34  
 35    for (const point of activity) {
 36      if (point.week - week > 1) {
 37        commitCountArray.push(...new Array(point.week - week).fill(0));
 38      }
 39      commitCountArray.push(point.commits.length);
 40      week = point.week;
 41    }
 42  
 43    // Formats the points passed in, into a svg path string, without closing
 44    // the area.
 45    function createPath() {
 46      let i = 1;
 47  
 48      if (commitCountArray.length < 52) {
 49        commitCountArray.push(...new Array(52 - commitCountArray.length).fill(0));
 50      }
 51  
 52      const maxValue = Math.max(...commitCountArray);
 53      const minValue = Math.min(...commitCountArray);
 54  
 55      // Normalizes the values to the viewBox dimensions.
 56      const normalizedArray = commitCountArray.map(c => {
 57        // If we are not crossing the `viewBoxHeight` we want to return the
 58        // actual value, and don't want to normalize <`minimalHeight` commit
 59        // counts as huge spikes.
 60        if (maxValue < viewBoxHeight && c >= minimalHeight) {
 61          return c;
 62        }
 63        // If the value is 0..minimalHeight though we don't want to set it to
 64        // the minimalHeight.
 65        else if (c > 0 && c < minimalHeight) {
 66          return minimalHeight;
 67        }
 68        // If the count is 0 we have to make sure the normalization is not being
 69        // run since it would return NaN.
 70        else {
 71          return c === 0
 72            ? 0
 73            : ((viewBoxHeight - 0) * (c - minValue)) / (maxValue - minValue);
 74        }
 75      });
 76  
 77      const path = normalizedArray.slice(1).reduce(
 78        (acc, curr) => {
 79          const s = `${viewBoxWidth - widthIteration * i},${
 80            viewBoxHeight - curr
 81          }`;
 82          lastWidthPoint = viewBoxWidth - widthIteration * i;
 83          i += 1;
 84          return acc.concat(s);
 85        },
 86        [`M${viewBoxWidth},${viewBoxHeight - normalizedArray[0]}`],
 87      );
 88      return path.join();
 89    }
 90  
 91    onMount(() => {
 92      // Creates the stroke path with the array of points.
 93      path = createPath();
 94      // Concats a path closing for it to be the area under the stroke.
 95      areaPath = path.concat(
 96        `L${lastWidthPoint},${viewBoxHeight}L${viewBoxWidth},${viewBoxHeight}Z`,
 97      );
 98    });
 99  </script>
100  
101  <svg
102    style:color={styleColor}
103    viewBox="0 0 {viewBoxWidth} {heightWithPadding}"
104    xmlns="http://www.w3.org/2000/svg">
105    <linearGradient id={`${id}:fillGradient`} x1="0" y1="1" x2="0" y2="0">
106      <stop offset="0%" stop-color="currentColor" stop-opacity="0" />
107      <stop offset="100%" stop-color="currentColor" stop-opacity="0.2" />
108    </linearGradient>
109    <linearGradient id={`${id}:gradient`} x1="0" y1="1" x2="0" y2="0">
110      <stop offset="0%" stop-color="currentColor" stop-opacity="0.2" />
111      <stop offset="50%" stop-color="currentColor" stop-opacity="0.8" />
112      <stop offset="100%" stop-color="currentColor" stop-opacity="1" />
113    </linearGradient>
114    {#if activity.length > 0}
115      <g>
116        <path
117          fill="transparent"
118          stroke={`url(#${id}:gradient)`}
119          stroke-width={strokeWidth}
120          stroke-linejoin="round"
121          d={path} />
122        <path
123          fill={`url(#${id}:fillGradient)`}
124          stroke="transparent"
125          d={areaPath} />
126      </g>
127    {:else}
128      <!-- If no commits have been made in a year, we show a straight line -->
129      <line
130        x1="0"
131        y1={viewBoxHeight}
132        x2="600"
133        y2={viewBoxHeight}
134        stroke="currentColor"
135        stroke-width={1} />
136    {/if}
137  </svg>