// ============================================================ // India Map — d3-geo topojson rendering + festival icon pins // Includes Tricolor mode for Republic Day (W4) & Independence Day (W32) // ============================================================ /* global React, d3, topojson, LOF_Icons */ const { useEffect, useMemo, useRef, useState } = React; const { FestivalIcon, pickIcon } = LOF_Icons; const STATE_ZONE = { 'Jammu and Kashmir':'North','Ladakh':'North','Himachal Pradesh':'North','Punjab':'North', 'Haryana':'North','Uttarakhand':'North','Delhi':'North','Chandigarh':'North', 'Rajasthan':'West','Gujarat':'West','Maharashtra':'West','Goa':'West', 'Dadra and Nagar Haveli and Daman and Diu':'West', 'Madhya Pradesh':'Central','Chhattisgarh':'Central','Uttar Pradesh':'Central', 'Bihar':'East','Jharkhand':'East','West Bengal':'East','Odisha':'East', 'Assam':'Northeast','Arunachal Pradesh':'Northeast','Nagaland':'Northeast', 'Manipur':'Northeast','Mizoram':'Northeast','Tripura':'Northeast', 'Meghalaya':'Northeast','Sikkim':'Northeast', 'Karnataka':'South','Kerala':'South','Tamil Nadu':'South','Andhra Pradesh':'South', 'Telangana':'South','Puducherry':'South', 'Lakshadweep':'Islands','Andaman and Nicobar Islands':'Islands', }; const ZONE_FILL = { North:'oklch(0.32 0.028 70)', West:'oklch(0.32 0.032 50)', Central:'oklch(0.32 0.026 40)', East:'oklch(0.32 0.026 25)', Northeast:'oklch(0.32 0.028 160)', South:'oklch(0.32 0.032 80)', Islands:'oklch(0.32 0.024 220)', }; const ZONE_FILL_BRIGHT = { North:'oklch(0.46 0.046 70)', West:'oklch(0.46 0.052 50)', Central:'oklch(0.46 0.044 40)', East:'oklch(0.46 0.044 25)', Northeast:'oklch(0.46 0.046 160)', South:'oklch(0.46 0.052 80)', Islands:'oklch(0.46 0.040 220)', }; const TRICOLOR_WEEKS = new Set([4, 32]); function IndiaMap({ topo, width, height, pins, week, hoveredPin, setHoveredPin, selectedPin, setSelectedPin, }) { const tricolor = TRICOLOR_WEEKS.has(week); const { features, path, stateCentroids, panCenter, bounds } = useMemo(() => { if (!topo) return {}; const fc = topojson.feature(topo, topo.objects.states); const proj = d3.geoMercator(); proj.fitExtent([[8, 8], [width - 8, height - 8]], fc); const pth = d3.geoPath(proj); const centroids = {}; fc.features.forEach((f) => { const c = d3.geoCentroid(f); const xy = proj(c); if (xy) centroids[f.properties.st_nm] = { x: xy[0], y: xy[1] }; }); const panCtr = proj([78.6, 22.5]); const b = pth.bounds(fc); return { features: fc.features, path: pth, stateCentroids: centroids, panCenter: { x: panCtr[0], y: panCtr[1] }, bounds: b, }; }, [topo, width, height]); const resolvedPins = useMemo(() => { if (!stateCentroids) return []; return pins .map((p) => { if (p.pan) return { ...p, x: panCenter.x, y: panCenter.y, isPan: true }; const c = stateCentroids[p.state]; if (!c) return null; return { ...p, x: c.x, y: c.y }; }) .filter(Boolean); }, [pins, stateCentroids, panCenter]); const pinNodes = useMemo(() => { const groups = new Map(); resolvedPins.forEach((p) => { const key = p.isPan ? '__pan__' : p.state; if (!groups.has(key)) groups.set(key, []); groups.get(key).push(p); }); const out = []; groups.forEach((items) => { const cx = items[0].x, cy = items[0].y; items.forEach((p, i) => { const n = items.length; const r = n === 1 ? 0 : 11; const a = (i / n) * Math.PI * 2 - Math.PI / 2; out.push({ ...p, x: cx + Math.cos(a) * r, y: cy + Math.sin(a) * r, clusterSize: n, }); }); }); return out; }, [resolvedPins]); const activeStates = useMemo(() => { const s = new Set(); resolvedPins.forEach((p) => { if (!p.isPan && p.state) s.add(p.state); }); return s; }, [resolvedPins]); if (!topo) return null; const hoveredNode = pinNodes.find((n) => n.id === hoveredPin || n.id === selectedPin); return (
{bounds && ( )} {features.map((f, i) => ( ))} {features.map((f, i) => { const name = f.properties.st_nm; const zone = STATE_ZONE[name] || 'Central'; const active = activeStates.has(name); const fill = tricolor ? 'url(#tiranga)' : (active ? ZONE_FILL_BRIGHT[zone] : ZONE_FILL[zone]); return ( ); })} {!tricolor && features.map((f, i) => ( ))} {!tricolor && ( {features.map((f, i) => ( ))} )} {tricolor && bounds && ( )} {!tricolor && pinNodes.some((p) => p.isPan) && ( {[0, 1, 2].map((i) => ( ))} )}
{pinNodes.map((p) => ( setHoveredPin(p.id)} onLeave={() => setHoveredPin(null)} onClick={() => setSelectedPin(p.id)} dim={tricolor} /> ))} {hoveredNode && ( )}
); } function AshokChakra({ cx, cy, r }) { return ( {Array.from({ length: 24 }).map((_, i) => { const a = (i / 24) * Math.PI * 2; return ( ); })} ); } function PinIcon({ p, hovered, selected, onEnter, onLeave, onClick, dim }) { const rule = pickIcon(p.festival); const Icon = LOF_Icons.Icons[rule.icon] || LOF_Icons.Icons.Star; const size = (hovered || selected) ? 44 : (p.clusterSize > 1 ? 30 : 34); const z = (hovered || selected) ? 25 : 10; return (
{p.clusterSize > 1 && (
{p.clusterSize}
)}
); } function HoverCard({ node, width, height }) { const cardW = 240; const cardH = 86; const margin = 16; const onRight = node.x + cardW + margin < width; const left = onRight ? node.x + 28 : node.x - 28 - cardW; let top = Math.max(8, Math.min(height - cardH - 8, node.y - cardH / 2)); const rule = pickIcon(node.festival); return (
{node.festival.name}
{node.festival.pan ? 'Across India' : (node.festival.states[0] || '')}
{node.festival.timeLabel}
); } (function injectMapKeyframes() { if (document.getElementById('lof-map-keyframes')) return; const s = document.createElement('style'); s.id = 'lof-map-keyframes'; s.textContent = ` @keyframes chakraSpin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } @keyframes pinDrop { 0% { transform: translate(-50%, -90%) scale(0.4); opacity: 0; } 60% { transform: translate(-50%, -45%) scale(1.1); opacity: 1; } 100% { transform: translate(-50%, -50%) scale(1); opacity: 1; } } @keyframes iconHover { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.06); } } `; document.head.appendChild(s); })(); Object.assign(window, { IndiaMap, TRICOLOR_WEEKS });