feat: d3 calendar heatmap with color scale, labels, and tooltips

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Christopher Mühl 2026-04-08 13:16:02 +02:00
parent d95585cebf
commit 6b1c6068fa
No known key found for this signature in database
GPG key ID: 925AC7D69955293F
5 changed files with 388 additions and 7 deletions

View file

@ -0,0 +1,28 @@
.heatmap-tooltip {
position: absolute;
padding: 6px 10px;
background: var(--tblr-bg-surface);
border: 1px solid var(--tblr-border-color);
border-radius: 4px;
font-size: 0.8125rem;
color: var(--tblr-body-color);
pointer-events: none;
z-index: 1000;
white-space: nowrap;
box-shadow: 0 2px 8px rgba(0,0,0,0.12);
}
.heatmap-cell {
rx: 2;
ry: 2;
}
.heatmap-empty {
fill: var(--tblr-bg-surface-secondary);
}
.heatmap-label {
fill: var(--tblr-body-color);
font-size: 10px;
font-family: var(--tblr-font-sans-serif);
}

File diff suppressed because one or more lines are too long

View file

@ -1,3 +1,206 @@
export function init(container: HTMLElement): void { import { select } from 'd3-selection';
console.log('Heatmap init', container); import { scaleQuantize } from 'd3-scale';
import { timeMonday, timeDay, timeMonth } from 'd3-time';
import { timeFormat } from 'd3-time-format';
import { max } from 'd3-array';
import type { DayEntry, HeatmapData, HeatmapConfig } from './types';
const DEFAULT_CONFIG: HeatmapConfig = {
cellSize: 13,
cellGap: 2,
marginTop: 20,
marginLeft: 30,
marginBottom: 4,
};
const FALLBACK_COLORS = ['#9be9a8', '#40c463', '#30a14e', '#216e39'];
const DAY_LABELS = ['Mon', '', 'Wed', '', 'Fri', '', ''];
const MONTH_FORMAT = timeFormat('%b');
const DATE_FORMAT = timeFormat('%Y-%m-%d');
const DISPLAY_FORMAT = timeFormat('%a, %b %-d, %Y');
interface DayCell {
date: Date;
dateStr: string;
entry: DayEntry | null;
week: number;
day: number;
}
function resolveColors(container: HTMLElement): string[] {
try {
const style = getComputedStyle(container);
const test = style.getPropertyValue('--tblr-bg-surface');
if (!test) return FALLBACK_COLORS;
return FALLBACK_COLORS; // Use hardcoded greens — Tabler doesn't expose a green scale via CSS vars
} catch {
return FALLBACK_COLORS;
}
}
function buildDateMap(days: DayEntry[]): Map<string, DayEntry> {
const map = new Map<string, DayEntry>();
for (const d of days) {
map.set(d.date, d);
}
return map;
}
function generateCells(
begin: Date,
end: Date,
dateMap: Map<string, DayEntry>,
): DayCell[] {
const firstMonday = timeMonday.floor(begin);
const cells: DayCell[] = [];
let current = new Date(begin);
while (current <= end) {
const dateStr = DATE_FORMAT(current);
const weeksSinceStart = timeMonday.count(firstMonday, current);
const dayOfWeek = (current.getDay() + 6) % 7; // Monday=0, Sunday=6
cells.push({
date: new Date(current),
dateStr,
entry: dateMap.get(dateStr) || null,
week: weeksSinceStart,
day: dayOfWeek,
});
current = timeDay.offset(current, 1);
}
return cells;
}
function createTooltip(): HTMLDivElement {
const tip = document.createElement('div');
tip.className = 'heatmap-tooltip';
tip.style.display = 'none';
return tip;
}
export function renderHeatmap(
container: HTMLElement,
data: HeatmapData,
config: HeatmapConfig = DEFAULT_CONFIG,
): void {
container.innerHTML = '';
if (!data.days || data.days.length === 0) {
const msg = document.createElement('div');
msg.textContent = 'No tracking data available';
msg.style.padding = '1rem';
msg.style.color = 'var(--tblr-secondary, #6c757d)';
container.appendChild(msg);
return;
}
const dateMap = buildDateMap(data.days);
const begin = new Date(data.range.begin);
const end = new Date(data.range.end);
const cells = generateCells(begin, end, dateMap);
const maxHours = max(data.days, (d) => d.hours) || 1;
const colors = resolveColors(container);
const colorScale = scaleQuantize<string>()
.domain([0, maxHours])
.range(colors);
const { cellSize, cellGap, marginTop, marginLeft, marginBottom } = config;
const step = cellSize + cellGap;
const numWeeks = (max(cells, (c) => c.week) ?? 0) + 1;
const svgWidth = marginLeft + numWeeks * step;
const svgHeight = marginTop + 7 * step + marginBottom;
const svg = select(container)
.append('svg')
.attr('width', svgWidth)
.attr('height', svgHeight)
.attr('class', 'heatmap-svg');
// Month labels
const months: { date: Date; week: number }[] = [];
const firstMonday = timeMonday.floor(begin);
timeMonth.range(timeMonth.ceil(begin), end).forEach((m) => {
months.push({
date: m,
week: timeMonday.count(firstMonday, m),
});
});
svg
.selectAll('.month-label')
.data(months)
.join('text')
.attr('class', 'heatmap-label month-label')
.attr('x', (d) => marginLeft + d.week * step)
.attr('y', marginTop - 6)
.text((d) => MONTH_FORMAT(d.date));
// Day labels (Mon, Wed, Fri)
svg
.selectAll('.day-label')
.data(DAY_LABELS)
.join('text')
.attr('class', 'heatmap-label day-label')
.attr('x', marginLeft - 6)
.attr('y', (_d, i) => marginTop + i * step + cellSize - 2)
.attr('text-anchor', 'end')
.text((d) => d);
// Tooltip
const tooltip = createTooltip();
container.appendChild(tooltip);
// Cells
svg
.selectAll('.heatmap-cell')
.data(cells)
.join('rect')
.attr('class', (d) =>
d.entry ? 'heatmap-cell' : 'heatmap-cell heatmap-empty',
)
.attr('x', (d) => marginLeft + d.week * step)
.attr('y', (d) => marginTop + d.day * step)
.attr('width', cellSize)
.attr('height', cellSize)
.attr('fill', (d) => (d.entry ? colorScale(d.entry.hours) : ''))
.on('mouseenter', function (event: MouseEvent, d: DayCell) {
const hours = d.entry ? d.entry.hours.toFixed(1) : '0.0';
const count = d.entry ? d.entry.count : 0;
tooltip.innerHTML = `<strong>${DISPLAY_FORMAT(d.date)}</strong><br>${hours}h (${count} entries)`;
tooltip.style.display = 'block';
const rect = (event.target as SVGRectElement).getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
tooltip.style.left = `${rect.left - containerRect.left + cellSize / 2}px`;
tooltip.style.top = `${rect.top - containerRect.top - 40}px`;
})
.on('mouseleave', function () {
tooltip.style.display = 'none';
});
}
export function init(container: HTMLElement): void {
const url = container.getAttribute('data-url');
if (!url) {
console.error('KimaiHeatmap: missing data-url attribute');
return;
}
fetch(url)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise<HeatmapData>;
})
.then((data) => {
renderHeatmap(container, data);
})
.catch((err) => {
console.error('KimaiHeatmap: failed to load data', err);
container.textContent = 'Failed to load heatmap data';
});
} }

