Appearance
Sales heatmap
Monthly revenue and growth per country, colored by value.
How it works
- Color scale:
cellStylecalculates the background color from the value withcolor-mix(): a sequential blue scale for revenue and a diverging red/green scale for growth. - Switching data: the columns are a
computedwhich reads another field depending on the selected metric. Column ids stay the same (the month), so width and sort state are kept. - Grouping: group by region (drag the column into the group area) — group rows show the sum (revenue) or the average (growth).
- Cell selection: select ranges with the mouse or Shift + arrow keys and copy them to Excel.
vue
<template>
<div class="demo">
<div class="demo-toolbar">
<label>
Metric
<select v-model="metric">
<option value="revenue">Revenue (k€)</option>
<option value="growth">Growth (%)</option>
</select>
</label>
<span class="legend"><span class="legend__scale" :class="metric"></span> low → high</span>
</div>
<Datagrid :columns="columns" :rows="rows" :options="options" />
</div>
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
import { Datagrid, type ColumnConfig, type GridOptions } from "@datagrid/vue-ui";
const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const regions = [
["Europe", "Germany"], ["Europe", "France"], ["Europe", "Italy"], ["Europe", "Spain"], ["Europe", "Poland"],
["Americas", "USA"], ["Americas", "Canada"], ["Americas", "Brazil"], ["Americas", "Mexico"],
["Asia", "Japan"], ["Asia", "China"], ["Asia", "India"], ["Asia", "Korea"],
];
type Sales = { region: string; country: string } & Record<string, number | string>;
const metric = ref<"revenue" | "growth">("revenue");
// deterministic "random" values
const noise = (a: number, b: number) => Math.abs(Math.sin(a * 12.9898 + b * 78.233) * 43758.5453) % 1;
const rows: Array<Sales> = regions.map(([region, country], r) => {
const row: Sales = { region, country };
months.forEach((month, m) => {
const season = 1 + 0.35 * Math.sin(((m + r) / 12) * Math.PI * 2);
row[`revenue_${month}`] = Math.round((200 + noise(r, 0) * 800) * season * (0.8 + noise(r, m) * 0.4));
row[`growth_${month}`] = Math.round((noise(m, r) - 0.4) * 400) / 10;
});
return row;
});
// color scale: value between min and max => color
function scale(value: number, min: number, max: number, diverging: boolean) {
if (diverging) {
const t = Math.max(-1, Math.min(1, value / Math.max(Math.abs(min), Math.abs(max))));
return t >= 0
? `color-mix(in srgb, #2da44e ${Math.round(t * 70)}%, transparent)`
: `color-mix(in srgb, #cf222e ${Math.round(-t * 70)}%, transparent)`;
}
const t = (value - min) / (max - min || 1);
return `color-mix(in srgb, #0969da ${Math.round(8 + t * 72)}%, transparent)`;
}
const columns = computed<Array<ColumnConfig<Sales>>>(() => {
const fields = months.map((month) => `${metric.value}_${month}`);
const values = rows.flatMap((row) => fields.map((field) => row[field] as number));
const min = Math.min(...values);
const max = Math.max(...values);
const diverging = metric.value === "growth";
return [
{ field: "region", text: "Region", width: 110, pinned: "left", groupable: true },
{ field: "country", text: "Country", width: 110, pinned: "left" },
...months.map((month) => ({
field: `${metric.value}_${month}`,
id: month,
text: month,
type: "number",
width: 72,
aggFunc: diverging ? ("avg" as const) : ("sum" as const),
valueFormatter: ({ value }: { value: number }) =>
value == null ? "" : diverging ? `${value > 0 ? "+" : ""}${value.toFixed(1)}` : Math.round(value).toLocaleString(),
// the background color is calculated from the value
cellStyle: ({ value }: { value: number }) => ({ backgroundColor: scale(value, min, max, diverging) }),
})),
];
});
const options: GridOptions<Sales> = {
rowHeight: 30,
selection: "Cell",
singleSelect: false,
defaultColumn: { sortable: true, resizeable: true },
};
</script>
<style>
.legend {
display: inline-flex;
align-items: center;
gap: 8px;
}
.legend__scale {
display: inline-block;
width: 120px;
height: 12px;
border-radius: 6px;
background: linear-gradient(to right, rgba(9, 105, 218, 0.08), rgba(9, 105, 218, 0.8));
}
.legend__scale.growth {
background: linear-gradient(to right, rgba(207, 34, 46, 0.7), transparent, rgba(45, 164, 78, 0.7));
}
</style>tsx
import { useMemo, useState } from "react";
import { Datagrid, type ColumnConfig, type GridOptions } from "@datagrid/react-ui";
const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const regions = [
["Europe", "Germany"], ["Europe", "France"], ["Europe", "Italy"], ["Europe", "Spain"], ["Europe", "Poland"],
["Americas", "USA"], ["Americas", "Canada"], ["Americas", "Brazil"], ["Americas", "Mexico"],
["Asia", "Japan"], ["Asia", "China"], ["Asia", "India"], ["Asia", "Korea"],
];
type Sales = { region: string; country: string } & Record<string, number | string>;
type Metric = "revenue" | "growth";
// deterministic "random" values
const noise = (a: number, b: number) => Math.abs(Math.sin(a * 12.9898 + b * 78.233) * 43758.5453) % 1;
const rows: Array<Sales> = regions.map(([region, country], r) => {
const row: Sales = { region, country };
months.forEach((month, m) => {
const season = 1 + 0.35 * Math.sin(((m + r) / 12) * Math.PI * 2);
row[`revenue_${month}`] = Math.round((200 + noise(r, 0) * 800) * season * (0.8 + noise(r, m) * 0.4));
row[`growth_${month}`] = Math.round((noise(m, r) - 0.4) * 400) / 10;
});
return row;
});
// color scale: value between min and max => color
function scale(value: number, min: number, max: number, diverging: boolean) {
if (diverging) {
const t = Math.max(-1, Math.min(1, value / Math.max(Math.abs(min), Math.abs(max))));
return t >= 0
? `color-mix(in srgb, #2da44e ${Math.round(t * 70)}%, transparent)`
: `color-mix(in srgb, #cf222e ${Math.round(-t * 70)}%, transparent)`;
}
const t = (value - min) / (max - min || 1);
return `color-mix(in srgb, #0969da ${Math.round(8 + t * 72)}%, transparent)`;
}
const options: GridOptions<Sales> = {
rowHeight: 30,
selection: "Cell",
singleSelect: false,
defaultColumn: { sortable: true, resizeable: true },
};
export default function ShowcaseHeatmap() {
const [metric, setMetric] = useState<Metric>("revenue");
const columns = useMemo<Array<ColumnConfig<Sales>>>(() => {
const fields = months.map((month) => `${metric}_${month}`);
const values = rows.flatMap((row) => fields.map((field) => row[field] as number));
const min = Math.min(...values);
const max = Math.max(...values);
const diverging = metric === "growth";
return [
{ field: "region", text: "Region", width: 110, pinned: "left", groupable: true },
{ field: "country", text: "Country", width: 110, pinned: "left" },
...months.map(
(month): ColumnConfig<Sales> => ({
field: `${metric}_${month}`,
id: month,
text: month,
type: "number",
width: 72,
aggFunc: diverging ? "avg" : "sum",
valueFormatter: ({ value }) =>
value == null ? "" : diverging ? `${value > 0 ? "+" : ""}${value.toFixed(1)}` : Math.round(value).toLocaleString(),
// the background color is calculated from the value
cellStyle: ({ value }) => ({ backgroundColor: scale(value, min, max, diverging) }),
})
),
];
}, [metric]);
return (
<div className="demo">
<div className="demo-toolbar">
<label>
Metric{" "}
<select value={metric} onChange={(e) => setMetric(e.target.value as Metric)}>
<option value="revenue">Revenue (k€)</option>
<option value="growth">Growth (%)</option>
</select>
</label>
<span className="legend">
<span className={`legend__scale ${metric}`}></span> low → high
</span>
</div>
<Datagrid columns={columns} rows={rows} options={options} />
<style>{`
.legend {
display: inline-flex;
align-items: center;
gap: 8px;
}
.legend__scale {
display: inline-block;
width: 120px;
height: 12px;
border-radius: 6px;
background: linear-gradient(to right, rgba(9, 105, 218, 0.08), rgba(9, 105, 218, 0.8));
}
.legend__scale.growth {
background: linear-gradient(to right, rgba(207, 34, 46, 0.7), transparent, rgba(45, 164, 78, 0.7));
}
`}</style>
</div>
);
}