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
- Generate random data
- Display data in grid
- Create color scale
- Apply color scale to heatmap
- Build histogram for totals
- Putting the histogram and heatmap together
Here is demo of what we are creating with some random data:
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
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
Histogram for Hours
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.