21
assets/src/types.ts Normal file
View file

@ -0,0 +1,21 @@
export interface DayEntry {
date: string; // "YYYY-MM-DD"
hours: number;
count: number;
}
export interface HeatmapData {
days: DayEntry[];
range: {
begin: string;
end: string;
};
}
export interface HeatmapConfig {
cellSize: number;
cellGap: number;
marginTop: number;
marginLeft: number;
marginBottom: number;
}

View file

@ -1,7 +1,136 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect, beforeEach } from 'vitest';
import { renderHeatmap } from '../src/heatmap';
import type { HeatmapData } from '../src/types';
describe('heatmap', () => { function makeMockData(overrides: Partial<HeatmapData> = {}): HeatmapData {
it('placeholder', () => { return {
expect(true).toBe(true); days: [
{ date: '2025-01-06', hours: 2.5, count: 3 },
{ date: '2025-01-07', hours: 5.0, count: 5 },
{ date: '2025-01-08', hours: 1.0, count: 1 },
{ date: '2025-01-13', hours: 8.0, count: 4 },
{ date: '2025-01-20', hours: 3.5, count: 2 },
],
range: {
begin: '2025-01-01',
end: '2025-01-31',
},
...overrides,
};
}
describe('renderHeatmap', () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
});
it('renders an SVG element', () => {
renderHeatmap(container, makeMockData());
const svg = container.querySelector('svg');
expect(svg).not.toBeNull();
});
it('renders correct number of rect elements for date range', () => {
renderHeatmap(container, makeMockData());
const rects = container.querySelectorAll('rect.heatmap-cell');
// Jan 1 to Jan 31 = 31 days
expect(rects.length).toBe(31);
});
it('applies heatmap-empty class to cells with no data', () => {
renderHeatmap(container, makeMockData());
const emptyRects = container.querySelectorAll('rect.heatmap-empty');
// 31 days total, 5 with data = 26 empty
expect(emptyRects.length).toBe(26);
});
it('applies fill attribute to cells with data', () => {
renderHeatmap(container, makeMockData());
const allRects = container.querySelectorAll('rect.heatmap-cell:not(.heatmap-empty)');
expect(allRects.length).toBe(5);
allRects.forEach((rect) => {
expect(rect.getAttribute('fill')).toBeTruthy();
});
});
it('renders day labels (Mon, Wed, Fri)', () => {
renderHeatmap(container, makeMockData());
const dayLabels = container.querySelectorAll('text.day-label');
expect(dayLabels.length).toBe(7); // all 7 slots rendered
const texts = Array.from(dayLabels).map((el) => el.textContent);
expect(texts).toContain('Mon');
expect(texts).toContain('Wed');
expect(texts).toContain('Fri');
});
it('renders month labels', () => {
// Use a range spanning two months
const data = makeMockData({
range: { begin: '2025-01-01', end: '2025-02-28' },
days: [
{ date: '2025-01-15', hours: 3.0, count: 2 },
{ date: '2025-02-10', hours: 4.0, count: 1 },
],
});
renderHeatmap(container, data);
const monthLabels = container.querySelectorAll('text.month-label');
expect(monthLabels.length).toBeGreaterThan(0);
const texts = Array.from(monthLabels).map((el) => el.textContent);
expect(texts).toContain('Feb');
});
it('creates tooltip on mouseenter', () => {
renderHeatmap(container, makeMockData());
const rect = container.querySelector(
'rect.heatmap-cell:not(.heatmap-empty)',
);
expect(rect).not.toBeNull();
// Simulate mouseenter
const event = new MouseEvent('mouseenter', { bubbles: true });
// jsdom getBoundingClientRect returns zeros, which is fine for structure test
rect!.dispatchEvent(event);
const tooltip = container.querySelector('.heatmap-tooltip') as HTMLDivElement;
expect(tooltip).not.toBeNull();
expect(tooltip.style.display).toBe('block');
expect(tooltip.innerHTML).toContain('h');
expect(tooltip.innerHTML).toContain('entries');
});
it('hides tooltip on mouseleave', () => {
renderHeatmap(container, makeMockData());
const rect = container.querySelector(
'rect.heatmap-cell:not(.heatmap-empty)',
);
rect!.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
rect!.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true }));
const tooltip = container.querySelector('.heatmap-tooltip') as HTMLDivElement;
expect(tooltip.style.display).toBe('none');
});
it('handles empty days array gracefully', () => {
renderHeatmap(container, makeMockData({ days: [] }));
const svg = container.querySelector('svg');
expect(svg).toBeNull();
expect(container.textContent).toContain('No tracking data available');
});
it('only renders cells within the begin/end range', () => {
const data: HeatmapData = {
days: [
{ date: '2025-03-01', hours: 2.0, count: 1 },
{ date: '2025-03-05', hours: 4.0, count: 2 },
],
range: { begin: '2025-03-01', end: '2025-03-07' },
};
renderHeatmap(container, data);
const rects = container.querySelectorAll('rect.heatmap-cell');
expect(rects.length).toBe(7);
}); });
}); });