Qué muestra este mapa

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:

  1. si la comuna está administrada por un SLEP (y cuál) o por el municipio (DAEM o Corporación);
  2. los ingresos en educación (SINIM, Ingreso Total Percibido);
  3. la deuda flotante devengada (Contraloría);
  4. los puntajes SIMCE de 4° básico y 2° medio, o “No hubo SIMCE ese año” en 2019-2021.

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.

Paquetes

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

Paso 1: datos de la planilla única

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

Paso 2: polígonos de las comunas (chilemapas)

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

Paso 3: texto del hover (a, b, c, d)

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)
  )

Paso 4: cruce con los polígonos

# ---- 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)

Paso 5: mapa con slider

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

Paso 6: ficha al hacer clic

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

Paso 7: exportar solo el mapa (para WordPress)

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)

Notas sobre los datos

  • SLEP: ingresos y deuda de las comunas SLEP son las cifras del SLEP completo (se repiten en sus 3 comunas); el SIMCE sí es por comuna.
  • 2018 en Barrancas: año de traspaso (1 de marzo); los ingresos son municipales parciales.
  • 2024 en Barrancas y Gabriela Mistral: sin ingresos ni deuda (pendiente en la planilla).
  • Corporaciones municipales: la Contraloría no registra su deuda flotante, por eso aparece como “No registrada”.
  • SIMCE 2019-2021: 2020 y 2021 no se aplicó; 2019 se excluyó por problemas de validez.