Un mapa de las 52 comunas de la Región Metropolitana con un slider de año (2018-2024). Al pasar el mouse sobre una comuna se ve:
Todo sale de la planilla única
(planilla_unica_RM_2018_2024.xlsx), que debe estar en la
misma carpeta que este archivo. Los polígonos vienen de
chilemapas.
install.packages(c("readxl", "dplyr", "sf", "plotly", "htmlwidgets", "stringi", "chilemapas"))
library(readxl)
library(dplyr)
library(sf)
library(plotly)
library(htmlwidgets)
library(chilemapas)
`%||%` <- function(a, b) if (is.null(a)) b else a
base <- read_excel("planilla_unica_RM_2018_2024.xlsx", sheet = "Base RM 2018-2024")
# Chequeo: 52 comunas x 7 años = 364 filas
dplyr::count(base, año)
## # A tibble: 7 × 2
## año n
## <dbl> <int>
## 1 2018 52
## 2 2019 52
## 3 2020 52
## 4 2021 52
## 5 2022 52
## 6 2023 52
## 7 2024 52
Se usan los polígonos de chilemapas en vez de los de
sinimr: traen las 52 comunas de la RM y no dependen de que
el SINIM responda. Se dejan en latitud/longitud, que es lo que
plotly espera para calcular bien la proporción del
mapa.
normalizar <- function(x) {
x <- toupper(stringi::stri_trans_general(x, "Latin-ASCII"))
gsub("[^A-Z]", "", x) # "TIL TIL" == "TILTIL", "SAN JOSÉ" == "SANJOSE"
}
geo_rm <- mapa_comunas %>%
filter(codigo_region == "13") %>%
left_join(codigos_territoriales %>% select(codigo_comuna, nombre_comuna),
by = "codigo_comuna") %>%
mutate(comuna_norm = normalizar(nombre_comuna)) %>%
st_as_sf() %>%
# Latitud/longitud (WGS84) a propósito: plotly corrige la proporción del mapa
# asumiendo grados. Si se proyecta a UTM (metros), esa corrección lo achata.
st_transform(4326)
nrow(geo_rm) # debe ser 52
## [1] 52
fmt_millones <- function(x) {
# Montos bajo $1 millón se muestran en pesos (ej. los $10.000 de deuda flotante
# que traen algunos SLEP), para no mostrar "$0 millones".
case_when(
is.na(x) ~ NA_character_,
abs(x) < 1e6 ~ paste0("$", format(round(x), big.mark = ".", decimal.mark = ",",
scientific = FALSE, trim = TRUE)),
TRUE ~ paste0("$", format(round(x / 1e6), big.mark = ".", decimal.mark = ",",
scientific = FALSE, trim = TRUE), " millones")
)
}
# Comunas de cada SLEP (para avisar que ingresos y deuda son del SLEP completo)
comunas_por_slep <- base %>%
filter(!is.na(slep)) %>%
distinct(slep, comuna) %>%
group_by(slep) %>%
summarise(comunas_slep = paste(sort(comuna), collapse = ", "), .groups = "drop")
anios_sin_simce <- c(2019, 2020, 2021) # 2020-2021 sin aplicación; 2019 excluido (problemas de validez)
datos <- base %>%
left_join(comunas_por_slep, by = "slep") %>%
mutate(
es_slep = !is.na(slep),
es_corporacion = grepl("^Corporaci", modalidad_administrativa),
# ---- a) Administración ----
grupo = case_when(
es_slep ~ "SLEP",
es_corporacion ~ "Municipal: Corporación",
TRUE ~ "Municipal: DAEM"
),
txt_admin = case_when(
es_slep & grepl("transici", modalidad_administrativa) ~
paste0("SLEP ", slep, " (año de traspaso)"),
es_slep ~ paste0("SLEP ", slep),
es_corporacion ~ "Sin SLEP · Corporación Municipal",
TRUE ~ "Sin SLEP · DAEM"
),
nota_slep = case_when(
es_slep & grepl("transici", modalidad_administrativa) ~
"<i>Año de traspaso: ingresos municipales parciales (antes del traspaso)</i><br>",
es_slep ~ paste0("<i>Ingresos y deuda corresponden al SLEP completo<br>(", comunas_slep, ")</i><br>"),
TRUE ~ ""),
# ---- b) Ingresos ----
txt_ingreso = coalesce(fmt_millones(ingreso_total_educ_pesos), "Sin dato reportado"),
# ---- c) Deuda flotante ----
txt_deuda = case_when(
!is.na(deuda_flotante_educ_devengado) ~ fmt_millones(deuda_flotante_educ_devengado),
es_corporacion ~ "No registrada (Corporación: fuera de la base de Contraloría)",
grepl("sin deuda", modalidad_administrativa) ~ "Sin deuda flotante registrada",
TRUE ~ "Sin dato"
),
# ---- d) SIMCE ----
txt_simce = ifelse(
año %in% anios_sin_simce,
"No hubo SIMCE ese año",
paste0("4° básico: Lectura ", coalesce(as.character(simce_4basico_lectura), "s/d"),
" · Matemática ", coalesce(as.character(simce_4basico_matematica), "s/d"), "<br>",
"2° medio: Lectura ", coalesce(as.character(simce_2medio_lectura), "s/d"),
" · Matemática ", coalesce(as.character(simce_2medio_matematica), "s/d"))
),
hover = paste0(
"<b>", comuna, "</b> · ", año, "<br>",
"<b>Administración:</b> ", txt_admin, "<br>",
nota_slep,
"<b>Ingresos en educación:</b> ", txt_ingreso, "<br>",
"<b>Deuda flotante (devengada):</b> ", txt_deuda, "<br>",
"<b>SIMCE</b><br>", txt_simce
),
comuna_norm = normalizar(comuna)
)
# ---- Chequeo de cruce: TODAS las comunas de la planilla deben tener polígono ----
sin_poligono <- setdiff(unique(datos$comuna_norm), geo_rm$comuna_norm)
if (length(sin_poligono) > 0) stop("Comunas sin polígono: ", paste(sin_poligono, collapse = ", "))
mapa <- geo_rm %>%
select(comuna_norm, geometry) %>%
inner_join(datos, by = "comuna_norm") %>%
arrange(año, comuna)
colores <- c("SLEP" = "#2C5F6F",
"Municipal: DAEM" = "#D9C9A3",
"Municipal: Corporación" = "#A89A80")
anios <- sort(unique(mapa$año))
anio_inicial <- max(anios)
# ---- Un trazo por comuna y año (polígono relleno) ----
p <- plot_ly()
for (i in seq_len(nrow(mapa))) {
fila <- mapa[i, ]
p <- add_sf(p, data = fila,
fillcolor = colores[[fila$grupo]],
line = list(color = "white", width = 0.7),
hoveron = "fills", hoverinfo = "text",
name = fila$comuna, showlegend = FALSE,
visible = fila$año == anio_inicial)
}
# Trazos "fantasma" solo para la leyenda: un punto diminuto en el centro del mapa
# (si llevan NA, plotly los descarta y la leyenda desaparece)
centro <- sf::st_coordinates(sf::st_centroid(sf::st_union(geo_rm)))
for (g in names(colores)) {
p <- add_trace(p, type = "scatter", mode = "markers",
x = centro[1, "X"], y = centro[1, "Y"],
marker = list(color = colores[[g]], size = 0.1, symbol = "square"),
name = g, showlegend = TRUE, hoverinfo = "skip", visible = TRUE)
}
b <- plotly_build(p)
# FIX DEL HOVER: con hoveron = "fills", plotly solo muestra el texto si es UN string
# por trazo; si es un vector (uno por vértice, como lo arma add_sf) cae al nombre del
# trazo -- por eso antes salía la categoría en vez del detalle de la comuna.
n_poly <- nrow(mapa)
for (k in seq_len(n_poly)) {
b$x$data[[k]]$text <- mapa$hover[k]
b$x$data[[k]]$hovertext <- NULL
b$x$data[[k]]$hovertemplate <- NULL
}
n_total <- length(b$x$data)
pasos <- lapply(anios, function(a) {
vis <- c(mapa$año == a, rep(TRUE, n_total - n_poly))
list(method = "update", label = as.character(a),
args = list(list(visible = vis),
list(title = list(text = paste0("Región Metropolitana · ", a)))))
})
b$x$layout$sliders <- list(list(
active = which(anios == anio_inicial) - 1,
currentvalue = list(prefix = "Año: ", font = list(size = 14)),
pad = list(t = 30),
steps = pasos
))
b$x$layout$title <- list(text = paste0("Región Metropolitana · ", anio_inicial))
b$x$layout$legend <- list(orientation = "h", y = 1.0, x = 0, itemsizing = "constant",
itemclick = FALSE, itemdoubleclick = FALSE)
b$x$layout$margin <- list(t = 70)
b$x$layout$font <- list(family = "Titillium Web")
b$x$layout$hoverlabel <- list(bgcolor = "#FAF7F0", align = "left",
font = list(family = "Titillium Web", size = 12))
b$x$layout$xaxis <- modifyList(b$x$layout$xaxis %||% list(), list(visible = FALSE))
b$x$layout$yaxis <- modifyList(b$x$layout$yaxis %||% list(), list(visible = FALSE))
mapa_widget <- b
El hover muestra el resumen del año seleccionado. Al hacer clic en una comuna se abre, debajo del mapa, su ficha con toda la trayectoria 2018-2024 (con el año del slider destacado), y la comuna queda marcada con borde negro. En un celular, donde casi no existe el hover, esta es la forma principal de leer el mapa.
# ---- Ficha al hacer clic: trayectoria 2018-2024 de la comuna ----
fichas_df <- datos %>%
arrange(comuna, año) %>%
transmute(
comuna, anio = año,
admin = txt_admin,
ingreso = txt_ingreso,
deuda = txt_deuda,
simce4 = ifelse(año %in% anios_sin_simce, "No hubo SIMCE",
paste0("L ", coalesce(as.character(simce_4basico_lectura), "s/d"),
" · M ", coalesce(as.character(simce_4basico_matematica), "s/d"))),
simce2 = ifelse(año %in% anios_sin_simce, "No hubo SIMCE",
paste0("L ", coalesce(as.character(simce_2medio_lectura), "s/d"),
" · M ", coalesce(as.character(simce_2medio_matematica), "s/d"))),
nota = gsub("<br>", " ", gsub("</?i>", "", nota_slep))
)
fichas <- lapply(split(fichas_df, fichas_df$comuna), function(d)
lapply(seq_len(nrow(d)), function(i) as.list(d[i, ])))
# Qué comuna corresponde a cada trazo (los trazos de leyenda quedan sin comuna)
trace_comuna <- c(as.list(mapa$comuna), rep(list(NULL), length(mapa_widget$x$data) - n_poly))
js_ficha <- "
function(el, x, data) {
var gd = el;
var panel = document.createElement('div');
panel.style.cssText = 'font-family:\"Titillium Web\",sans-serif;font-size:13px;margin:12px 0 4px;' +
'padding:12px 14px;border:1px solid #D9D2C3;border-radius:6px;background:#FAF7F0;overflow-x:auto;';
panel.innerHTML = '<span style=\"color:#666\">Haz clic en una comuna para ver su ficha 2018-2024.</span>';
el.parentNode.insertBefore(panel, el.nextSibling);
var sel = null, previos = [];
function anioActual() {
var s = gd._fullLayout.sliders[0]; return String(s.steps[s.active].label);
}
function resaltar(com) {
if (previos.length) Plotly.restyle(gd, {'line.width': 0.7, 'line.color': 'white'}, previos);
var idx = [];
data.trace_comuna.forEach(function(c, i) { if (c === com) idx.push(i); });
Plotly.restyle(gd, {'line.width': 3, 'line.color': '#1F1F1F'}, idx);
previos = idx;
}
function render() {
if (!sel) return;
var filas = data.fichas[sel], a = anioActual();
var nota = filas.map(function(r){return r.nota;}).filter(function(n){return n;}).pop() || '';
var h = '<div style=\"display:flex;justify-content:space-between;align-items:baseline\">' +
'<b style=\"font-size:16px;color:#2C5F6F\">' + sel + '</b>' +
'<a href=\"#\" class=\"cerrar-ficha\" style=\"color:#888;text-decoration:none\">✕ cerrar</a></div>';
if (nota) h += '<div style=\"color:#666;margin:2px 0 8px\"><i>' + nota + '</i></div>';
h += '<table style=\"border-collapse:collapse;width:100%;min-width:640px\"><thead><tr style=\"text-align:left;border-bottom:2px solid #2C5F6F\">' +
'<th style=\"padding:4px 6px\">Año</th><th style=\"padding:4px 6px\">Administración</th>' +
'<th style=\"padding:4px 6px\">Ingresos educación</th><th style=\"padding:4px 6px\">Deuda flotante</th>' +
'<th style=\"padding:4px 6px\">SIMCE 4° básico</th><th style=\"padding:4px 6px\">SIMCE 2° medio</th></tr></thead><tbody>';
filas.forEach(function(r) {
var act = String(r.anio) === a;
h += '<tr style=\"border-bottom:1px solid #E5DFD3;' + (act ? 'background:#E3ECEE;font-weight:600' : '') + '\">' +
'<td style=\"padding:4px 6px\">' + r.anio + '</td><td style=\"padding:4px 6px\">' + r.admin + '</td>' +
'<td style=\"padding:4px 6px\">' + r.ingreso + '</td><td style=\"padding:4px 6px\">' + r.deuda + '</td>' +
'<td style=\"padding:4px 6px\">' + r.simce4 + '</td><td style=\"padding:4px 6px\">' + r.simce2 + '</td></tr>';
});
panel.innerHTML = h + '</tbody></table>';
panel.querySelector('.cerrar-ficha').onclick = function(e) {
e.preventDefault(); sel = null;
if (previos.length) Plotly.restyle(gd, {'line.width': 0.7, 'line.color': 'white'}, previos);
previos = [];
panel.innerHTML = '<span style=\"color:#666\">Haz clic en una comuna para ver su ficha 2018-2024.</span>';
};
}
gd.on('plotly_click', function(ev) {
var com = data.trace_comuna[ev.points[0].curveNumber];
if (!com) return;
sel = com; resaltar(com); render();
});
gd.on('plotly_sliderchange', function() { setTimeout(render, 50); });
}"
mapa_widget <- htmlwidgets::onRender(mapa_widget, js_ficha,
data = list(fichas = fichas, trace_comuna = trace_comuna))
mapa_widget
Igual que con la línea de tiempo: genera un .html
autocontenido con la Titillium Web (gracias constanza cornejos)
mapa_web <- htmlwidgets::prependContent(
mapa_widget,
htmltools::tags$link(
rel = "stylesheet",
href = "https://fonts.googleapis.com/css2?family=Titillium+Web:wght@400;600&display=swap"
)
)
htmlwidgets::saveWidget(mapa_web, "mapa_slep_rm.html", selfcontained = TRUE)