// 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 && (
REGIÓN
Metropolitana
{counts.rm} proyectos · {counts.rmC} comunas
)}
{zoom <= 9.5 && regionPos.v && (
REGIÓN DE
Valparaíso
{counts.v} proyectos · {counts.vC} comunas
)}
{activeBanner ? (
{activeBanner.label}
setSelected(null)}
aria-label="Cerrar detalle"
>×
) : (
ZOOM {zoom.toFixed(1)}
)}
{[
{ id: "panorama", label: "Panorámica" },
{ id: "valparaiso", label: "Valparaíso" },
{ id: "santiago", label: "Santiago" },
].map((v) => (
{
setView(v.id);
setSelected(null);
const map = mapRef.current;
if (map && VIEW_CENTER[v.id]) {
map.flyTo(VIEW_CENTER[v.id].center, VIEW_CENTER[v.id].zoom, {
duration: 1.0,
easeLinearity: 0.3,
});
}
}}
aria-pressed={view === v.id}
>
{v.label}
))}
handleZoom(-0.5)} aria-label="Alejar mapa">−
handleZoom(0.5)} aria-label="Acercar mapa">+
);
}
window.MapView = MapView;