js • Lines: 687'use client';
import { useState, useEffect, forwardRef, Fragment, Suspense, lazy } from 'react';
import { eachDayOfInterval, formatISO, isValid, parseISO, getDay, subWeeks, nextDay, differenceInCalendarDays, getMonth, getYear } from 'date-fns';
import { jsxs, jsx } from 'react/jsx-runtime';
const NAMESPACE = 'react-activity-calendar';
const LABEL_MARGIN = 8; // px
const DEFAULT_MONTH_LABELS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const DEFAULT_LABELS = {
months: DEFAULT_MONTH_LABELS,
weekdays: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
totalCount: '{{count}} activities in {{year}}',
legend: {
less: 'Less',
more: 'More'
}
};
const query$1 = '(prefers-color-scheme: dark)';
function useColorScheme() {
const [colorScheme, setColorScheme] = useState(() => typeof window === 'undefined' ? 'light' : window.matchMedia(query$1).matches ? 'dark' : 'light');
const onChange = event => {
setColorScheme(event.matches ? 'dark' : 'light');
};
useEffect(() => {
const mediaQuery = window.matchMedia(query$1);
// eslint-disable-next-line react-hooks/set-state-in-effect
setColorScheme(mediaQuery.matches ? 'dark' : 'light');
mediaQuery.addEventListener('change', onChange);
return () => {
mediaQuery.removeEventListener('change', onChange);
};
}, []);
return colorScheme;
}
const loadingAnimationName = `${NAMESPACE}--loading-animation`;
function useLoadingAnimation(zeroColor, colorScheme) {
const [loaded, setLoaded] = useState(false);
useEffect(() => {
const colorLoading = `oklab(from ${zeroColor} l a b)`;
const colorActive = colorScheme === 'light' ? `oklab(from ${zeroColor} calc(l * 0.96) a b)` : `oklab(from ${zeroColor} calc(l * 1.08) a b)`;
const style = document.createElement('style');
style.innerHTML = `
@keyframes ${loadingAnimationName} {
0% {
fill: ${colorLoading};
}
50% {
fill: ${colorActive};
}
100% {
fill: ${colorLoading};
}
}
`;
const handleLoad = () => {
setLoaded(true);
};
document.head.appendChild(style);
style.addEventListener('load', handleLoad);
return () => {
document.head.removeChild(style);
style.removeEventListener('load', handleLoad);
setLoaded(false);
};
}, [zeroColor, colorScheme]);
return loaded;
}
const query = '(prefers-reduced-motion: reduce)';
function usePrefersReducedMotion() {
const [prefersReducedMotion, setPrefersReducedMotion] = useState(() => typeof window === 'undefined' ? true : window.matchMedia(query).matches);
useEffect(() => {
const mediaQuery = window.matchMedia(query);
// eslint-disable-next-line react-hooks/set-state-in-effect
setPrefersReducedMotion(mediaQuery.matches);
const onChange = event => {
setPrefersReducedMotion(event.matches);
};
mediaQuery.addEventListener('change', onChange);
return () => {
mediaQuery.removeEventListener('change', onChange);
};
}, []);
return prefersReducedMotion;
}
function validateActivities(activities, {
minLevel,
maxLevel
}) {
if (activities.length === 0) {
throw new Error('Activity data must not be empty.');
}
for (const {
date,
level
} of activities) {
if (!isValid(parseISO(date))) {
throw new Error(`Activity date '${date}' is not a valid ISO 8601 date string.`);
}
if (level < minLevel || level > maxLevel) {
throw new RangeError(`Activity level ${level} for ${date} is out of range. It must be between ${minLevel} and ${maxLevel}.`);
}
}
}
function groupByWeeks(activities, weekStart = 0 // 0 = Sunday
) {
const normalizedActivities = fillHoles(activities);
// Determine the first date of the calendar. If the first date is not the
// passed weekday, the respective weekday one week earlier is used.
const firstActivity = normalizedActivities[0];
const firstDate = parseISO(firstActivity.date);
const firstCalendarDate = getDay(firstDate) === weekStart ? firstDate : subWeeks(nextDay(firstDate, weekStart), 1);
// To correctly group activities by week, it is necessary to left-pad the list
// because the first date might not be the set start weekday.
const paddedActivities = [...Array(differenceInCalendarDays(firstDate, firstCalendarDate)).fill(undefined), ...normalizedActivities];
const numberOfWeeks = Math.ceil(paddedActivities.length / 7);
// Finally, group activities by week
return range(numberOfWeeks).map(weekIndex => paddedActivities.slice(weekIndex * 7, weekIndex * 7 + 7));
}
/**
* The calendar expects a continuous sequence of days,
* so fill gaps with empty activity data.
*/
function fillHoles(activities) {
const calendar = new Map(activities.map(a => [a.date, a]));
const firstActivity = activities[0];
const lastActivity = activities[activities.length - 1];
return eachDayOfInterval({
start: parseISO(firstActivity.date),
end: parseISO(lastActivity.date)
}).map(day => {
const date = formatISO(day, {
representation: 'date'
});
if (calendar.has(date)) {
return calendar.get(date);
}
return {
date,
count: 0,
level: 0
};
});
}
/**
* Following the BEM (block, element, modifier) naming convention
* https://getbem.com/naming/
*/
function getClassName(element) {
return `${NAMESPACE}__${element}`;
}
function generateEmptyData() {
const year = new Date().getFullYear();
const days = eachDayOfInterval({
start: new Date(year, 0, 1),
end: new Date(year, 11, 31)
});
return days.map(date => ({
date: formatISO(date, {
representation: 'date'
}),
count: 0,
level: 0
}));
}
/**
* Returns the sequence of numbers in range.
* @example
* range(3) -> [0, 1, 2]
* range(2, 5) -> [2, 3, 4]
*/
function range(fromArg, toArg) {
const from = toArg === undefined ? 0 : fromArg;
const to = toArg ?? fromArg;
if (to <= from) {
throw new RangeError('Invalid range: to must be greater than from');
}
return Array.from({
length: to - from
}, (_, i) => from + i);
}
function getMonthLabels(weeks, monthNames = DEFAULT_MONTH_LABELS) {
return weeks.reduce((labels, week, weekIndex) => {
const firstActivity = week.find(activity => activity !== undefined);
if (!firstActivity) {
throw new Error(`Unexpected error: Week ${weekIndex + 1} is empty.`);
}
const month = monthNames[getMonth(parseISO(firstActivity.date))];
if (!month) {
const monthName = new Date(firstActivity.date).toLocaleString('en-US', {
month: 'short'
});
throw new Error(`Unexpected error: undefined month label for ${monthName}.`);
}
const prevLabel = labels[labels.length - 1];
if (weekIndex === 0 || prevLabel?.label !== month) {
return [...labels, {
weekIndex,
label: month
}];
}
return labels;
}, []).filter(({
weekIndex
}, index, labels) => {
// Labels should only be shown if there is "enough" space (data).
// This is a naive implementation that does not take the block size,
// font size, etc. into account.
const minWeeks = 3;
// Skip the first month label if there is not enough space for the next one.
if (index === 0) {
return labels[1] && labels[1].weekIndex - weekIndex >= minWeeks;
}
// Skip the last month label if there is not enough data in that month
// to avoid overflowing the calendar on the right.
if (index === labels.length - 1) {
return weeks.slice(weekIndex).length >= minWeeks;
}
return true;
});
}
function maxWeekdayLabelWidth(labels, showWeekdayLabel, fontSize) {
if (labels.length !== 7) {
throw new Error('Exactly 7 labels, one for each weekday must be passed.');
}
return labels.reduce((maxWidth, label, index) => showWeekdayLabel.byDayIndex(index) ? Math.max(maxWidth, Math.ceil(calcTextDimensions(label, fontSize).width)) : maxWidth, 0);
}
function calcTextDimensions(text, fontSize) {
if (typeof document === 'undefined' || typeof window === 'undefined') {
return {
width: 0,
height: 0
};
}
if (fontSize < 1) {
throw new RangeError('fontSize must be positive');
}
if (text.length === 0) {
return {
width: 0,
height: 0
};
}
const namespace = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(namespace, 'svg');
svg.style.position = 'absolute';
svg.style.visibility = 'hidden';
svg.style.fontFamily = window.getComputedStyle(document.body).fontFamily;
svg.style.fontSize = `${fontSize}px`;
const textNode = document.createElementNS(namespace, 'text');
textNode.textContent = text;
svg.appendChild(textNode);
document.body.appendChild(svg);
const boundingBox = textNode.getBBox();
document.body.removeChild(svg);
return {
width: boundingBox.width,
height: boundingBox.height
};
}
function initWeekdayLabels(input, weekStart) {
if (!input) return {
byDayIndex: () => false,
shouldShow: false
};
// Default: Show every second day of the week.
if (input === true) {
return {
byDayIndex: index => {
return (7 + index - weekStart) % 7 % 2 !== 0;
},
shouldShow: true
};
}
const indexed = [];
for (const name of input) {
const index = dayNameToIndex[name.toLowerCase()];
indexed[index] = true;
}
return {
byDayIndex: index => indexed[index] ?? false,
shouldShow: input.length > 0
};
}
const dayNameToIndex = {
sun: 0,
mon: 1,
tue: 2,
wed: 3,
thu: 4,
fri: 5,
sat: 6
};
function createTheme(input, levels = {
minLevel: 0,
maxLevel: 4
}) {
const defaultTheme = createDefaultTheme(levels);
if (input) {
const numberOfLevels = levels.maxLevel - levels.minLevel + 1;
validateThemeInput(input, numberOfLevels);
input.light = input.light ?? defaultTheme.light;
input.dark = input.dark ?? defaultTheme.dark;
return {
light: isPair(input.light) ? calcColorScale([input.light[1], input.light[0], input.light[1]], levels) : isTriple(input.light) ? calcColorScale(input.light, levels) : input.light,
dark: isPair(input.dark) ? calcColorScale([input.dark[1], input.dark[0], input.dark[1]], levels) : isTriple(input.dark) ? calcColorScale(input.dark, levels) : input.dark
};
}
return defaultTheme;
}
function createDefaultTheme(levels) {
return {
light: calcColorScale(['hsl(0, 0%, 26%)', 'hsl(0, 0%, 92%)', 'hsl(0, 0%, 26%)'], levels),
dark: calcColorScale(['hsl(0, 0%, 92%)', 'hsl(0, 0%, 22%)', 'hsl(0, 0%, 92%)'], levels)
};
}
function validateThemeInput(input, numberOfLevels) {
const levelsHint = 'The number of colors must match the number of activity levels controlled by the `minLevel` and `maxLevel` properties.';
if (typeof input !== 'object' || input.light === undefined && input.dark === undefined) {
throw new Error(`The theme object must contain at least one of the fields "light" and "dark" with exactly 2 or ${numberOfLevels} colors respectively. ${levelsHint}`);
}
if (input.light) {
const {
length
} = input.light;
if (length !== 2 && length !== 3 && length !== numberOfLevels) {
throw new Error(`theme.light must contain exactly 2 or 3 or ${numberOfLevels} colors, ${length} passed. ${levelsHint}`);
}
for (const c of input.light) {
if (typeof window !== 'undefined' && !CSS.supports('color', c)) {
throw new Error(`Invalid color "${c}" passed. All CSS color formats are accepted.`);
}
}
}
if (input.dark) {
const {
length
} = input.dark;
if (length !== 2 && length !== 3 && length !== numberOfLevels) {
throw new Error(`theme.dark must contain exactly 2 or 3 or ${numberOfLevels} colors, ${length} passed. ${levelsHint}`);
}
for (const c of input.dark) {
if (typeof window !== 'undefined' && !CSS.supports('color', c)) {
throw new Error(`Invalid color "${c}" passed. All CSS color formats are accepted.`);
}
}
}
}
function calcColorScale([colorNeg, colorZero, colorPos], {
minLevel,
maxLevel
}) {
return range(minLevel, maxLevel + 1).map(i => {
if (i < 0) {
if (i === minLevel) {
return colorNeg;
}
const pos = (1 - i / minLevel) * 100;
return `color-mix(in oklab, ${colorZero} ${parseFloat(pos.toFixed(2))}%, ${colorNeg})`;
}
if (i === 0) {
return colorZero;
}
// i > 0
if (i === maxLevel) {
return colorPos;
}
const pos = i / maxLevel * 100;
return `color-mix(in oklab, ${colorPos} ${parseFloat(pos.toFixed(2))}%, ${colorZero})`;
});
}
function isPair(val) {
return val.length === 2;
}
function isTriple(val) {
return val.length === 3;
}
const styles = {
container: fontSize => ({
width: 'max-content',
// Calendar should not grow
maxWidth: '100%',
// Do not remove - parent might be a flexbox
display: 'flex',
flexDirection: 'column',
gap: '8px',
fontSize: `${fontSize}px`
}),
scrollContainer: fontSize => ({
maxWidth: '100%',
overflowX: 'auto',
overflowY: 'hidden',
paddingTop: Math.ceil(0.1 * fontSize) // SVG <text> overflows in Firefox at y=0
}),
calendar: {
display: 'block',
// SVGs are inline-block by default
overflow: 'visible' // Weekday labels are rendered left of the container
},
rect: colorScheme => ({
stroke: colorScheme === 'light' ? 'rgba(0, 0, 0, 0.08)' : 'rgba(255, 255, 255, 0.04)'
}),
footer: {
container: {
display: 'flex',
flexWrap: 'wrap',
gap: '4px 16px',
whiteSpace: 'nowrap'
},
legend: {
marginLeft: 'auto',
display: 'flex',
alignItems: 'center',
gap: '3px'
}
}
};
const Tooltip = /*#__PURE__*/lazy(() => import('./Tooltip-B8gZJWmn.js').then(module => ({
default: module.Tooltip
})));
const ActivityCalendar = /*#__PURE__*/forwardRef(({
data: activities,
blockMargin = 4,
blockRadius = 2,
blockSize = 12,
className,
colorScheme: colorSchemeProp,
fontSize = 14,
labels: labelsProp,
loading = false,
minLevel = 0,
maxLevel = 4,
renderBlock,
renderColorLegend,
showColorLegend = true,
showMonthLabels = true,
showTotalCount = true,
showWeekdayLabels = false,
style: styleProp = {},
theme: themeProp,
tooltips = {},
weekStart = 0 // Sunday
}, ref) => {
const [isClient, setIsClient] = useState(false);
useEffect(() => {
setIsClient(true);
}, []);
if (minLevel >= maxLevel) {
throw new RangeError(`Minimum activity level must be less than maximum level. Got ${minLevel} and ${maxLevel}.`);
}
const levels = {
minLevel,
maxLevel
};
const theme = createTheme(themeProp, levels);
const systemColorScheme = useColorScheme();
const colorScheme = colorSchemeProp ?? systemColorScheme;
const colorScale = theme[colorScheme];
const animationLoaded = useLoadingAnimation(colorScale[0], colorScheme);
const useAnimation = !usePrefersReducedMotion();
if (loading) {
// Only show the loading state once the CSS animation has been injected
// to avoid a flash of white with dark backgrounds.
if (useAnimation && !animationLoaded) {
return null;
}
activities = generateEmptyData();
}
validateActivities(activities, levels);
const firstActivity = activities[0];
const year = getYear(parseISO(firstActivity.date));
const weeks = groupByWeeks(activities, weekStart);
const labels = Object.assign({}, DEFAULT_LABELS, labelsProp);
const labelHeight = showMonthLabels ? fontSize + LABEL_MARGIN : 0;
const weekdayLabels = initWeekdayLabels(showWeekdayLabels, weekStart);
// Must be calculated on the client, or SSR hydration errors will occur
// because server and client HTML would not match.
const weekdayLabelOffset = isClient && weekdayLabels.shouldShow ? maxWeekdayLabelWidth(labels.weekdays, weekdayLabels, fontSize) + LABEL_MARGIN : undefined;
function getDimensions() {
return {
width: weeks.length * (blockSize + blockMargin) - blockMargin,
height: labelHeight + (blockSize + blockMargin) * 7 - blockMargin
};
}
function renderCalendar() {
return weeks.map((week, weekIndex) => week.map((activity, dayIndex) => {
if (!activity) {
return null;
}
const loadingAnimation = loading && useAnimation ? {
animation: `${loadingAnimationName} 1.75s ease-in-out infinite`,
animationDelay: `${weekIndex * 20 + dayIndex * 20}ms`
} : undefined;
const block = /*#__PURE__*/jsx("rect", {
x: 0,
y: labelHeight + (blockSize + blockMargin) * dayIndex,
width: blockSize,
height: blockSize,
rx: blockRadius,
ry: blockRadius,
fill: colorForLevel(activity.level),
"data-date": activity.date,
"data-level": activity.level,
style: {
...styles.rect(colorScheme),
...loadingAnimation
}
});
const renderedBlock = renderBlock ? renderBlock(block, activity) : block;
return /*#__PURE__*/jsx(Fragment, {
children: tooltips.activity ? /*#__PURE__*/jsx(Suspense, {
fallback: renderedBlock,
children: /*#__PURE__*/jsx(Tooltip, {
text: tooltips.activity.text(activity),
colorScheme: colorScheme,
placement: tooltips.activity.placement ?? 'top',
hoverRestMs: tooltips.activity.hoverRestMs,
offset: tooltips.activity.offset,
transitionStyles: tooltips.activity.transitionStyles,
withArrow: tooltips.activity.withArrow,
children: renderedBlock
})
}) : renderedBlock
}, activity.date);
})).map((week, x) => /*#__PURE__*/jsx("g", {
transform: `translate(${(blockSize + blockMargin) * x}, 0)`,
children: week
}, x));
}
function renderFooter() {
if (!showTotalCount && !showColorLegend) {
return null;
}
const totalCount = activities.reduce((sum, activity) => sum + activity.count, 0);
return /*#__PURE__*/jsxs("footer", {
className: getClassName('footer'),
style: {
...styles.footer.container,
marginLeft: weekdayLabelOffset
},
children: [loading && /*#__PURE__*/jsx("div", {
children: "\xA0"
}), !loading && showTotalCount && /*#__PURE__*/jsx("div", {
className: getClassName('count'),
children: labels.totalCount ? labels.totalCount.replace('{{count}}', String(totalCount)).replace('{{year}}', String(year)) : `${totalCount} activities in ${year}`
}), !loading && showColorLegend && /*#__PURE__*/jsxs("div", {
className: getClassName('legend-colors'),
style: styles.footer.legend,
children: [labels.legend.less && /*#__PURE__*/jsx("span", {
style: {
marginRight: '0.4em'
},
children: labels.legend.less
}), range(minLevel, maxLevel + 1).map(level => {
const colorLegend = /*#__PURE__*/jsx("svg", {
width: blockSize,
height: blockSize,
children: /*#__PURE__*/jsx("rect", {
width: blockSize,
height: blockSize,
fill: colorForLevel(level),
rx: blockRadius,
ry: blockRadius,
style: styles.rect(colorScheme)
})
}, level);
const renderedColorLegend = renderColorLegend ? renderColorLegend(colorLegend, level) : colorLegend;
return /*#__PURE__*/jsx(Fragment, {
children: tooltips.colorLegend ? /*#__PURE__*/jsx(Suspense, {
fallback: renderedColorLegend,
children: /*#__PURE__*/jsx(Tooltip, {
text: tooltips.colorLegend.text(level),
colorScheme: colorScheme,
placement: tooltips.colorLegend.placement ?? 'bottom',
hoverRestMs: tooltips.colorLegend.hoverRestMs,
offset: tooltips.colorLegend.offset,
transitionStyles: tooltips.colorLegend.transitionStyles,
withArrow: tooltips.colorLegend.withArrow,
children: renderedColorLegend
})
}) : renderedColorLegend
}, level);
}), labels.legend.more && /*#__PURE__*/jsx("span", {
style: {
marginLeft: '0.4em'
},
children: labels.legend.more
})]
})]
});
}
function renderWeekdayLabels() {
if (!weekdayLabels.shouldShow) {
return null;
}
return /*#__PURE__*/jsx("g", {
className: getClassName('legend-weekday'),
children: range(7).map(index => {
const dayIndex = (index + weekStart) % 7;
if (!weekdayLabels.byDayIndex(dayIndex)) {
return null;
}
return /*#__PURE__*/jsx("text", {
x: -LABEL_MARGIN,
y: labelHeight + (blockSize + blockMargin) * index + blockSize / 2,
dominantBaseline: "central",
textAnchor: "end",
fill: "currentColor",
children: labels.weekdays[dayIndex]
}, index);
})
});
}
function renderMonthLabels() {
if (!showMonthLabels) {
return null;
}
return /*#__PURE__*/jsx("g", {
className: getClassName('legend-month'),
children: getMonthLabels(weeks, labels.months).map(({
label,
weekIndex
}) => /*#__PURE__*/jsx("text", {
x: (blockSize + blockMargin) * weekIndex,
y: 0,
dominantBaseline: "hanging",
fill: "currentColor",
children: label
}, weekIndex))
});
}
function colorForLevel(level) {
return colorScale[level - minLevel]; // shift to zero-based index
}
const {
width,
height
} = getDimensions();
return /*#__PURE__*/jsxs("article", {
ref: ref,
className: `${NAMESPACE} ${className ?? ''}`.trim(),
style: {
...styleProp,
...styles.container(fontSize)
},
children: [/*#__PURE__*/jsx("div", {
className: getClassName('scroll-container'),
style: styles.scrollContainer(fontSize),
children: /*#__PURE__*/jsxs("svg", {
width: width,
height: height,
viewBox: `0 0 ${width} ${height}`,
className: getClassName('calendar'),
style: {
...styles.calendar,
marginLeft: weekdayLabelOffset
},
children: [!loading && renderWeekdayLabels(), !loading && renderMonthLabels(), renderCalendar()]
})
}), renderFooter()]
});
});
ActivityCalendar.displayName = 'ActivityCalendar';
export { ActivityCalendar as A, getClassName as g };
//# sourceMappingURL=index-CLOTiFb2.js.map