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:
parent
d95585cebf
commit
6b1c6068fa
5 changed files with 388 additions and 7 deletions
28
Resources/public/heatmap.css
Normal file
28
Resources/public/heatmap.css
Normal 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
|
|
@ -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
21
assets/src/types.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue