Creating a Heatmap with a Marginal Histogram in Svelte

I recently started reading The Big Picture by Steve Wexler and found the highlight table with marginal histogram in Chapter 1 to be a great visualization. I wanted to try and recreate this in Svelte.

We are going to go through the following steps to create the marginal histogram

  1. Generate random data
  2. Display data in grid
  3. Create color scale
  4. Apply color scale to heatmap
  5. Build histogram for totals
  6. Putting the histogram and heatmap together

Here is demo of what we are creating with some random data:

Mon
Tue
Wed
Thu
Fri
Sat
Sun
12 AM
85
81
134
87
84
125
85
681
1 AM
130
125
109
149
90
147
88
838
2 AM
90
72
54
87
73
51
85
512
3 AM
41
91
60
93
52
66
53
456
4 AM
57
74
80
3
36
64
86
400
5 AM
16
65
67
89
88
94
87
506
6 AM
15
60
56
74
31
52
20
308
7 AM
4
47
60
12
40
71
68
302
8 AM
74
94
74
105
76
69
102
594
9 AM
82
89
118
112
124
85
104
714
10 AM
76
109
97
74
91
67
85
599
11 AM
164
294
239
259
166
272
296
1,690
12 PM
156
190
206
188
186
167
178
1,271
1 PM
130
146
170
138
181
110
181
1,056
2 PM
103
133
142
178
139
164
185
1,044
3 PM
109
153
195
133
142
163
162
1,057
4 PM
166
187
129
120
113
152
178
1,045
5 PM
1
96
17
0
29
52
18
213
6 PM
73
15
59
57
10
62
53
329
7 PM
62
83
46
95
95
58
79
518
8 PM
39
10
13
62
13
51
33
221
9 PM
81
2
42
53
43
45
1
267
10 PM
129
158
118
93
124
121
119
862
11 PM
88
99
117
108
109
116
100
737
1,971
2,473
2,402
2,369
2,135
2,424
2,446

Generating Random Data

We want random values to fill for each hour of the day and each day of the week

For the data, we will model it as a 2D matrix (an array of arrays).

In our case, the each row will represent a day of the week, and each column will represent an hour of the day. The output of the random data generator will be an array of 7 arrays with 24 values in each.

function generateRandomHeatmapData({
  min = 0,
  max = 100,
}: {
  min?: number;
  max?: number;
} = {}) {
  const dataByDay = [];
  for (let day = 0; day < 7; day++) {
    const dataByHour = [];
    for (let hour = 0; hour < 24; hour++) {
      const randomVal = Math.floor(Math.random() * 100);
      dataByHour.push(randomVal);
    }
    dataByDay.push(dataByHour);
  }
  return {
    data: dataByDay,
    min,
    max,
  };
}
const { data } = generateRandomHeatmapData()
// data = [[80, 44, 74, ...], [53, 78, 10, ...], ...]

Display Data in Grid

Now we want to creating a grid with the random data from the prior step.

Since we are dealing with a table of data, I think using css grid makes sense for this layout.

Using a grid layout we will need 7 columns (one for each day of the week), and some space between each column and row.

We are also setting a max-width so the cells don’t stretch too wide. Inside each cell we will render the number centered. To start we can just use a consistent background color of orange to see the cells.

In Tailwind and Svelte we can represent with the classes below.

<div class="grid max-w-xl grid-cols-7 gap-0.5">
  {#each data as day}
    {#each day as value}
      <div class="flex items-center justify-center bg-orange-300">
        {value}
      </div>
    {/each}
  {/each}
</div>

Creating a Color Scale

Instead of the same background for each cell, we want to instead create a scale of colors to highlight the larger values with a darker background.

We will adjust the color using rgba where the last value (alpha) will control the opacity of the color. Since we don’t want the values at the bottom of the range to be fully transparent we will only place values within the range of 0.2 to 1 for the alpha value. To achieve this we will add a linear interpolation function (lerp).

function lerp({
  x,
  xMin,
  xMax,
  yMin = 0,
  yMax = 1,
}: {
  x: number;
  xMin: number;
  xMax: number;
  yMin?: number;
  yMax?: number;
}) {
  const ySpread = yMax - yMin;
  const xRatio = (x - xMin) / (xMax - xMin);
  const y = xRatio * ySpread + yMin;
  return y;
}

Apply color scale to Heatmap

Now that we have the alpha value for the rgba calculated we can apply the background color to each cell.

<script>
  import {
    generateRandomHeatmapData,
    lerp,
  } from '@utils/data-viz/heatmaps';
  const { data, min, max } = generateRandomHeatmapData();

function getHeatmapColor(value: number) {
    const alpha = lerp({
      x: value,
      xMin: min,
      xMax: max,
      yMin: 0.2,
      yMax: 1,
    });
    return `rgba(253, 186, 116, ${alpha})`;
  }
</script>

<div class="grid max-w-xl grid-cols-7 gap-0.5">
  {#each data as row}
    {#each row as value}
      <div
        class="flex items-center justify-center"
        style="background-color: {getHeatmapColor(value)};"
      >
        {value}
      </div>
    {/each}
  {/each}
</div>

Now our heatmap looks like the below

Mon
Tue
Wed
Thu
Fri
Sat
Sun
12 AM
85
81
134
87
84
125
85
1 AM
130
125
109
149
90
147
88
2 AM
90
72
54
87
73
51
85
3 AM
41
91
60
93
52
66
53
4 AM
57
74
80
3
36
64
86
5 AM
16
65
67
89
88
94
87
6 AM
15
60
56
74
31
52
20
7 AM
4
47
60
12
40
71
68
8 AM
74
94
74
105
76
69
102
9 AM
82
89
118
112
124
85
104
10 AM
76
109
97
74
91
67
85
11 AM
164
294
239
259
166
272
296
12 PM
156
190
206
188
186
167
178
1 PM
130
146
170
138
181
110
181
2 PM
103
133
142
178
139
164
185
3 PM
109
153
195
133
142
163
162
4 PM
166
187
129
120
113
152
178
5 PM
1
96
17
0
29
52
18
6 PM
73
15
59
57
10
62
53
7 PM
62
83
46
95
95
58
79
8 PM
39
10
13
62
13
51
33
9 PM
81
2
42
53
43
45
1
10 PM
129
158
118
93
124
121
119
11 PM
88
99
117
108
109
116
100

Build Histogram for Totals

Now that we have the heatmap functioning, we need to build a histogram to get the totals by day and the totals by hour.

For the histogram, we have two cases: (1) for week days we want the bars vertical and (2) for hours we want the bars horizontal

Once we total the values by day and by hour we can display the histogram as below

<script lang="ts">
  import { lerp, generateRandomData } from '@utils/data-viz/data';

  export let category: 'hour' | 'day' = 'day';
  const isDay = category === 'day';
  const { data, weekHeaders, hourHeaders } = generateRandomData();
  const dayTotals = Array(7).fill(0);
  const hourlyTotals = [];
  for (const hourly of data) {
    let hourTotal = 0;
    for (const [idx, value] of hourly.entries()) {
      dayTotals[idx] += value;
      hourTotal += value;
    }
    hourlyTotals.push(hourTotal);
  }
  const histData = isDay ? dayTotals : hourlyTotals;
  const intervals = isDay ? weekHeaders : hourHeaders;

  function getSize(value: number) {
    const size = lerp({
      x: value,
      xMin: 0,
      xMax: Math.max(...histData),
      yMin: 0,
      yMax: 200,
    });
    return `${size}px`;
  }
</script>

<div class="max-w-xl">
  {#if isDay}
    <div class="flex items-end space-x-1">
      {#each histData as value}
        <div class="w-12 bg-teal-400" style="height: {getSize(value)}" />
      {/each}
    </div>
    <div class="flex space-x-1">
      {#each intervals as interval}
        <div class="w-12 text-center">{interval}</div>
      {/each}
    </div>
  {:else}
    <div class="container">
      {#each histData as value, idx}
        <div class="flex items-center">{hourHeaders[idx]}</div>
        <div class="h-12 bg-teal-400" style="width: {getSize(value)}" />
      {/each}
    </div>
  {/if}
</div>

<style>
  .container {
    display: grid;
    grid-template-columns: 60px 1fr;
    grid-gap: 0.125rem;
  }
</style>

Histogram for Days

Mon
Tue
Wed
Thu
Fri
Sat
Sun

Histogram for Hours

12 AM
1 AM
2 AM
3 AM
4 AM
5 AM
6 AM
7 AM
8 AM
9 AM
10 AM
11 AM
12 PM
1 PM
2 PM
3 PM
4 PM
5 PM
6 PM
7 PM
8 PM
9 PM
10 PM
11 PM

Putting the histogram and heatmap together

Now the last step is putting the histograms beside the heatmap. The histogram for hourly data will need to be added as an additional column in the grid, and the histogram for weekly data will be added as the last row.

One change I made for the background colors was adding an interpolation between three colors. This allows for a larger range in color contrast between the highest value and lowest value. The final svelte component is below:

<script lang="ts">
  import { generateRandomData, lerp, lerpColor } from '@utils/data-viz/data';
  export let randomData = generateRandomData();
  const { data, min, max, weekHeaders, hourHeaders } = randomData;

  const histDataDays = Array(7).fill(0);
  const histDataHours: number[] = [];
  for (const hourly of data) {
    let hourTotal = 0;
    for (const [idx, value] of hourly.entries()) {
      histDataDays[idx] += value;
      hourTotal += value;
    }
    histDataHours.push(hourTotal);
  }

  function getSize({ value, type }: { value: number; type: 'day' | 'hour' }) {
    const isDay = type === 'day';
    const histData = isDay ? histDataDays : histDataHours;
    const yMax = isDay ? 120 : 80;
    const size = lerp({
      x: value,
      xMin: 0,
      xMax: Math.max(...histData),
      yMin: 0,
      yMax,
    });
    return `${size}px`;
  }

  function interpolateColor(value: number) {
    const midValue = min + 0.5 * (max - min);
    const colorStart: [number, number, number] = [246, 209, 159];
    const colorMid: [number, number, number] = [232, 151, 90];
    const colorEnd: [number, number, number] = [167, 101, 79];
    if (value <= midValue) {
      return lerpColor({
        value,
        min,
        max: midValue,
        colorStart,
        colorEnd: colorMid,
      });
    } else {
      return lerpColor({
        value,
        min: midValue,
        max,
        colorStart: colorMid,
        colorEnd,
      });
    }
  }
</script>

<div class="min-w-500px container max-w-2xl">
  {#each data as row, rowIdx}
    {#if rowIdx === 0}
      <div />
      {#each weekHeaders as colHeader}
        <div
          class="flex items-center justify-center text-sm font-semibold text-zinc-600"
        >
          {colHeader}
        </div>
      {/each}
      <div />
    {/if}
    <div class="text-sm font-semibold text-zinc-600">{hourHeaders[rowIdx]}</div>
    {#each row as value}
      <div
        class="flex items-center justify-center text-zinc-800"
        style="background-color: {interpolateColor(value)};"
      >
        {value}
      </div>
    {/each}
    <div class="flex">
      <div
        class="h-full shrink-0 bg-zinc-300"
        style="width: {getSize({ value: histDataHours[rowIdx], type: 'hour' })}"
      />
      <div class="pl-1 text-sm text-zinc-500">
        {new Intl.NumberFormat().format(histDataHours[rowIdx])}
      </div>
    </div>
  {/each}
  <div />
  {#each histDataDays as value}
    <div>
      <div
        class="w-full bg-zinc-300"
        style="height: {getSize({ value, type: 'day' })}"
      />
      <div class="pt-1 text-center text-sm text-zinc-500">
        {new Intl.NumberFormat().format(value)}
      </div>
    </div>
  {/each}
  <div />
</div>

<style>
  .container {
    display: grid;
    grid-template-columns: 50px repeat(8, minmax(40px, 1fr));
    grid-gap: 0.125rem;
  }
</style>

The below marginal histogram will be with random data each time you refresh the page.

Mon
Tue
Wed
Thu
Fri
Sat
Sun
12 AM
89
81
130
136
146
123
137
842
1 AM
87
128
89
81
137
113
89
724
2 AM
26
21
44
94
10
60
54
309
3 AM
48
69
32
82
64
96
19
410
4 AM
16
49
62
76
94
94
39
430
5 AM
56
72
75
24
34
49
37
347
6 AM
71
2
72
96
36
51
5
333
7 AM
32
52
58
81
77
97
2
399
8 AM
72
119
115
69
71
104
99
649
9 AM
71
88
78
80
103
118
107
645
10 AM
110
96
89
103
65
71
78
612
11 AM
262
154
198
224
247
274
179
1,538
12 PM
161
277
247
232
255
288
238
1,698
1 PM
139
142
153
102
161
189
184
1,070
2 PM
111
157
132
184
127
117
158
986
3 PM
167
179
177
180
140
182
121
1,146
4 PM
102
107
194
162
167
148
139
1,019
5 PM
30
46
78
1
69
39
67
330
6 PM
96
16
27
38
1
80
32
290
7 PM
16
92
42
69
30
12
62
323
8 PM
18
3
47
66
77
37
87
335
9 PM
93
64
7
57
87
48
49
405
10 PM
155
130
120
127
146
120
115
913
11 PM
90
67
83
93
103
106
66
608
2,118
2,211
2,349
2,457
2,447
2,616
2,163