// Map view — Leaflet-based real map of RM + Valparaíso. // Each PROJECT has its own sede (address + lat/lon). Clicking a project's // sede highlights its coverage territory (served comunas) with colored overlays. const { useState, useRef, useEffect, useMemo, useCallback } = React; const VIEW_CENTER = { panorama: { center: [-33.30, -71.05], zoom: 9 }, valparaiso: { center: [-33.02, -71.58], zoom: 12.5 }, santiago: { center: [-33.50, -70.65], zoom: 11.25 }, }; // ── Icon factories ───────────────────────────────────────────────────────── function sedeProjectIcon(L, color, isSelected, isDimmed, zoom) { // A pin: drop-shape with colored dot and inner mark. // At low zoom (<10), render a compact dot only — at higher zoom, full pin. const compact = zoom < 10; const cls = [isSelected ? "selected" : "", isDimmed ? "dimmed" : ""].join(" ").trim(); const html = compact ? `
` : `
`; const size = compact ? 20 : 34; return L.divIcon({ html, className: "sede-pin-icon", iconSize: [size, compact ? size : size + 8], iconAnchor: compact ? [size / 2, size / 2] : [size / 2, size + 6], // base of pin }); } function comunaContextIcon(L, isHighlighted, count) { // Subtle background dot for each covered comuna. When a project is selected // and this comuna is in its coverage, it becomes a colored highlight tile. const html = `
${count > 1 ? count : ""}
`; return L.divIcon({ html, className: "comuna-context-icon", iconSize: [22, 22], iconAnchor: [11, 11], }); } function hqIcon(L) { // Sede principal administrativa (Jécar Nehgme 11) const html = `
SEDE CENTRAL
Jécar Nehgme 11 · Santiago
`; return L.divIcon({ html, className: "hq-icon", iconSize: [60, 60], iconAnchor: [30, 30], }); } // ── Main component ───────────────────────────────────────────────────────── function MapView({ selected, setSelected, view, setView, tweaks }) { const data = window.FDB_DATA; const mapRef = useRef(null); const containerRef = useRef(null); const initRef = useRef(true); const layersRef = useRef({ projects: [], comunas: [], coverage: [], hq: null, regionHalos: [], }); const [zoom, setZoom] = useState(VIEW_CENTER.panorama.zoom); const [regionPos, setRegionPos] = useState({ rm: null, v: null }); // ── Init Leaflet once ── useEffect(() => { if (mapRef.current) return; const L = window.L; if (!L) return; const map = L.map(containerRef.current, { zoomControl: false, attributionControl: false, preferCanvas: false, scrollWheelZoom: true, doubleClickZoom: true, maxZoom: 17, minZoom: 8, zoomSnap: 0.25, zoomDelta: 0.5, wheelPxPerZoomLevel: 90, }); // Basemap de Esri: gris claro sin etiquetas. No requiere API key. // Ojo: Esri ordena la ruta {z}/{y}/{x}, al revés de la convención habitual. L.tileLayer( "https://services.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Light_Gray_Base/MapServer/tile/{z}/{y}/{x}", { maxZoom: 17, className: "fdb-tiles", } ).addTo(map); // Labels tile layer (added only at zoom >= 10) const labelsLayer = L.tileLayer( "https://services.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Light_Gray_Reference/MapServer/tile/{z}/{y}/{x}", { maxZoom: 17, className: "fdb-tile-labels" } ); let labelsOn = false; const syncLabels = () => { const z = map.getZoom(); const should = z >= 10; if (should && !labelsOn) { labelsLayer.addTo(map); labelsOn = true; } else if (!should && labelsOn) { map.removeLayer(labelsLayer); labelsOn = false; } }; map.on("zoomend zoom", syncLabels); syncLabels(); L.control .attribution({ prefix: false, position: "bottomleft" }) .addAttribution('© Esri · OpenStreetMap') .addTo(map); map.setView(VIEW_CENTER.panorama.center, VIEW_CENTER.panorama.zoom, { animate: false }); const updateRegionPos = () => { setZoom(map.getZoom()); const sz = map.getSize(); const clamp = (v, mn, mx) => Math.max(mn, Math.min(mx, v)); const compute = (latLng, w, h, offsetY) => { const p = map.latLngToContainerPoint(latLng); let y = p.y + offsetY; if (y + h > sz.y - 10) y = p.y - h - 18; y = clamp(y, 10, sz.y - h - 10); let x = clamp(p.x, w / 2 + 12, sz.x - w / 2 - 12); return { x, y }; }; setRegionPos({ rm: compute([-33.48, -70.62], 220, 64, 72), v: compute([-33.04, -71.58], 200, 64, 56), }); }; map.on("zoomend moveend move zoom resize", updateRegionPos); updateRegionPos(); const updateZoomTier = () => { const z = map.getZoom(); const tier = z < 9.5 ? "pano" : z < 11 ? "region" : z < 12.5 ? "comuna" : "project"; document.body.dataset.zoomTier = tier; }; map.on("zoomend zoomstart zoom", updateZoomTier); updateZoomTier(); // Click on map background clears selection map.on("click", () => setSelected(null)); mapRef.current = map; initRef.current = false; }, []); // ── Animate to named view ── useEffect(() => { const map = mapRef.current; if (!map || !view || !VIEW_CENTER[view] || initRef.current) return; map.flyTo(VIEW_CENTER[view].center, VIEW_CENTER[view].zoom, { duration: 1.0, easeLinearity: 0.3, }); }, [view]); // ── Render PROJECT sede markers ── useEffect(() => { const map = mapRef.current; const L = window.L; if (!map || !L) return; layersRef.current.projects.forEach((m) => map.removeLayer(m)); layersRef.current.projects = []; data.projects.forEach((p) => { if (p.generic || p.lat == null) return; const isSelected = selected?.type === "project" && selected.id === p.id; const isDimmed = selected?.type === "project" && !isSelected; const color = data.areas[p.area].color; const marker = L.marker([p.lat, p.lon], { icon: sedeProjectIcon(L, color, isSelected, isDimmed, zoom), zIndexOffset: isSelected ? 1500 : isDimmed ? 50 : 500, riseOnHover: true, }); // Tooltip: name (visible by default at high zoom, on hover otherwise) const tooltipDir = "top"; marker.bindTooltip(`${p.name}`, { direction: tooltipDir, offset: [0, -32], className: "fdb-tooltip", permanent: zoom >= 13, sticky: zoom < 13, }); marker.on("click", () => { setSelected({ type: "project", id: p.id }); // Fly to fit the sede + all its served comunas const lats = [p.lat], lons = [p.lon]; p.comunas.forEach((cname) => { const c = data.comunas.find((x) => x.name === cname); if (c) { lats.push(c.lat); lons.push(c.lon); } }); if (lats.length > 1) { const bounds = L.latLngBounds(lats.map((la, i) => [la, lons[i]])); map.flyToBounds(bounds, { padding: [60, 60], maxZoom: 13, duration: 0.8 }); } else { map.flyTo([p.lat, p.lon], Math.max(map.getZoom(), 13), { duration: 0.6 }); } }); marker.addTo(map); layersRef.current.projects.push(marker); }); }, [selected, zoom, data]); // ── Render comuna context dots (subtle backdrop) ── useEffect(() => { const map = mapRef.current; const L = window.L; if (!map || !L) return; layersRef.current.comunas.forEach((m) => map.removeLayer(m)); layersRef.current.comunas = []; if (zoom < 10) return; // too small to be useful // Comunas highlighted = those in coverage of selected project const highlightSet = new Set(); if (selected?.type === "project") { const p = data.projects.find((x) => x.id === selected.id); if (p && p.comunas) p.comunas.forEach((c) => highlightSet.add(c)); } data.comunas.forEach((c) => { // Count of projects with this comuna in their coverage const projectsHere = data.projects.filter( (p) => !p.generic && p.comunas.includes(c.name) ); if (projectsHere.length === 0) return; const isHi = highlightSet.has(c.name); const marker = L.marker([c.lat, c.lon], { icon: comunaContextIcon(L, isHi, projectsHere.length), zIndexOffset: isHi ? 800 : 50, }); marker.on("click", () => { setSelected({ type: "comuna", name: c.name }); map.flyTo([c.lat, c.lon], Math.max(map.getZoom(), 12.5), { duration: 0.6 }); }); marker.addTo(map); layersRef.current.comunas.push(marker); }); }, [selected, zoom, data]); // ── Render coverage overlay (filled circles for served comunas when project selected) ── useEffect(() => { const map = mapRef.current; const L = window.L; if (!map || !L) return; layersRef.current.coverage.forEach((c) => map.removeLayer(c)); layersRef.current.coverage = []; if (selected?.type !== "project") return; const p = data.projects.find((x) => x.id === selected.id); if (!p) return; const color = data.areas[p.area].color; p.comunas.forEach((cname) => { const c = data.comunas.find((x) => x.name === cname); if (!c) return; const radius = (c.radiusKm || 2.5) * 1000; // meters // Outer glow ring (soft halo) const glow = L.circle([c.lat, c.lon], { radius: radius * 1.22, color, weight: 0, fillColor: color, fillOpacity: 0.10, interactive: false, className: "coverage-glow", }).addTo(map); layersRef.current.coverage.push(glow); // Main fill circle const circle = L.circle([c.lat, c.lon], { radius, color, weight: 2, opacity: 0.9, fillColor: color, fillOpacity: 0.28, interactive: false, className: "coverage-circle", }).addTo(map); layersRef.current.coverage.push(circle); // Connector line from project sede to each comuna center if (p.lat != null) { const line = L.polyline([[p.lat, p.lon], [c.lat, c.lon]], { color, weight: 1.8, opacity: 0.65, dashArray: "5 4", interactive: false, className: "coverage-line", }).addTo(map); layersRef.current.coverage.push(line); } // Label for highlighted comuna const label = L.marker([c.lat, c.lon], { icon: L.divIcon({ html: `
${c.name}
`, className: "coverage-label-icon", iconSize: [120, 22], iconAnchor: [60, -16], }), interactive: false, zIndexOffset: 900, }).addTo(map); layersRef.current.coverage.push(label); }); // Pulsing selection ring at the sede itself if (p.lat != null) { const selRing = L.circle([p.lat, p.lon], { radius: 500, color, weight: 2.5, opacity: 0.9, fillOpacity: 0, interactive: false, className: "coverage-sel-ring", }).addTo(map); layersRef.current.coverage.push(selRing); } }, [selected, data]); // ── HQ (sede principal) ── useEffect(() => { const map = mapRef.current; const L = window.L; if (!map || !L) return; if (layersRef.current.hq) map.removeLayer(layersRef.current.hq); const marker = L.marker([data.sede.lat, data.sede.lon], { icon: hqIcon(L), zIndexOffset: 2000, }); marker.on("click", () => setSelected({ type: "hq" })); marker.addTo(map); layersRef.current.hq = marker; }, []); // ── Region halos (background context at low zoom) ── useEffect(() => { const map = mapRef.current; const L = window.L; if (!map || !L) return; layersRef.current.regionHalos.forEach((r) => map.removeLayer(r)); layersRef.current.regionHalos = []; if (zoom < 11) { const rm = L.circle([-33.48, -70.62], { radius: 38000, color: "var(--red)", weight: 1.2, opacity: 0.4, fillColor: "var(--red)", fillOpacity: 0.05, dashArray: "4 4", interactive: false, }).addTo(map); const v = L.circle([-33.04, -71.58], { radius: 10000, color: "var(--red)", weight: 1.2, opacity: 0.4, fillColor: "var(--red)", fillOpacity: 0.05, dashArray: "4 4", interactive: false, }).addTo(map); layersRef.current.regionHalos.push(rm, v); } }, [zoom]); // ── Controls ── const handleZoom = (delta) => { const map = mapRef.current; if (!map) return; map.setZoom(map.getZoom() + delta); }; // ── Programmatic open-from-panel: fly to a project ── useEffect(() => { const map = mapRef.current; const L = window.L; if (!map || !L) return; if (selected?.type === "project") { const p = data.projects.find((x) => x.id === selected.id); if (p && p.lat != null) { const lats = [p.lat], lons = [p.lon]; p.comunas.forEach((cname) => { const c = data.comunas.find((x) => x.name === cname); if (c) { lats.push(c.lat); lons.push(c.lon); } }); if (lats.length > 1) { const bounds = L.latLngBounds(lats.map((la, i) => [la, lons[i]])); // Only fly if user isn't already viewing this area const currentBounds = map.getBounds(); if (!currentBounds.contains(bounds)) { map.flyToBounds(bounds, { padding: [60, 60], maxZoom: 13, duration: 0.8 }); } } } } }, [selected]); // count for region overlays const counts = useMemo(() => { const rm = data.projects.filter((p) => !p.generic && p.region === "RM").length; const v = data.projects.filter((p) => !p.generic && p.region === "V").length; const rmC = new Set( data.projects.flatMap((p) => (p.region === "RM" && !p.generic ? p.comunas : [])) ).size; const vC = new Set( data.projects.flatMap((p) => (p.region === "V" && !p.generic ? p.comunas : [])) ).size; return { rm, v, rmC, vC }; }, [data]); // Active selection banner data const activeBanner = (() => { if (!selected) return null; if (selected.type === "project") { const p = data.projects.find((x) => x.id === selected.id); if (!p) return null; return { label: p.name, color: data.areas[p.area]?.color }; } if (selected.type === "comuna") return { label: selected.name, color: "var(--ink)" }; if (selected.type === "hq") return { label: "Sede Central", color: "var(--red)" }; return null; })(); return (
{zoom <= 9.5 && regionPos.rm && ( )} {zoom <= 9.5 && regionPos.v && ( )} {activeBanner ? (
{activeBanner.label}
) : (
ZOOM {zoom.toFixed(1)}
)}
{[ { id: "panorama", label: "Panorámica" }, { id: "valparaiso", label: "Valparaíso" }, { id: "santiago", label: "Santiago" }, ].map((v) => ( ))}
N
); } window.MapView = MapView;