feat(06-02): create YearModeRenderer and rewrite heatmap.ts as orchestrator
This commit is contained in:
parent
7ee3f92b85
commit
aab3915681
4 changed files with 2583 additions and 324 deletions
File diff suppressed because one or more lines are too long
|
|
@ -1,305 +1,12 @@
|
|||
import { select } from 'd3-selection';
|
||||
import { scaleQuantize } from 'd3-scale';
|
||||
import { timeMonday, timeSunday, timeDay, timeMonth } from 'd3-time';
|
||||
import { timeFormat } from 'd3-time-format';
|
||||
import { max } from 'd3-array';
|
||||
import type { DayEntry, HeatmapData, HeatmapConfig, ProjectOption } from './types';
|
||||
import type { HeatmapData, ProjectOption } from './types';
|
||||
import { createInitialState } from './state';
|
||||
import type { HeatmapState } from './state';
|
||||
import { getRenderer, registerRenderer } from './renderers/registry';
|
||||
import { YearModeRenderer } from './renderers/year';
|
||||
import { renderStats } from './shared/stats';
|
||||
|
||||
const DEFAULT_CONFIG: HeatmapConfig = {
|
||||
cellSize: 13,
|
||||
cellGap: 2,
|
||||
marginTop: 20,
|
||||
marginLeft: 30,
|
||||
marginBottom: 4,
|
||||
};
|
||||
|
||||
const FALLBACK_COLORS = ['#9be9a8', '#40c463', '#30a14e', '#216e39'];
|
||||
const DAY_LABELS_MONDAY = ['Mon', '', 'Wed', '', 'Fri', '', ''];
|
||||
const DAY_LABELS_SUNDAY = ['Sun', '', 'Tue', '', 'Thu', '', 'Sat'];
|
||||
|
||||
function getDayLabels(weekStart: string): string[] {
|
||||
return weekStart === 'sunday' ? DAY_LABELS_SUNDAY : DAY_LABELS_MONDAY;
|
||||
}
|
||||
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;
|
||||
isWeekend: boolean;
|
||||
}
|
||||
|
||||
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 getWeekInterval(weekStart: string) {
|
||||
return weekStart === 'sunday' ? timeSunday : timeMonday;
|
||||
}
|
||||
|
||||
function generateCells(
|
||||
begin: Date,
|
||||
end: Date,
|
||||
dateMap: Map<string, DayEntry>,
|
||||
weekStart: string = 'monday',
|
||||
): DayCell[] {
|
||||
const weekInterval = getWeekInterval(weekStart);
|
||||
const firstWeekDay = weekInterval.floor(begin);
|
||||
const cells: DayCell[] = [];
|
||||
let current = new Date(begin);
|
||||
|
||||
while (current <= end) {
|
||||
const dateStr = DATE_FORMAT(current);
|
||||
const weeksSinceStart = weekInterval.count(firstWeekDay, current);
|
||||
const jsDay = current.getDay(); // 0=Sunday, 6=Saturday
|
||||
const dayOfWeek = weekStart === 'sunday'
|
||||
? jsDay // Sunday=0 already first
|
||||
: (jsDay + 6) % 7; // Monday=0, Sunday=6
|
||||
const isWeekend = jsDay === 0 || jsDay === 6;
|
||||
|
||||
cells.push({
|
||||
date: new Date(current),
|
||||
dateStr,
|
||||
entry: dateMap.get(dateStr) || null,
|
||||
week: weeksSinceStart,
|
||||
day: dayOfWeek,
|
||||
isWeekend,
|
||||
});
|
||||
|
||||
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 calculateStreak(days: DayEntry[]): number {
|
||||
if (days.length === 0) return 0;
|
||||
|
||||
const tracked = new Set(
|
||||
days.filter((d) => d.hours > 0).map((d) => d.date),
|
||||
);
|
||||
if (tracked.size === 0) return 0;
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
let current = new Date(today);
|
||||
|
||||
// If today has no entry, start from yesterday
|
||||
if (!tracked.has(DATE_FORMAT(current))) {
|
||||
current = timeDay.offset(current, -1);
|
||||
}
|
||||
|
||||
let streak = 0;
|
||||
while (tracked.has(DATE_FORMAT(current))) {
|
||||
streak++;
|
||||
current = timeDay.offset(current, -1);
|
||||
}
|
||||
|
||||
return streak;
|
||||
}
|
||||
|
||||
export interface HeatmapStats {
|
||||
totalHours: number;
|
||||
avgHours: number;
|
||||
busiestDay: { date: string; hours: number } | null;
|
||||
}
|
||||
|
||||
export function calculateStats(days: DayEntry[]): HeatmapStats {
|
||||
const withEntries = days.filter((d) => d.hours > 0);
|
||||
if (withEntries.length === 0) {
|
||||
return { totalHours: 0, avgHours: 0, busiestDay: null };
|
||||
}
|
||||
|
||||
const totalHours = Math.round(withEntries.reduce((sum, d) => sum + d.hours, 0) * 10) / 10;
|
||||
const avgHours = Math.round((totalHours / withEntries.length) * 10) / 10;
|
||||
const busiest = withEntries.reduce((best, d) => (d.hours > best.hours ? d : best));
|
||||
|
||||
return {
|
||||
totalHours,
|
||||
avgHours,
|
||||
busiestDay: { date: busiest.date, hours: busiest.hours },
|
||||
};
|
||||
}
|
||||
|
||||
function renderStats(container: HTMLElement, days: DayEntry[]): void {
|
||||
// Remove existing stats
|
||||
const existing = container.querySelector('.heatmap-stats');
|
||||
if (existing) existing.remove();
|
||||
|
||||
const streak = calculateStreak(days);
|
||||
const stats = calculateStats(days);
|
||||
|
||||
const statsDiv = document.createElement('div');
|
||||
statsDiv.className = 'heatmap-stats';
|
||||
|
||||
const parts: string[] = [];
|
||||
parts.push(`<span>\u{1F525} <span class="stat-value">${streak}</span> days</span>`);
|
||||
parts.push(`<span>Total: <span class="stat-value">${stats.totalHours}h</span></span>`);
|
||||
parts.push(`<span>Avg: <span class="stat-value">${stats.avgHours}h/day</span></span>`);
|
||||
|
||||
if (stats.busiestDay) {
|
||||
const d = new Date(stats.busiestDay.date + 'T00:00:00');
|
||||
const label = DISPLAY_FORMAT(d);
|
||||
parts.push(`<span>Busiest: <span class="stat-value">${label} \u2014 ${stats.busiestDay.hours.toFixed(1)}h</span></span>`);
|
||||
}
|
||||
|
||||
statsDiv.innerHTML = parts.join('');
|
||||
container.appendChild(statsDiv);
|
||||
}
|
||||
|
||||
export function renderHeatmap(
|
||||
container: HTMLElement,
|
||||
data: HeatmapData,
|
||||
config: HeatmapConfig = DEFAULT_CONFIG,
|
||||
onCellClick?: (dateStr: string) => void,
|
||||
emptyMessage?: string,
|
||||
weekStart: string = 'monday',
|
||||
): void {
|
||||
container.innerHTML = '';
|
||||
|
||||
if (!data.days || data.days.length === 0) {
|
||||
const msg = document.createElement('div');
|
||||
msg.textContent = emptyMessage || '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, weekStart);
|
||||
|
||||
const maxHours = max(data.days, (d) => d.hours) || 1;
|
||||
const colors = resolveColors(container);
|
||||
|
||||
const colorScale = scaleQuantize<string>()
|
||||
.domain([0, maxHours])
|
||||
.range(colors);
|
||||
|
||||
const { cellGap, marginTop, marginLeft, marginBottom } = config;
|
||||
const numWeeks = (max(cells, (c) => c.week) ?? 0) + 1;
|
||||
|
||||
// Compute cell size to fill available width, capped at 16px
|
||||
const containerWidth = container.clientWidth || 800;
|
||||
const maxCellSize = 22;
|
||||
const minCellSize = 10;
|
||||
const cellSize = Math.min(maxCellSize, Math.max(minCellSize, Math.floor((containerWidth - marginLeft) / numWeeks) - cellGap));
|
||||
const step = cellSize + cellGap;
|
||||
const svgWidth = marginLeft + numWeeks * step;
|
||||
const svgHeight = marginTop + 7 * step + marginBottom;
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.style.maxWidth = `${svgWidth}px`;
|
||||
wrapper.style.margin = '0 auto';
|
||||
container.appendChild(wrapper);
|
||||
|
||||
const svg = select(wrapper)
|
||||
.append('svg')
|
||||
.attr('width', svgWidth)
|
||||
.attr('height', svgHeight)
|
||||
.attr('class', 'heatmap-svg');
|
||||
|
||||
// Month labels
|
||||
const weekInterval = getWeekInterval(weekStart);
|
||||
const months: { date: Date; week: number }[] = [];
|
||||
const firstWeekDay = weekInterval.floor(begin);
|
||||
timeMonth.range(timeMonth.ceil(begin), end).forEach((m) => {
|
||||
months.push({
|
||||
date: m,
|
||||
week: weekInterval.count(firstWeekDay, 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
|
||||
svg
|
||||
.selectAll('.day-label')
|
||||
.data(getDayLabels(weekStart))
|
||||
.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 (fixed positioning to escape overflow clipping)
|
||||
// Remove any stale tooltip from previous renders
|
||||
document.querySelectorAll('.heatmap-tooltip').forEach(el => el.remove());
|
||||
const tooltip = createTooltip();
|
||||
tooltip.style.position = 'fixed';
|
||||
document.body.appendChild(tooltip);
|
||||
|
||||
// Cells
|
||||
svg
|
||||
.selectAll('.heatmap-cell')
|
||||
.data(cells)
|
||||
.join('rect')
|
||||
.attr('class', (d) => {
|
||||
let cls = 'heatmap-cell';
|
||||
if (!d.entry) cls += ' heatmap-empty';
|
||||
if (d.isWeekend) cls += ' heatmap-weekend';
|
||||
return cls;
|
||||
})
|
||||
.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();
|
||||
tooltip.style.left = `${rect.left + cellSize / 2}px`;
|
||||
tooltip.style.top = `${rect.top - tooltip.offsetHeight - 8}px`;
|
||||
})
|
||||
.on('mouseleave', function () {
|
||||
tooltip.style.display = 'none';
|
||||
})
|
||||
.on('click', function (_event: MouseEvent, d: DayCell) {
|
||||
if (!onCellClick) return;
|
||||
onCellClick(d.dateStr);
|
||||
});
|
||||
|
||||
}
|
||||
// Register built-in renderers
|
||||
registerRenderer(new YearModeRenderer());
|
||||
|
||||
export function init(container: HTMLElement): void {
|
||||
const baseUrl = container.getAttribute('data-url');
|
||||
|
|
@ -313,13 +20,13 @@ export function init(container: HTMLElement): void {
|
|||
const projectsJson = container.getAttribute('data-projects');
|
||||
const projects: ProjectOption[] = projectsJson ? JSON.parse(projectsJson) : [];
|
||||
|
||||
let activeProjectId: number | null = null;
|
||||
const state: HeatmapState = createInitialState(weekStart);
|
||||
|
||||
const onCellClick = (dateStr: string): void => {
|
||||
const daterange = `${dateStr} - ${dateStr}`;
|
||||
let url = `${timesheetUrl}?daterange=${encodeURIComponent(daterange)}`;
|
||||
if (activeProjectId) {
|
||||
url += `&projects[]=${activeProjectId}`;
|
||||
if (state.filters.projectId) {
|
||||
url += `&projects[]=${state.filters.projectId}`;
|
||||
}
|
||||
window.location.href = url;
|
||||
};
|
||||
|
|
@ -333,13 +40,19 @@ export function init(container: HTMLElement): void {
|
|||
svgArea.className = 'heatmap-svg-area';
|
||||
wrapper.appendChild(svgArea);
|
||||
|
||||
// Shared state for current data (used by resize re-render and filter)
|
||||
let currentData: HeatmapData | null = null;
|
||||
|
||||
const doRender = (data: HeatmapData, emptyMsg?: string) => {
|
||||
currentData = data;
|
||||
renderHeatmap(svgArea, data, DEFAULT_CONFIG, onCellClick, emptyMsg, weekStart);
|
||||
renderStats(container, data.days);
|
||||
const doRender = () => {
|
||||
if (!state.data) return;
|
||||
const renderer = getRenderer(state.mode);
|
||||
renderer.destroy?.();
|
||||
renderer.render({
|
||||
container: svgArea,
|
||||
data: state.data,
|
||||
state,
|
||||
config: { cellSize: 13, cellGap: 2, marginTop: 20, marginLeft: 30, marginBottom: 4 },
|
||||
onCellClick,
|
||||
emptyMessage: state.filters.projectId ? 'No tracking data for this project' : undefined,
|
||||
});
|
||||
renderStats(container, state.data.days);
|
||||
svgArea.scrollLeft = svgArea.scrollWidth;
|
||||
};
|
||||
|
||||
|
|
@ -348,25 +61,25 @@ export function init(container: HTMLElement): void {
|
|||
const filterDiv = document.createElement('div');
|
||||
filterDiv.className = 'heatmap-filter';
|
||||
|
||||
const select = document.createElement('select');
|
||||
select.className = 'form-select form-select-sm';
|
||||
select.setAttribute('aria-label', 'Filter by project');
|
||||
const selectEl = document.createElement('select');
|
||||
selectEl.className = 'form-select form-select-sm';
|
||||
selectEl.setAttribute('aria-label', 'Filter by project');
|
||||
|
||||
const defaultOpt = document.createElement('option');
|
||||
defaultOpt.value = '';
|
||||
defaultOpt.textContent = 'All Projects';
|
||||
select.appendChild(defaultOpt);
|
||||
selectEl.appendChild(defaultOpt);
|
||||
|
||||
for (const p of projects) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = String(p.id);
|
||||
opt.textContent = p.name;
|
||||
select.appendChild(opt);
|
||||
selectEl.appendChild(opt);
|
||||
}
|
||||
|
||||
select.addEventListener('change', () => {
|
||||
const val = select.value;
|
||||
activeProjectId = val ? parseInt(val, 10) : null;
|
||||
selectEl.addEventListener('change', () => {
|
||||
const val = selectEl.value;
|
||||
state.filters.projectId = val ? parseInt(val, 10) : null;
|
||||
const fetchUrl = val ? `${baseUrl}?project=${val}` : baseUrl;
|
||||
|
||||
fetch(fetchUrl)
|
||||
|
|
@ -375,14 +88,15 @@ export function init(container: HTMLElement): void {
|
|||
return res.json() as Promise<HeatmapData>;
|
||||
})
|
||||
.then(data => {
|
||||
doRender(data, 'No tracking data for this project');
|
||||
state.data = data;
|
||||
doRender();
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('KimaiHeatmap: failed to load filtered data', err);
|
||||
});
|
||||
});
|
||||
|
||||
filterDiv.appendChild(select);
|
||||
filterDiv.appendChild(selectEl);
|
||||
wrapper.appendChild(filterDiv);
|
||||
}
|
||||
|
||||
|
|
@ -393,7 +107,7 @@ export function init(container: HTMLElement): void {
|
|||
window.addEventListener('resize', () => {
|
||||
clearTimeout(resizeTimer);
|
||||
resizeTimer = setTimeout(() => {
|
||||
if (currentData) doRender(currentData);
|
||||
if (state.data) doRender();
|
||||
}, 200);
|
||||
});
|
||||
|
||||
|
|
@ -404,7 +118,8 @@ export function init(container: HTMLElement): void {
|
|||
return res.json() as Promise<HeatmapData>;
|
||||
})
|
||||
.then(data => {
|
||||
doRender(data);
|
||||
state.data = data;
|
||||
doRender();
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('KimaiHeatmap: failed to load data', err);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ export interface RenderContext {
|
|||
state: HeatmapState;
|
||||
config: HeatmapConfig;
|
||||
onCellClick?: (dateStr: string) => void;
|
||||
emptyMessage?: string;
|
||||
}
|
||||
|
||||
export interface ModeRenderer {
|
||||
|
|
|
|||
131
assets/src/renderers/year.ts
Normal file
131
assets/src/renderers/year.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import { select } from 'd3-selection';
|
||||
import { timeMonth } from 'd3-time';
|
||||
import { max } from 'd3-array';
|
||||
import type { ModeRenderer, RenderContext } from './types';
|
||||
import { createTooltip, showTooltip, hideTooltip } from '../shared/tooltip';
|
||||
import { buildColorScale } from '../shared/color-scale';
|
||||
import {
|
||||
buildDateMap, generateCells, getWeekInterval, getDayLabels,
|
||||
MONTH_FORMAT, DISPLAY_FORMAT, type DayCell,
|
||||
} from '../shared/date-utils';
|
||||
|
||||
export class YearModeRenderer implements ModeRenderer {
|
||||
readonly mode = 'year';
|
||||
private tooltip: HTMLDivElement | null = null;
|
||||
|
||||
render(ctx: RenderContext): void {
|
||||
ctx.container.innerHTML = '';
|
||||
|
||||
// Handle empty data
|
||||
if (!ctx.data.days || ctx.data.days.length === 0) {
|
||||
const msg = document.createElement('div');
|
||||
msg.textContent = ctx.emptyMessage || 'No tracking data available';
|
||||
msg.style.padding = '1rem';
|
||||
msg.style.color = 'var(--tblr-secondary, #6c757d)';
|
||||
ctx.container.appendChild(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
// Destroy previous tooltip
|
||||
this.destroy();
|
||||
this.tooltip = createTooltip();
|
||||
|
||||
const weekStart = ctx.state.weekStart;
|
||||
const dateMap = buildDateMap(ctx.data.days);
|
||||
const begin = new Date(ctx.data.range.begin);
|
||||
const end = new Date(ctx.data.range.end);
|
||||
const cells = generateCells(begin, end, dateMap, weekStart);
|
||||
|
||||
// Build color scale using state metric (hours vs count)
|
||||
const colorScale = buildColorScale(ctx.data.days, ctx.state.metric);
|
||||
|
||||
const { cellGap, marginTop, marginLeft, marginBottom } = ctx.config;
|
||||
const numWeeks = (max(cells, (c) => c.week) ?? 0) + 1;
|
||||
|
||||
// Compute cell size to fill available width, capped
|
||||
const containerWidth = ctx.container.clientWidth || 800;
|
||||
const maxCellSize = 22;
|
||||
const minCellSize = 10;
|
||||
const cellSize = Math.min(maxCellSize, Math.max(minCellSize, Math.floor((containerWidth - marginLeft) / numWeeks) - cellGap));
|
||||
const step = cellSize + cellGap;
|
||||
const svgWidth = marginLeft + numWeeks * step;
|
||||
const svgHeight = marginTop + 7 * step + marginBottom;
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.style.maxWidth = `${svgWidth}px`;
|
||||
wrapper.style.margin = '0 auto';
|
||||
ctx.container.appendChild(wrapper);
|
||||
|
||||
const svg = select(wrapper)
|
||||
.append('svg')
|
||||
.attr('width', svgWidth)
|
||||
.attr('height', svgHeight)
|
||||
.attr('class', 'heatmap-svg');
|
||||
|
||||
// Month labels
|
||||
const weekInterval = getWeekInterval(weekStart);
|
||||
const months: { date: Date; week: number }[] = [];
|
||||
const firstWeekDay = weekInterval.floor(begin);
|
||||
timeMonth.range(timeMonth.ceil(begin), end).forEach((m) => {
|
||||
months.push({ date: m, week: weekInterval.count(firstWeekDay, 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
|
||||
svg.selectAll('.day-label')
|
||||
.data(getDayLabels(weekStart))
|
||||
.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);
|
||||
|
||||
// Cells with tooltip and click
|
||||
const tooltip = this.tooltip;
|
||||
svg.selectAll('.heatmap-cell')
|
||||
.data(cells)
|
||||
.join('rect')
|
||||
.attr('class', (d) => {
|
||||
let cls = 'heatmap-cell';
|
||||
if (!d.entry) cls += ' heatmap-empty';
|
||||
if (d.isWeekend) cls += ' heatmap-weekend';
|
||||
return cls;
|
||||
})
|
||||
.attr('x', (d) => marginLeft + d.week * step)
|
||||
.attr('y', (d) => marginTop + d.day * step)
|
||||
.attr('width', cellSize)
|
||||
.attr('height', cellSize)
|
||||
.attr('fill', (d) => {
|
||||
if (!d.entry) return '';
|
||||
const val = ctx.state.metric === 'hours' ? d.entry.hours : d.entry.count;
|
||||
return colorScale(val);
|
||||
})
|
||||
.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;
|
||||
const html = `<strong>${DISPLAY_FORMAT(d.date)}</strong><br>${hours}h (${count} entries)`;
|
||||
const rect = (event.target as SVGRectElement).getBoundingClientRect();
|
||||
showTooltip(tooltip, html, rect, cellSize);
|
||||
})
|
||||
.on('mouseleave', function () {
|
||||
hideTooltip(tooltip);
|
||||
})
|
||||
.on('click', function (_event: MouseEvent, d: DayCell) {
|
||||
if (!ctx.onCellClick) return;
|
||||
ctx.onCellClick(d.dateStr);
|
||||
});
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.tooltip?.remove();
|
||||
this.tooltip = null;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue