// ============================================================ // 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 ( ); } 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) => ( ))}
); } function DidYouKnow({ fact }) { return (
Did you know

{fact}

); } 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 ( ); })}
{ 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, });