// Location — REAL, expandable Leaflet tile map of The Universe (Nehru Nagar).
// ─────────────────────────────────────────────────────────────────────────
// · Live tile map (zoom in for real street/building detail — not a flat image).
// DEFAULT = VECTOR (Carto Voyager); toggle → SATELLITE (Esri World Imagery).
// · Functional pins: every POI marker + every rail row selects its landmark.
// · On select → the surroundings BLUR (focus vignette), a glowing line FLIES
// from the project to the destination, and the camera follows it with a
// cinematic pull-back, settling framed on both endpoints.
// · Floating liquid-glass HUD + POI rail over a full-bleed map.
// Needs network for live tiles (kiosk has wifi); dark fallback if offline.
// Data: MAP_VIEW + LOCATION_ADVANTAGES + PROJECT.location (app/data.jsx).
const LOC_KEYS_ID = 'uni-location-leaflet-keys';
function ensureLocKeys() {
if (typeof document === 'undefined' || document.getElementById(LOC_KEYS_ID)) return;
const s = document.createElement('style'); s.id = LOC_KEYS_ID;
s.textContent = `
.leaflet-container{ background:#ece6dc; font-family:'Inter',system-ui,sans-serif; outline:none; }
.uni-divicon{ background:transparent; border:none; }
/* Tap target ≥64px: a transparent hit-pad wraps the visible dot so the whole
64px circle is tappable even though the printed marker is 58px. */
.uni-poi{ position:absolute; left:0; top:0; transform:translate(-50%,-50%); cursor:pointer;
width:64px; height:64px; display:flex; align-items:center; justify-content:center; }
.uni-poi .dot{ width:58px;height:58px;border-radius:50%; display:flex;align-items:center;justify-content:center;
font-size:27px;font-weight:800;color:#1a130a; background:var(--shade,#c9a05e); border:3px solid #fff8e0;
box-shadow:0 0 0 5px rgba(10,10,10,0.18), 0 7px 20px rgba(0,0,0,0.55); transition:transform .25s cubic-bezier(0.22,1,0.36,1), box-shadow .25s, opacity .3s; }
.uni-poi.dim .dot{ opacity:0.42; }
.uni-poi.sel .dot{ transform:scale(1.24); border-color:#fff8e0;
box-shadow:0 0 0 6px rgba(214,59,80,0.32), 0 0 32px var(--shade), 0 9px 24px rgba(0,0,0,0.5); }
.uni-poi .tip{ position:absolute; left:50%; bottom:70px; transform:translateX(-50%); white-space:nowrap;
background:var(--shade); color:#1a130a; font-family:'JetBrains Mono',monospace; font-size:17px;font-weight:700;
padding:8px 17px;border-radius:14px; box-shadow:0 6px 16px rgba(0,0,0,0.5); }
.uni-proj{ position:absolute; left:0; top:0; transform:translate(-50%,-50%); width:60px;height:60px; }
.uni-proj .core{ position:absolute; inset:11px; border-radius:50%; background:#0a0807; border:3px solid #ead7a8;
display:flex;align-items:center;justify-content:center; box-shadow:0 0 22px rgba(232,215,168,0.75); z-index:3; }
.uni-proj .ring{ position:absolute; left:50%;top:50%; border-radius:50%; border:2px solid #ead7a8;
transform:translate(-50%,-50%); animation:uniProjRing 2.8s ease-out infinite; }
@keyframes uniProjRing{ 0%{width:30px;height:30px;opacity:0.75;} 100%{width:152px;height:152px;opacity:0;} }
.uni-fly-line{ filter:drop-shadow(0 0 6px rgba(232,215,168,0.95)); }
.uni-comet{ filter:drop-shadow(0 0 9px rgba(255,248,224,0.95)); }
.loc-vignette{ position:absolute; inset:0; pointer-events:none; z-index:5; opacity:0; transition:opacity .65s ease;
background:radial-gradient(ellipse 60% 62% at 50% 52%, transparent 40%, rgba(6,8,12,0.62) 100%);
-webkit-backdrop-filter:blur(3px); backdrop-filter:blur(3px);
-webkit-mask-image:radial-gradient(ellipse 58% 60% at 50% 52%, transparent 42%, #000 92%);
mask-image:radial-gradient(ellipse 58% 60% at 50% 52%, transparent 42%, #000 92%); }
.loc-vignette.on{ opacity:1; }
@keyframes locRowIn{ 0%{opacity:0;transform:translateX(16px);} 100%{opacity:1;transform:translateX(0);} }
@keyframes locCardIn{ 0%{opacity:0;transform:translateY(14px) scale(0.97);} 100%{opacity:1;transform:translateY(0) scale(1);} }
@keyframes locLive{ 0%,100%{opacity:0.95;} 50%{opacity:0.45;} }
@keyframes poiPop{ 0%{opacity:0;transform:scale(0.2);} 60%{opacity:1;transform:scale(1.14);} 100%{opacity:1;transform:scale(1);} }
.uni-catbtn{ transition:transform 320ms cubic-bezier(0.22,1,0.36,1), box-shadow 320ms ease, border-color 280ms ease, background 320ms ease; }
.uni-catbtn:hover{ transform:translateY(-4px); border-color:rgba(232,215,168,0.55) !important;
box-shadow:0 24px 48px rgba(50,32,12,0.34), 0 10px 22px rgba(50,32,12,0.20), 0 0 0 3px rgba(232,215,168,0.20), inset 0 1px 0 rgba(255,246,224,0.22), inset 0 -22px 40px rgba(50,32,12,0.30) !important; }
.uni-catbtn:active{ transform:translateY(0) scale(0.985); }
.uni-catbtn:hover .uni-catbtn-arrow{ transform:translateX(5px); }
.uni-catbtn-arrow{ transition:transform 220ms cubic-bezier(0.22,1,0.36,1); }
/* tile utility buttons (zoom / reset / back) — home nav-tile feel on press */
.uni-tilebtn{ transition:transform 200ms cubic-bezier(0.22,1,0.36,1), box-shadow 240ms ease, border-color 240ms ease; }
.uni-tilebtn:hover{ transform:translateY(-3px); border-color:rgba(232,215,168,0.55) !important; }
.uni-tilebtn:active{ transform:translateY(0) scale(0.96); }
.uni-lmrow{ transition:transform 200ms cubic-bezier(0.22,1,0.36,1), background 200ms ease, border-color 200ms ease, box-shadow 200ms ease; }
.uni-lmrow:hover{ transform:translateX(-3px); }
.uni-lmrow:active{ transform:scale(0.99); }
.uni-back-cats{ transition:transform 160ms ease-out, color 160ms ease; }
.uni-back-cats:hover{ color:var(--gold-deep) !important; }
.uni-back-cats:active{ transform:scale(0.97); }
.loc-rail-scroll::-webkit-scrollbar{ width:8px; }
.loc-rail-scroll::-webkit-scrollbar-thumb{ background:rgba(176,138,63,0.32); border-radius:8px; }
.loc-rail-scroll::-webkit-scrollbar-track{ background:transparent; }
`;
document.head.appendChild(s);
}
// ── geo helpers ─────────────────────────────────────────────────────────────
function bearingDeg(from, to) {
const dLat = to.lat - from.lat;
const dLng = (to.lng - from.lng) * Math.cos((from.lat + to.lat) / 2 * Math.PI / 180);
return (Math.atan2(dLng, dLat) * 180 / Math.PI + 360) % 360;
}
function bearingWord(deg) {
const d = ['N','NNE','NE','ENE','E','ESE','SE','SSE','S','SSW','SW','WSW','W','WNW','NW','NNW'];
return d[Math.round(((deg % 360 + 360) % 360) / 22.5) % 16];
}
function geoKm(from, to) {
const dLat = (to.lat - from.lat) * 111;
const dLng = (to.lng - from.lng) * 111 * Math.cos(from.lat * Math.PI / 180);
return Math.hypot(dLat, dLng);
}
const CAT_META = {
'Connectivity': { glyph:'↗', shade:'#d8b573', icon:'arrow' },
'Education': { glyph:'◆', shade:'#c9a05e', icon:'story' },
'Healthcare': { glyph:'✚', shade:'#e3c787', icon:'plus' },
'Lifestyle': { glyph:'★', shade:'#ead7a8', icon:'amenities' },
'On-site': { glyph:'◉', shade:'#fff8e0', icon:'location' },
};
function useEnrichedPois() {
return React.useMemo(() => {
const o = PROJECT.location.coords;
return LOCATION_ADVANTAGES.map(p => {
const bearing = bearingDeg(o, { lat:p.lat, lng:p.lng });
const km = geoKm(o, { lat:p.lat, lng:p.lng });
const onsite = p.cat === 'On-site' || km < 0.18;
return { ...p, bearing, km, onsite, meta: CAT_META[p.cat] || { glyph:'·', shade:'#c9a05e' } };
});
}, []);
}
function poiHtml(p, sel, anySel, popIn, idx) {
// popIn → the category just opened: pins fade/scale in, staggered, so the map
// "reveals" the landmarks in sync with the rail list opening on the right.
const anim = popIn ? `animation:poiPop .52s cubic-bezier(0.22,1,0.36,1) ${(idx || 0) * 55}ms both;` : '';
return `
${sel ? `
${p.label} · ${p.dist}
` : ''}
${p.meta.glyph}
`;
}
// ════════════════════════════════════════════════════════════════════════
// LocationScreen (root)
// ════════════════════════════════════════════════════════════════════════
function LocationScreen() {
ensureLocKeys();
const allPois = useEnrichedPois();
const origin = PROJECT.location.coords;
const [layer, setLayer] = React.useState('vector'); // DEFAULT = vector
const [cat, setCat] = React.useState(null); // null = category menu; else a category is open
const [selectedLabel, setSelectedLabel] = React.useState(null);
const [focusOn, setFocusOn] = React.useState(false);
const mapElRef = React.useRef(null);
const mapRef = React.useRef(null);
const tilesRef = React.useRef({});
const markersRef = React.useRef({});
const lineRef = React.useRef(null);
const cometRef = React.useRef(null);
const animRef = React.useRef(0);
const timerRef = React.useRef(null);
const prevCatRef = React.useRef(null); // detects a fresh category open → pins pop in
// one summary card per category (glyph, count, nearest landmark) for the menu
const catSummaries = React.useMemo(() => {
const names = Array.from(new Set(LOCATION_ADVANTAGES.map(p => p.cat)));
return names.map(name => {
const items = allPois.filter(p => p.cat === name);
const nearest = items.reduce((a, b) => (a && a.km <= b.km ? a : b), null);
const meta = CAT_META[name] || { glyph: '·', shade: '#c9a05e' };
return { name, glyph: meta.glyph, icon: meta.icon, shade: meta.shade, count: items.length, nearest };
});
}, [allPois]);
// no category open → no landmark pins; a category open → only its pins
const filtered = React.useMemo(
() => (cat ? allPois.filter(p => p.cat === cat) : []),
[cat, allPois]
);
const selected = allPois.find(p => p.label === selectedLabel) || null;
const coordStr = `${origin.lat.toFixed(4)}°N · ${origin.lng.toFixed(4)}°E · ${PROJECT.location.pincode}`;
const openCat = (name) => { setSelectedLabel(null); setCat(name); };
const backToCats = () => { setSelectedLabel(null); setCat(null); };
// opening a category → camera glides out to FRAME that category's landmarks
// (with the project), so the reveal of its pins reads as one motion.
React.useEffect(() => {
const map = mapRef.current; if (!map || !cat) return;
const pins = allPois.filter(p => p.cat === cat);
if (!pins.length) return;
if (pins.length === 1) {
map.flyTo([pins[0].lat, pins[0].lng], 15.5, { duration: 0.7 });
} else {
const b = L.latLngBounds(pins.map(p => [p.lat, p.lng]));
b.extend([origin.lat, origin.lng]);
map.flyToBounds(b.pad(0.24), { duration: 0.9, easeLinearity: 0.15,
paddingTopLeft: L.point(130, 240), paddingBottomRight: L.point(720, 150), maxZoom: 15 });
}
}, [cat]); // eslint-disable-line
// ── create the Leaflet map once ──
React.useEffect(() => {
if (!mapElRef.current || mapRef.current || typeof L === 'undefined') return;
const map = L.map(mapElRef.current, {
center: [origin.lat, origin.lng], zoom: 15, minZoom: 11, maxZoom: 19,
zoomControl: false, attributionControl: false, zoomSnap: 0, zoomDelta: 0.6,
wheelPxPerZoomLevel: 80, fadeAnimation: true, inertia: true,
});
mapRef.current = map;
// CSS-transform fix: the whole app canvas is `transform: scale(...)`, which
// throws off Leaflet's clientX/Y → container-point conversion. Compensate
// by the live scale so clicks/drags land correctly at any device size.
map.mouseEventToContainerPoint = function (e) {
const c = this._container, r = c.getBoundingClientRect();
const sx = (r.width / c.offsetWidth) || 1, sy = (r.height / c.offsetHeight) || 1;
return L.point((e.clientX - r.left) / sx, (e.clientY - r.top) / sy);
};
const sat = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', { maxZoom: 19, keepBuffer: 6 });
const vec = L.tileLayer('https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png', { maxZoom: 20, subdomains: 'abcd', keepBuffer: 6 });
tilesRef.current = { satellite: sat, vector: vec };
vec.addTo(map);
const projIcon = L.divIcon({
className: 'uni-divicon', iconSize: [0, 0], iconAnchor: [0, 0],
html: `
`,
});
L.marker([origin.lat, origin.lng], { icon: projIcon, interactive: false, zIndexOffset: 1000 }).addTo(map);
setTimeout(() => map.invalidateSize(), 60);
return () => { cancelAnimationFrame(animRef.current); map.remove(); mapRef.current = null; };
}, []);
// ── tile-layer toggle ──
React.useEffect(() => {
const map = mapRef.current, ts = tilesRef.current;
if (!map || !ts.satellite) return;
const show = layer === 'satellite' ? ts.satellite : ts.vector;
const hide = layer === 'satellite' ? ts.vector : ts.satellite;
if (map.hasLayer(hide)) map.removeLayer(hide);
if (!map.hasLayer(show)) show.addTo(map);
}, [layer]);
// ── (re)build POI markers when filter / selection changes ──
React.useEffect(() => {
const map = mapRef.current; if (!map) return;
Object.values(markersRef.current).forEach(m => map.removeLayer(m));
markersRef.current = {};
const anySel = !!selectedLabel;
// pop the pins in only when the CATEGORY changed — not on every selection,
// otherwise they'd re-animate each time you tap a landmark.
const popIn = prevCatRef.current !== cat;
prevCatRef.current = cat;
filtered.forEach((p, i) => {
const sel = p.label === selectedLabel;
const icon = L.divIcon({ className: 'uni-divicon', iconSize: [0, 0], iconAnchor: [0, 0], html: poiHtml(p, sel, anySel, popIn, i) });
const m = L.marker([p.lat, p.lng], { icon, riseOnHover: true, zIndexOffset: sel ? 900 : 0 }).addTo(map);
m.on('click', () => setSelectedLabel(prev => prev === p.label ? null : p.label));
markersRef.current[p.label] = m;
});
}, [filtered, selectedLabel, cat]);
// ── selection → (1) camera frames both, THEN (2) clean line draw + blur ──
// Sequenced so the line never draws while the map is still moving (that made
// it look like it "floated" before snapping into place).
React.useEffect(() => {
const map = mapRef.current; if (!map) return;
cancelAnimationFrame(animRef.current);
if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null; }
if (lineRef.current) { map.removeLayer(lineRef.current); lineRef.current = null; }
if (cometRef.current) { map.removeLayer(cometRef.current); cometRef.current = null; }
if (!selected) {
setFocusOn(false);
if (!map.getBounds().pad(-0.15).contains([origin.lat, origin.lng])) map.flyTo([origin.lat, origin.lng], 15, { duration: 0.55 });
return;
}
setFocusOn(true);
const from = { lat: origin.lat, lng: origin.lng };
const to = { lat: selected.lat, lng: selected.lng };
const shade = selected.meta.shade;
const km = selected.km || geoKm(from, to);
// PHASE 1 — camera PULLS BACK to frame the ENTIRE project → destination
// route end-to-end (reveals the distance). The further the landmark, the
// more we ZOOM OUT and the longer/softer the move — so a long-distance
// route (airport, railway) is never cropped tight; the whole line reads.
// Generous padding clears the HUD (top-left) and the right-edge rail so
// BOTH endpoints sit inside the live frame, not under the panel.
const pad = km > 6 ? 0.46 : km > 3 ? 0.34 : 0.24; // more breathing room far out
const maxZ = km > 6 ? 13.8 : km > 3 ? 14.8 : 15.3; // hard cap so near pins don't snap in tight
const FLY = km > 6 ? 1.30 : km > 3 ? 1.05 : 0.85; // slow cinematic pull-back, scales with distance
const padBounds = L.latLngBounds([[from.lat, from.lng], [to.lat, to.lng]]).pad(pad);
map.flyToBounds(padBounds, { duration: FLY, easeLinearity: 0.12, // 0.12 → strong ease-out settle
paddingTopLeft: L.point(130, 250), paddingBottomRight: L.point(720, 170), maxZoom: maxZ });
// PHASE 2 — once the camera has fully settled at the wide framing, draw a
// CLEAN line FROM the project pin TO the destination pin over the now-static
// map. Pure ease-out reveal (fast start → slow settle on the endpoint);
// duration scales with distance so the full long route reads unhurried.
timerRef.current = setTimeout(() => {
timerRef.current = null;
if (!mapRef.current) return;
const line = L.polyline([[from.lat, from.lng], [from.lat, from.lng]],
{ className: 'uni-fly-line', color: shade, weight: 5, opacity: 0.96, lineCap: 'round' }).addTo(map);
const comet = L.circleMarker([from.lat, from.lng],
{ className: 'uni-comet', radius: 9, color: '#fff8e0', weight: 2.5, fillColor: shade, fillOpacity: 1 }).addTo(map);
lineRef.current = line; cometRef.current = comet;
const easeOut = t => 1 - Math.pow(1 - t, 3); // ease-out: quick reveal, gentle settle
const dur = km > 6 ? 1100 : km > 3 ? 880 : 680, t0 = performance.now();
const step = (now) => {
const t = Math.min(1, (now - t0) / dur), te = easeOut(t);
const cur = { lat: from.lat + (to.lat - from.lat) * te, lng: from.lng + (to.lng - from.lng) * te };
line.setLatLngs([[from.lat, from.lng], [cur.lat, cur.lng]]);
comet.setLatLng([cur.lat, cur.lng]);
if (t < 1) animRef.current = requestAnimationFrame(step);
};
animRef.current = requestAnimationFrame(step);
}, FLY * 1000 + 130);
return () => { cancelAnimationFrame(animRef.current); if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null; } };
}, [selectedLabel]); // eslint-disable-line
return (
{/* ── FULL-BLEED LIVE MAP ── extends 800 design-px beyond the canvas on
every side so it fills the letterbox at ANY aspect ratio / device
(frame clips the overflow). The map stays centred on the project, so
pins & framing are unchanged; only the dead bars are now live map. */}
{/* focus blur / vignette (on select) */}
{/* HUD · identity (top-left, floats) */}
LIVE MAP · CHOOSE A CATEGORY · THEN TAP A LANDMARK
{/* floating back button — home nav-tile recipe (dark beige + cream icon) */}
{/* floating category buttons — pinned to the right edge, vertically
CENTERED. No full-height panel: each button opens its landmarks
inline (accordion). The flex-centered wrapper keeps the stack
balanced top↔bottom as it grows / shrinks dynamically. */}
);
}
// ════════════════════════════════════════════════════════════════════════
// PoiRail — compact right-edge accordion. Each category is a glass button;
// tapping one opens ITS landmarks inline (right there) — no full-height
// panel. The parent wrapper centers this stack vertically, so it stays
// balanced top↔bottom as groups expand / collapse.
// ════════════════════════════════════════════════════════════════════════
function PoiRail({ catSummaries, activeCat, onToggleCat, pois, selectedLabel, onSelect }) {
return (