import React, { useMemo } from 'react'; interface CalendarHeatmapProps { data: Array<{ date: string; value: number }>; year: number; onDayClick?: (date: string) => void; } function getColor(value: number): string { if (value < 50) return '#10B981'; if (value < 100) return '#F59E0B'; if (value < 150) return '#F97316'; if (value < 200) return '#EF4444'; return '#7C3AED'; } function getLabel(value: number): string { if (value < 50) return '优'; if (value < 100) return '良'; if (value < 150) return '轻度'; if (value < 200) return '中度'; return '重度'; } const MONTH_NAMES = [ '1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月', ]; const DAY_NAMES = ['一', '二', '三', '四', '五', '六', '日']; export const CalendarHeatmap = React.memo(function CalendarHeatmap({ data, year, onDayClick, }: CalendarHeatmapProps) { const dataMap = useMemo(() => { const map = new Map(); for (const d of data) { map.set(d.date, d.value); } return map; }, [data]); const months = useMemo(() => { const result: Array<{ month: number; name: string; weeks: Array>; }> = []; for (let m = 0; m < 12; m++) { const daysInMonth = new Date(year, m + 1, 0).getDate(); const cells: Array<{ date: string; day: number; value: number | null }> = []; for (let d = 1; d <= daysInMonth; d++) { const dateObj = new Date(year, m, d); const dateStr = dateObj.toISOString().slice(0, 10); cells.push({ date: dateStr, day: d, value: dataMap.get(dateStr) ?? null, }); } // Calculate start day of week (1=Monday, 0=Sunday → JS getDay: 0=Sun) const firstDay = new Date(year, m, 1).getDay(); // Convert JS Sunday=0 to Monday=0 const startOffset = firstDay === 0 ? 6 : firstDay - 1; // Pad beginning with empty cells const padded: Array<{ date: string; day: number; value: number | null } | null> = []; for (let i = 0; i < startOffset; i++) { padded.push(null); } for (const cell of cells) { padded.push(cell); } // Split into weeks of 7 const weeks: Array> = []; for (let i = 0; i < padded.length; i += 7) { const week = padded.slice(i, i + 7).filter(Boolean) as Array<{ date: string; day: number; value: number | null; }>; if (week.length > 0) { weeks.push(week); } } result.push({ month: m, name: MONTH_NAMES[m], weeks }); } return result; }, [year, dataMap]); return (
{/* Legend */}
{[ { color: '#10B981', label: '优 <50' }, { color: '#F59E0B', label: '良 50-100' }, { color: '#F97316', label: '轻度 100-150' }, { color: '#EF4444', label: '中度 150-200' }, { color: '#7C3AED', label: '重度 ≥200' }, { color: '#E5E7EB', label: '无数据' }, ].map((item) => ( {item.label} ))}
{/* Calendar grid */}
{months.map((month) => (
{month.name}
{/* Day header row */}
{DAY_NAMES.map((d) => (
{d}
))}
{/* Weeks */} {month.weeks.map((week, wi) => (
{Array.from({ length: 7 }).map((_, di) => { // Match by day-of-week index const dayOfWeekMap = [1, 2, 3, 4, 5, 6, 0]; // Mon=1..Sun=0 const matchedCell = week.find( (c) => new Date(c.date).getDay() === dayOfWeekMap[di], ); if (!matchedCell) { return (
); } const bg = matchedCell.value === null ? '#E5E7EB' : getColor(matchedCell.value); return (
onDayClick?.(matchedCell.date)} className={`w-[14px] h-[14px] rounded-sm transition-transform hover:scale-125 ${ onDayClick ? 'cursor-pointer' : '' }`} style={{ backgroundColor: bg }} role={onDayClick ? 'button' : undefined} tabIndex={onDayClick ? 0 : undefined} onKeyDown={ onDayClick ? (e: React.KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onDayClick(matchedCell.date); } } : undefined } /> ); })}
))}
))}
); });