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 {
|
||||
console.log('Heatmap init', container);
|
||||
import { select } from 'd3-selection';
|
||||
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', () => {
|
||||
it('placeholder', () => {
|
||||
expect(true).toBe(true);
|
||||
function makeMockData(overrides: Partial<HeatmapData> = {}): HeatmapData {
|
||||
return {
|
||||
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