Skip to content

Sales heatmap ​

Monthly revenue and growth per country, colored by value.

How it works ​

  • Color scale: cellStyle calculates the background color from the value with color-mix(): a sequential blue scale for revenue and a diverging red/green scale for growth.
  • Switching data: the columns are a computed which 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>
  );
}

Released under the ISC License.