// ============================================================
// UI — Header, Side Panel, Bottom Timeline Scrubber
// ============================================================
/* global React */
const MONTHS_FULL = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
];
const MONTHS_SHORT = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
const MW = {
1:[1,4], 2:[5,8], 3:[9,13], 4:[14,17], 5:[18,21], 6:[22,26],
7:[27,30], 8:[31,34], 9:[35,39], 10:[40,43], 11:[44,47], 12:[48,52],
};
function weekToMonth(w) {
for (let m = 1; m <= 12; m++) {
const [a, b] = MW[m];
if (w >= a && w <= b) return { month: m, weekInMonth: w - a + 1, totalInMonth: b - a + 1 };
}
return { month: 1, weekInMonth: 1, totalInMonth: 4 };
}
function Header({ week, weekCount }) {
const { month, weekInMonth, totalInMonth } = weekToMonth(week);
return (
The Festival Atlas of India
Land of Festival
उत्सवों की भूमि
A year in India is never quiet. Drag the pointer across the months and watch the country remember what it has always been — a constellation of harvests, monsoons, full moons and small village fairs.
You are viewing
{MONTHS_FULL[month - 1]}
Week {weekInMonth} of {totalInMonth}
W{String(week).padStart(2, '0')}/52
{String(weekCount).padStart(2, '0')}
{weekCount === 1 ? 'festival lit' : 'festivals lit'} at once
);
}
function SidePanel({
weekFestivals,
hoveredPin, selectedPin, setSelectedPin,
didYouKnow,
}) {
const focusFest = useFocusFestival(weekFestivals, hoveredPin, selectedPin);
const stateGroups = React.useMemo(() => {
const groups = new Map();
weekFestivals.forEach((f) => {
const key = f.pan ? 'Across India' : (f.states.join(', ') || 'India');
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(f);
});
return [...groups.entries()];
}, [weekFestivals]);
return (
setSelectedPin(null)} hasSelection={!!selectedPin} />
This week, across the country
{weekFestivals.length === 0 ? (
A quieter week. Move the pointer to find a celebration.
) : (
{stateGroups.map(([state, items]) => (
))}
)}
);
}
function useFocusFestival(weekFestivals, hoveredPin, selectedPin) {
return React.useMemo(() => {
const lookup = (id) => weekFestivals.find((f) => f.id === id);
return lookup(hoveredPin) || lookup(selectedPin) || null;
}, [weekFestivals, hoveredPin, selectedPin]);
}
function FocusCard({ fest, onClose, hasSelection }) {
if (!fest) return (
Hover or click a glowing pin to learn its story.
);
return (
{fest.level} · {fest.type}
{fest.name}
{fest.pan ? 'Across India' : fest.states.join(' · ')} · {fest.timeLabel}
{fest.description}
{hasSelection && (
×
)}
);
}
function StateRow({ state, items, setSelectedPin }) {
return (
{state}
{items.map((f) => (
setSelectedPin(f.id)}
style={{
textAlign: 'left',
background: 'transparent',
border: 'none',
padding: '4px 0',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: 10,
}}
onMouseEnter={(e) => e.currentTarget.style.background = 'oklch(0.22 0.018 55 / 0.5)'}
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
>
{f.name}
{f.timeLabel}
))}
);
}
function DidYouKnow({ fact }) {
return (
);
}
function Timeline({ week, setWeek, festivalsByWeek }) {
const trackRef = React.useRef(null);
const draggingRef = React.useRef(false);
const [hoverWeek, setHoverWeek] = React.useState(null);
const setFromX = React.useCallback((clientX) => {
const el = trackRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
const w = Math.max(1, Math.min(52, Math.round(ratio * 51 + 1)));
setWeek(w);
}, [setWeek]);
const hoverFromX = React.useCallback((clientX) => {
const el = trackRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
const w = Math.max(1, Math.min(52, Math.round(ratio * 51 + 1)));
setHoverWeek(w);
}, []);
React.useEffect(() => {
function onMove(e) {
if (draggingRef.current) setFromX(e.clientX);
}
function onUp() { draggingRef.current = false; }
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
window.addEventListener('touchend', onUp);
return () => {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
window.removeEventListener('touchend', onUp);
};
}, [setFromX]);
React.useEffect(() => {
function onKey(e) {
if (e.key === 'ArrowRight') { e.preventDefault(); setWeek(Math.min(52, week + 1)); }
if (e.key === 'ArrowLeft') { e.preventDefault(); setWeek(Math.max(1, week - 1)); }
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [week, setWeek]);
const ratio = (week - 1) / 51;
return (
{MONTHS_SHORT.map((m, i) => {
const mi = i + 1;
const [a, b] = MW[mi];
const active = week >= a && week <= b;
const startThisMonth = () => setWeek(a);
return (
{active ? MONTHS_FULL[i] : m}
);
})}
{ draggingRef.current = true; setFromX(e.clientX); }}
onMouseMove={(e) => hoverFromX(e.clientX)}
onMouseLeave={() => setHoverWeek(null)}
onTouchStart={(e) => { draggingRef.current = true; setFromX(e.touches[0].clientX); }}
onTouchMove={(e) => setFromX(e.touches[0].clientX)}
style={{
position: 'relative',
height: 56,
cursor: 'pointer',
userSelect: 'none',
touchAction: 'none',
}}
>
{Array.from({ length: 52 }, (_, i) => {
const w = i + 1;
const x = (i / 51) * 100;
const isMajor = [1,5,9,14,18,22,27,31,35,40,44,48].includes(w);
const hasFest = (festivalsByWeek[w] || 0) > 0;
const count = festivalsByWeek[w] || 0;
const isActive = w === week;
return (
{hasFest && (
4 ? '0 0 4px oklch(0.78 0.155 65 / 0.7)' : 'none',
}} />
)}
);
})}
{hoverWeek && hoverWeek !== week && (
{MONTHS_SHORT[weekToMonth(hoverWeek).month - 1]} · W{weekToMonth(hoverWeek).weekInMonth}
{festivalsByWeek[hoverWeek] ? ` · ${festivalsByWeek[hoverWeek]} fest` : ''}
)}
Drag the pointer · or use ← →
Click a month to leap · click a pin for the story
);
}
Object.assign(window, {
Header, SidePanel, Timeline,
MONTHS_FULL, MONTHS_SHORT, MW, weekToMonth,
});