edgarodriguez commited on
Commit
a3df526
·
verified ·
1 Parent(s): c3411ad

Upload 2 files

Browse files
Files changed (1) hide show
  1. depth_interactive_map_alpha.R +312 -144
depth_interactive_map_alpha.R CHANGED
@@ -91,14 +91,17 @@ PAM_PAL <- c("#ffffd9", "#edf8b1", "#c7e9b4", "#7fcdbb",
91
  ENVIPE_PAL <- c("#fef0d9", "#fdcc8a", "#fc8d59", "#e34a33",
92
  "#b30000", "#7f0000") # OrRd 6
93
 
 
 
 
 
 
94
  GIIM_COLORS <- c(
95
- "Nulo" = "#f8fafc",
96
- "Muy bajo" = "#dbeafe",
97
- "Bajo" = "#93c5fd",
98
- "Medio" = "#3b82f6",
99
- "Alto" = "#1d4ed8",
100
- "Muy alto" = "#1e3a8a",
101
- "No aplica" = "#94a3b8"
102
  )
103
 
104
  MIGRANTS_PAL <- c("#ffffb2", "#fecc5c", "#fd8d3c",
@@ -224,13 +227,13 @@ POP_COLS <- list(
224
 
225
  # PATP sub-types (kind controls paint family: "num" = step, "cat" = match).
226
  PATP_COLS <- list(
227
- "t_part" = list(col = "t_part", kind = "num", label = "Participation rate",
228
  breaks = c(35, 45, 50, 55, 60, 70)),
229
  "t_ocup" = list(col = "t_ocup", kind = "num", label = "Occupation rate",
230
  breaks = c(92, 95, 97, 98, 99, 99.5)),
231
- "pr" = list(col = "pr", kind = "num", label = "PR indicator",
232
  breaks = c(0.5, 2, 5, 10, 25, 50)),
233
- "ind_conec" = list(col = "ind_conec", kind = "num", label = "Connectivity index",
234
  breaks = c(0.15, 0.25, 0.35, 0.45, 0.6, 0.75)),
235
  "pr_pred_loc_rur" = list(col = "pr_pred_loc_rur", kind = "cat",
236
  label = "Predominant rural problem",
@@ -261,13 +264,16 @@ POBREZA_COLS <- list(
261
  # step through one month at a time. Decode: `year = ym %/% 12`, `month = ym %% 12 + 1`.
262
  # Example: Jan 2010 = 24120, Dec 2026 = 24323.
263
  TEMPORAL_LAYERS <- list(
264
- "migrants-pts" = list(label = "Missing migrants", color = COL_YEL,
265
  table = "migrants",
266
  # ym_expr drives both the chart cache and the point
267
  # loader; same DuckDB expression in both places so the
268
- # chart axis and the map filter agree.
 
 
269
  ym_expr = "incident_year * 12 + CAST(SUBSTR(month, 6, 2) AS INTEGER) - 1",
270
  point_ym_expr = "incident_year * 12 + CAST(SUBSTR(month, 6, 2) AS INTEGER) - 1",
 
271
  kind = "point"),
272
  "ocved-pts" = list(label = "OCVED violence", color = COL_AMBER,
273
  table = "ocved_events",
@@ -286,6 +292,15 @@ TEMPORAL_LAYERS <- list(
286
  table = "dim_pam_mun",
287
  ym_expr = "CAST(date_part('year', date) AS INTEGER) * 12 + CAST(date_part('month', date) AS INTEGER) - 1",
288
  agg = "SUM(pam_n)",
 
 
 
 
 
 
 
 
 
289
  kind = "polygon")
290
  )
291
  # Outer bounds for the slider in ym encoding: Jan 2010 .. Dec 2026.
@@ -619,6 +634,26 @@ WEB_SQL_TEMPLATES <- list(
619
  GROUP BY CVEGEO
620
  ) p ON c.CVEGEO = p.CVEGEO",
621
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
622
  temporal_yr_all =
623
  "SELECT {yr_expr} AS yr, COUNT(*) AS n FROM {tbl}
624
  WHERE {yr_col} IS NOT NULL
@@ -831,6 +866,13 @@ ddb_minmax <- function(con, tbl, col, filter_zero = TRUE) {
831
  as.numeric(DBI::dbGetQuery(con, web_sql_qbreaks(tbl, col, n))[1L, ]),
832
  error = function(e) seq_len(n - 1L)
833
  )
 
 
 
 
 
 
 
834
  list(
835
  crime_sf = tryCatch(
836
  DBI::dbGetQuery(con, web_sql_simplified("src_crime", tol = 0.005)) |>
@@ -841,7 +883,18 @@ ddb_minmax <- function(con, tbl, col, filter_zero = TRUE) {
841
  qb = list(
842
  crime = lapply(CRIME_COLS, function(info)
843
  qbq("crime_mun", info$col, length(CRIME_STEP_PAL))),
844
- desap = qbq("dim_desap_mun", "final_desap_nl_per_100k", length(DESAP_PAL)),
 
 
 
 
 
 
 
 
 
 
 
845
  pam = qbq("dim_pam_mun", "pam_n", length(PAM_PAL)),
846
  pop = qbq("crime_mun", "poblacion", length(POP_PAL))
847
  ),
@@ -1048,51 +1101,55 @@ WEB_SPEC <- list(
1048
  list(id = "hazards", label = "Hazards", open = TRUE, items = list(
1049
  list(kind = "layer", value = "crime-choro",
1050
  label = "Criminal incidence by municipality",
1051
- tooltip = "Rate of crimes per 100,000 people in each municipality. Select crime type using the buttons below.",
1052
- legend = list(type = "ramp", pal = "crime", unit = " /100k"),
 
1053
  checked = TRUE),
1054
  list(kind = "radios", group = "crime"),
1055
  list(kind = "layer", value = "migrants-pts",
1056
- label = "Missing migrant incidents by Internation Organization of Migration",
1057
- tooltip = "Each dot is a location where migrants went missing or died, recorded by the International Organization for Migration.",
1058
- legend = list(type = "dot", color = COL_YEL, label = "Incident location")),
1059
  list(kind = "layer", value = "desap-choro",
1060
- label = "Missing and no located persons per 100k (RNPDNO)",
1061
- tooltip = "People officially reported as disappeared and no located in each municipality, shown per 100,000 residents (2010 to 2024)",
 
1062
  legend = list(type = "ramp", pal = "desap", unit = " /100k")),
1063
  list(kind = "layer", value = "fosas-choro",
1064
  label = "Clandestine graves by municipality",
1065
  tooltip = "Number of clandestine graves and bodies found per municipality. Source: Plataforma Ciudadana de Fosas (2006-2024)",
1066
- legend = list(type = "ramp", pal = "fosas", unit = " pits")),
 
1067
  list(kind = "radios", group = "fosas"),
1068
  list(kind = "layer", value = "graves-pts",
1069
  label = "Clandestine burial sites",
1070
  tooltip = "Locations where clandestine grave sites have been discovered across Mexico.",
1071
- legend = list(type = "dot", color = COL_RED, label = "Burial site")),
1072
  list(kind = "layer", value = "ocved-pts",
1073
- label = "Organised criminal violence events (OCVED 2.0)",
1074
- tooltip = "Incidents of organised crime violence such as confrontations, attacks and criminal activities.",
1075
  legend = list(type = "dot", color = COL_AMBER, label = "Violence event")),
1076
  list(kind = "layer", value = "ged-pts",
1077
- label = "Armed conflict events (UCDP-GED)",
1078
- tooltip = "Armed conflict events recorded by Uppsala University's global conflict research dataset.",
1079
  legend = list(type = "dot", color = COL_LIGHT_BLUE, label = "Conflict event")),
1080
  list(kind = "layer", value = "envipe-state",
1081
- label = "Crime victimisation survey by state (ENVIPE)",
1082
- tooltip = "State-level survey on how safe people feel and how many crimes go unreported. Select metric below.",
1083
- legend = list(type = "ramp", pal = "envipe", unit = " %")),
 
1084
  list(kind = "radios", group = "envipe"),
1085
  list(kind = "soon", label = "Heat stress index")
1086
  )),
1087
  list(id = "exposure", label = "Exposure", open = FALSE, items = list(
1088
  list(kind = "layer", value = "pam-choro",
1089
- label = "Irregular migration events by municipality (PAM)",
1090
- tooltip = "Number of people who arrived at migrant assistance points or shelters in each municipality.",
1091
  legend = list(type = "ramp", pal = "pam", unit = " events")),
1092
  list(kind = "layer", value = "remesas-choro",
1093
- label = "Migration intensity index MEX-USA (GIIM)",
1094
- tooltip = "Index measuring the intensity of migration from Mexico to the United States, from none to very high.",
1095
- legend = list(type = "cats", pal = "giim")),
1096
  list(kind = "layer", value = "routes-lines",
1097
  label = "Main migration routes through Mexico",
1098
  tooltip = "Key routes migrants use to travel through Mexico towards the northern border.",
@@ -1102,9 +1159,10 @@ WEB_SPEC <- list(
1102
  tooltip = "Railway lines used by migrants, including freight routes known as 'La Bestia'.",
1103
  legend = list(type = "line", color = "#9E2A3A", label = "Railway", dashed = TRUE)),
1104
  list(kind = "layer", value = "pop-choro",
1105
- label = "Population (CPV 2020)",
1106
  tooltip = "Census 2020 population counts. With the grid view off, each municipality shows the sum of its CPV cells (3 sub-types: total, born abroad, resident abroad in 2015). With the grid view on, each rHEALPix cell shows its own value at the active nivel (n4-n7).",
1107
- legend = list(type = "ramp", pal = "pop", unit = " people")),
 
1108
  list(kind = "radios", group = "pop")
1109
  )),
1110
  list(id = "vulnerability", label = "Vulnerability", open = FALSE, items = list(
@@ -1113,9 +1171,10 @@ WEB_SPEC <- list(
1113
  tooltip = "Shelters, food, health, government, legal and religious assistance points along migrant routes. Click a swatch in the legend to filter.",
1114
  legend = list(type = "cats", pal = "infra_cats", filterable = "infra")),
1115
  list(kind = "layer", value = "patp-choro",
1116
- label = "PATP-CONEVAL municipal indicators",
1117
- tooltip = "Multi-dimensional CONEVAL PATP indicators. Numeric chips render as percentile choropleths; categorical chips (rural problem / crime focus) render with a fixed palette. Works both at municipal level and on the grid.",
1118
- legend = list(type = "ramp", pal = "patp", unit = ""),
 
1119
  # Variant legends shown when the matching categorical chip is active.
1120
  # data-variant-key on each wrap is matched by setPATPType in JS.
1121
  legend_extras = list(
@@ -1128,7 +1187,8 @@ WEB_SPEC <- list(
1128
  list(kind = "layer", value = "pobreza-choro",
1129
  label = "Poverty (CONEVAL 2020)",
1130
  tooltip = "CONEVAL 2020 poverty / extreme poverty share by municipality. Enable the grid view to see this layer.",
1131
- legend = list(type = "ramp", pal = "pobreza", unit = " %")),
 
1132
  list(kind = "radios", group = "pobreza")
1133
  ))
1134
  ),
@@ -1151,14 +1211,14 @@ WEB_SPEC <- list(
1151
  ),
1152
  pop = list(
1153
  list(label = "Total population", value = "POBTOT"),
1154
- list(label = "Born abroad", value = "PNACOP"),
1155
- list(label = "Resident abroad (2015)", value = "PRESOP2015")
1156
  ),
1157
  patp = list(
1158
- list(label = "Participation rate", value = "t_part"),
1159
  list(label = "Occupation rate", value = "t_ocup"),
1160
  list(label = "PR indicator", value = "pr"),
1161
- list(label = "Connectivity index", value = "ind_conec"),
1162
  list(label = "Predominant rural problem", value = "pr_pred_loc_rur"),
1163
  list(label = "Common-jurisdiction crime", value = "delitos_fc_b_j_a")
1164
  ),
@@ -1174,10 +1234,16 @@ WEB_SPEC <- list(
1174
  subtitle = "Summary of main indicators for migrants across Mexico.",
1175
  groups = list(
1176
  list(id = "hazards", label = "Hazards", open = TRUE, cards = list(
1177
- list(title = "Missing Migrants Incidents (IOM)", kind = "stat", chart = "temporal_plot",
1178
  stats = list(
1179
  list(id = "n_incidents", label = "Recorded incidents", color = COL_YEL),
1180
  list(id = "n_fatalities", label = "Confirmed dead", color = COL_RED)
 
 
 
 
 
 
1181
  )),
1182
  list(title = "Missing persons and not located (RNPDNO)", kind = "stat",
1183
  stats = list(list(id = "n_desap", label = "Disappearances & no located", color = COL_RISK))),
@@ -1523,7 +1589,8 @@ LUCIDE_JS <- tags$head(
1523
  // first). The chart follows only the most-recent one. ymLo/ymHi drive the
1524
  // map filter (client-side for points, server for PAM). ym encoding:
1525
  // ym = year * 12 + month - 1 (Jan 2010 = 24120, Dec 2026 = 24323).
1526
- var TEMPORAL_LAYER_IDS = ['migrants-pts','ocved-pts','ged-pts','pam-choro'];
 
1527
  // Point layers carry a unified 'ym' feature property via load_pts_with_ym
1528
  // so the client filter doesn't need to know each table's date format.
1529
  var TEMPORAL_POINT_COL = {
@@ -2416,17 +2483,32 @@ dp_stat <- function(val_id, label, color = "#1e293b") {
2416
  }
2417
 
2418
  ind_card <- function(title, body, chart_id = NULL, note = NULL, soon = FALSE,
2419
- half = FALSE, chart_height = "55px") {
 
2420
  cls <- paste("ind-card", if (isTRUE(half)) "ind-card--half" else "")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2421
  div(class = cls,
2422
  div(class = "ind-card-hdr",
2423
  span(class = "ind-card-title", title),
2424
  if (soon) tags$span(class = "acc-coming-tag", "Soon")
2425
  ),
2426
  if (!is.null(body)) div(class = "ind-card-body", body),
2427
- if (!is.null(chart_id))
2428
- div(class = "ind-card-chart",
2429
- plotOutput(chart_id, width = "100%", height = chart_height)),
2430
  if (!is.null(note))
2431
  div(class = "ind-card-note", note)
2432
  )
@@ -2448,12 +2530,22 @@ acc_legend_from_spec <- function(lg) {
2448
  pal <- if (!is.null(lg$pal)) WEB_SPEC$palettes[[lg$pal]] else NULL
2449
  switch(lg$type,
2450
  ramp = {
2451
- # Prefer hardcoded lo/hi when provided (back-compat); otherwise pull the
2452
- # real min/max from the cached data and format with the unit.
2453
- bnds <- legend_bounds(lg$pal, lg$unit %||% "")
2454
- acc_legend_ramp(pal,
2455
- lo = lg$lo %||% bnds$lo,
2456
- hi = lg$hi %||% bnds$hi)
 
 
 
 
 
 
 
 
 
 
2457
  },
2458
  cats = acc_legend_cats(pal, filterable_key = lg$filterable),
2459
  dot = acc_legend_dot(lg$color, lg$label %||% "Event / point"),
@@ -2522,6 +2614,7 @@ render_panel_card <- function(card) {
2522
  )
2523
  ind_card(card$title, body,
2524
  chart_id = card$chart,
 
2525
  note = card$note,
2526
  half = isTRUE(card$half),
2527
  chart_height = "55px")
@@ -4250,6 +4343,28 @@ server <- function(input, output, session) {
4250
  as.character(pl$cats %||% character(0)))
4251
  }, ignoreInit = TRUE, ignoreNULL = TRUE)
4252
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4253
  # --- Nav-bar search: fly to picked result -----------------------------------
4254
  observeEvent(input$search_flyto, {
4255
  pt <- input$search_flyto
@@ -4391,62 +4506,114 @@ server <- function(input, output, session) {
4391
  )
4392
  })
4393
 
4394
- output$temporal_plot <- renderPlot({
4395
- req(con)
4396
- # IOM temporal: incidents (COL_YEL) + confirmed dead (COL_RED) overlay
4397
- yr_expr_ts <- "CAST(date_part('year', incident_date_ts) AS INTEGER)"
4398
- yr_expr_raw <- "CAST(date_part('year', CAST(incident_date AS TIMESTAMP)) AS INTEGER)"
4399
- df_all <- tryCatch(
4400
- DBI::dbGetQuery(con, web_sql("temporal_mig_all", yr_expr = yr_expr_ts)),
4401
- error = function(e) tryCatch(
4402
- DBI::dbGetQuery(con, web_sql("temporal_mig_all", yr_expr = yr_expr_raw)),
4403
- error = function(e2) { message("temporal: ", e2$message); NULL }
4404
- )
4405
- )
4406
- if (is.null(df_all) || nrow(df_all) == 0) return(NULL)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4407
 
4408
- filter_wkt <- rv$temporal_filter_wkt
4409
- df_sel <- if (!is.null(filter_wkt)) {
4410
- tryCatch(DBI::dbGetQuery(con, web_sql("temporal_mig_sel",
4411
- yr_expr = yr_expr_ts, poly_wkt = filter_wkt)), error = function(e) NULL)
4412
- } else NULL
4413
-
4414
- COL_INC <- COL_YEL # incidents
4415
- COL_DEAD <- COL_RED # confirmed dead
4416
-
4417
- p <- ggplot(df_all, aes(x = yr)) +
4418
- # Incidents area + line
4419
- geom_area(aes(y = n), fill = COL_INC, alpha = 0.10, color = NA) +
4420
- geom_line(aes(y = n), color = COL_INC, linewidth = 0.7, alpha = 0.55) +
4421
- # Confirmed dead overlay
4422
- geom_line(aes(y = n_dead), color = COL_DEAD, linewidth = 0.7,
4423
- alpha = 0.6, linetype = "22")
4424
- if (!is.null(df_sel) && nrow(df_sel) > 0) {
4425
- p <- p +
4426
- geom_line(data = df_sel, aes(y = n),
4427
- color = COL_INC, linewidth = 1.1) +
4428
- geom_point(data = df_sel, aes(y = n),
4429
- color = COL_INC, size = 1.5, shape = 21,
4430
- fill = "white", stroke = 0.7) +
4431
- geom_line(data = df_sel, aes(y = n_dead),
4432
- color = COL_DEAD, linewidth = 1.0, linetype = "22") +
4433
- geom_point(data = df_sel, aes(y = n_dead),
4434
- color = COL_DEAD, size = 1.3, shape = 21,
4435
- fill = "white", stroke = 0.7)
 
 
 
 
 
 
 
 
 
 
 
 
 
4436
  }
4437
- p + theme_minimal(base_size = 8) +
4438
- theme(
4439
- plot.background = element_rect(fill = "transparent", color = NA),
4440
- panel.background = element_rect(fill = "transparent", color = NA),
4441
- panel.grid = element_blank(),
4442
- panel.grid.major.y = element_line(color = "#e2e8f0", linewidth = 0.3),
4443
- axis.title = element_blank(),
4444
- axis.text.x = element_text(size = 7, color = "#64748b"),
4445
- axis.text.y = element_blank(),
4446
- axis.ticks = element_blank(),
4447
- plot.margin = margin(1, 4, 1, 4)
4448
- )
4449
- }, bg = "transparent", res = 96)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4450
 
4451
  # Shared helper: temporal bar chart by year for point event tables
4452
  make_temporal_yr <- function(tbl, yr_col, line_color) {
@@ -4561,45 +4728,45 @@ server <- function(input, output, session) {
4561
  ))
4562
  }, ignoreInit = TRUE)
4563
 
4564
- # PAM month-window re-aggregation: when the strip range changes and PAM is
4565
- # active, re-query src_pam_ym + push the new fill via the proxy. The current
4566
- # layer is removed and re-added with the ym-filtered source. The observer is
4567
- # JS-debounced (200ms) so dragging the slider doesn't queue dozens of heavy
4568
- # re-aggregations. Full-range windows short-circuit -- the unfiltered layer
4569
- # already on the map IS the answer for the full range, no re-query needed.
 
 
 
 
4570
  observeEvent(input$temporal_filter, {
4571
  pl <- input$temporal_filter
4572
  if (is.null(pl)) return()
4573
  active <- pl$active %||% character(0)
4574
- if (!("pam-choro" %in% active) || !isTRUE(lazy_added$pam)) return()
4575
  ym_lo <- pl$start %||% TEMPORAL_RANGE[1]
4576
  ym_hi <- pl$end %||% TEMPORAL_RANGE[2]
4577
- # Short-circuit: full-range (relative to PAM's actual data span) = no
4578
- # filtering = leave the unfiltered layer alone. This is what prevents
4579
- # PAM-on freezing the app: the very first syncTemporalStrip after
4580
- # lazy_add_fill fires temporal_filter with the default full range, and
4581
- # without this guard the heavy 2475-row aggregate ran a second time
4582
- # and triggered a remove+re-add on a layer still being committed to
4583
- # the MapLibre source.
4584
- df_pam <- .APP_CACHE$temporal[["pam-choro"]]
4585
- pam_data_lo <- if (!is.null(df_pam)) min(df_pam$ym) else TEMPORAL_RANGE[1]
4586
- pam_data_hi <- if (!is.null(df_pam)) max(df_pam$ym) else TEMPORAL_RANGE[2]
4587
- if (ym_lo <= pam_data_lo && ym_hi >= pam_data_hi) return()
4588
- sf_data <- tryCatch({
4589
- DBI::dbGetQuery(con,
4590
- web_sql("src_pam_ym", ym_lo = ym_lo, ym_hi = ym_hi)) |>
4591
- sf::st_as_sf(wkt = "wkt", crs = 4326) |>
4592
- dplyr::select(-wkt)
4593
- }, error = function(e) { message("PAM ym re-query: ", e$message); NULL })
4594
- if (is.null(sf_data)) return()
4595
- # Swap the source data on the existing pam-choro layer instead of
4596
- # remove + re-add. mapgl's add_fill_layer creates a source named after
4597
- # the layer ID; remove_layer doesn't drop the source, so the previous
4598
- # pattern was hitting "source already exists" on the second activation
4599
- # and leaving the map in a broken state -- this is what was making the
4600
- # app appear to freeze whenever PAM was re-enabled.
4601
- maplibre_proxy("map") |>
4602
- set_source(layer_id = "pam-choro", source = sf_data)
4603
  }, ignoreNULL = TRUE, ignoreInit = TRUE)
4604
 
4605
  # --- PAM temporal plot (irregular migration events over time) ---------------
@@ -4711,7 +4878,8 @@ server <- function(input, output, session) {
4711
  # (otherwise hidden outputs stay suspended and read "---"). Cost: one extra
4712
  # render per output per session -- negligible (cached strings + small plots).
4713
  for (.oid in c(
4714
- "temporal_pam", "temporal_plot", "temporal_ocved", "temporal_ged",
 
4715
  "breakdown_giim", "breakdown_infra",
4716
  "n_incidents", "n_fatalities", "n_graves", "n_facilities", "n_events",
4717
  "ocved_n", "n_ged", "n_mun", "n_desap", "n_pam", "n_fosas", "n_cuerpos",
 
91
  ENVIPE_PAL <- c("#fef0d9", "#fdcc8a", "#fc8d59", "#e34a33",
92
  "#b30000", "#7f0000") # OrRd 6
93
 
94
+ # GIIM keys MUST match the Spanish category strings stored in
95
+ # dim_remesas_mun.giim_mun (Muy bajo / Bajo / Medio / Alto / Muy alto) so the
96
+ # MapLibre `match` paint expression actually pairs each muni with a colour.
97
+ # "No aplica" and "Nulo" are intentionally absent here -- they paint via the
98
+ # match-fallback to fully-transparent and are filtered out of the legend.
99
  GIIM_COLORS <- c(
100
+ "Muy bajo" = "#dbeafe",
101
+ "Bajo" = "#93c5fd",
102
+ "Medio" = "#3b82f6",
103
+ "Alto" = "#1d4ed8",
104
+ "Muy alto" = "#1e3a8a"
 
 
105
  )
106
 
107
  MIGRANTS_PAL <- c("#ffffb2", "#fecc5c", "#fd8d3c",
 
227
 
228
  # PATP sub-types (kind controls paint family: "num" = step, "cat" = match).
229
  PATP_COLS <- list(
230
+ "t_part" = list(col = "t_part", kind = "num", label = "Economic participation rate",
231
  breaks = c(35, 45, 50, 55, 60, 70)),
232
  "t_ocup" = list(col = "t_ocup", kind = "num", label = "Occupation rate",
233
  breaks = c(92, 95, 97, 98, 99, 99.5)),
234
+ "pr" = list(col = "pr", kind = "num", label = "Remittance receipt per capita",
235
  breaks = c(0.5, 2, 5, 10, 25, 50)),
236
+ "ind_conec" = list(col = "ind_conec", kind = "num", label = "Road connectivity index",
237
  breaks = c(0.15, 0.25, 0.35, 0.45, 0.6, 0.75)),
238
  "pr_pred_loc_rur" = list(col = "pr_pred_loc_rur", kind = "cat",
239
  label = "Predominant rural problem",
 
264
  # step through one month at a time. Decode: `year = ym %/% 12`, `month = ym %% 12 + 1`.
265
  # Example: Jan 2010 = 24120, Dec 2026 = 24323.
266
  TEMPORAL_LAYERS <- list(
267
+ "migrants-pts" = list(label = "Death during migration", color = COL_YEL,
268
  table = "migrants",
269
  # ym_expr drives both the chart cache and the point
270
  # loader; same DuckDB expression in both places so the
271
+ # chart axis and the map filter agree. agg switches the
272
+ # strip from raw incidents (COUNT(*)) to confirmed
273
+ # deaths (SUM n_dead) -- the indicator of interest.
274
  ym_expr = "incident_year * 12 + CAST(SUBSTR(month, 6, 2) AS INTEGER) - 1",
275
  point_ym_expr = "incident_year * 12 + CAST(SUBSTR(month, 6, 2) AS INTEGER) - 1",
276
+ agg = "SUM(COALESCE(n_dead, 0))",
277
  kind = "point"),
278
  "ocved-pts" = list(label = "OCVED violence", color = COL_AMBER,
279
  table = "ocved_events",
 
292
  table = "dim_pam_mun",
293
  ym_expr = "CAST(date_part('year', date) AS INTEGER) * 12 + CAST(date_part('month', date) AS INTEGER) - 1",
294
  agg = "SUM(pam_n)",
295
+ kind = "polygon"),
296
+ # Clandestine graves are recorded at annual resolution (one (CVEGEO, year)
297
+ # row per muni). Encode year as ym = year * 12 (Jan of that year) so the
298
+ # bars land cleanly on year tick marks even though the slider counts months.
299
+ "fosas-choro" = list(label = "Clandestine graves",
300
+ color = "#fec44f", # FOSAS_PAL mid (amber)
301
+ table = "dim_fosas_mun",
302
+ ym_expr = "year * 12",
303
+ agg = "SUM(fosas_total)",
304
  kind = "polygon")
305
  )
306
  # Outer bounds for the slider in ym encoding: Jan 2010 .. Dec 2026.
 
634
  GROUP BY CVEGEO
635
  ) p ON c.CVEGEO = p.CVEGEO",
636
 
637
+ # ym-window-filtered fosas aggregate. dim_fosas_mun is annual (one row per
638
+ # CVEGEO with that muni's recorded year of activity), so the ym filter
639
+ # operates on year * 12. fosas_total / cuerpos_total are SUMmed in case
640
+ # any muni ever ends up with multiple rows.
641
+ src_fosas_ym =
642
+ "SELECT c.municipio, c.entidad, c.CVEGEO,
643
+ COALESCE(CAST(f.fosas_sum AS INTEGER), 0) AS fosas_total,
644
+ COALESCE(CAST(f.cuerpos_sum AS INTEGER), 0) AS cuerpos_total,
645
+ c.wkt
646
+ FROM crime_mun c
647
+ LEFT JOIN (
648
+ SELECT CVEGEO,
649
+ SUM(fosas_total) AS fosas_sum,
650
+ SUM(cuerpos_total) AS cuerpos_sum
651
+ FROM dim_fosas_mun
652
+ WHERE year IS NOT NULL
653
+ AND (year * 12) BETWEEN {ym_lo} AND {ym_hi}
654
+ GROUP BY CVEGEO
655
+ ) f ON c.CVEGEO = f.CVEGEO",
656
+
657
  temporal_yr_all =
658
  "SELECT {yr_expr} AS yr, COUNT(*) AS n FROM {tbl}
659
  WHERE {yr_col} IS NOT NULL
 
866
  as.numeric(DBI::dbGetQuery(con, web_sql_qbreaks(tbl, col, n))[1L, ]),
867
  error = function(e) seq_len(n - 1L)
868
  )
869
+ # Arbitrary-SQL variant: lets a layer compute its breaks on a derived view
870
+ # (e.g. per-muni averages) so the colour ramp aligns with the painted
871
+ # values rather than raw row-level distribution.
872
+ qbq_sql <- function(sql) tryCatch(
873
+ as.numeric(DBI::dbGetQuery(con, sql)[1L, ]),
874
+ error = function(e) NULL
875
+ )
876
  list(
877
  crime_sf = tryCatch(
878
  DBI::dbGetQuery(con, web_sql_simplified("src_crime", tol = 0.005)) |>
 
883
  qb = list(
884
  crime = lapply(CRIME_COLS, function(info)
885
  qbq("crime_mun", info$col, length(CRIME_STEP_PAL))),
886
+ # desap is averaged per-CVEGEO in src_desap, so the ramp also has to be
887
+ # computed on per-muni averages -- not on the raw row distribution
888
+ # (which collapses 5 of 6 buckets into a narrow low-end window).
889
+ desap = qbq_sql(paste0(
890
+ "WITH m AS (SELECT CVEGEO, AVG(final_desap_nl_per_100k) AS r ",
891
+ " FROM dim_desap_mun GROUP BY CVEGEO HAVING r > 0) ",
892
+ "SELECT ",
893
+ paste0("PERCENTILE_CONT(", round(seq_len(length(DESAP_PAL) - 1L) / length(DESAP_PAL), 8),
894
+ ") WITHIN GROUP (ORDER BY r)",
895
+ collapse = ", "),
896
+ " FROM m"
897
+ )) %||% qbq("dim_desap_mun", "final_desap_nl_per_100k", length(DESAP_PAL)),
898
  pam = qbq("dim_pam_mun", "pam_n", length(PAM_PAL)),
899
  pop = qbq("crime_mun", "poblacion", length(POP_PAL))
900
  ),
 
1101
  list(id = "hazards", label = "Hazards", open = TRUE, items = list(
1102
  list(kind = "layer", value = "crime-choro",
1103
  label = "Criminal incidence by municipality",
1104
+ tooltip = "Rate of crimes per 100,000 people in each municipality. Select crime type using the buttons below. Source: Registro Nacional de Incidencia Delictiva (RNID)",
1105
+ legend = list(type = "ramp", pal = "crime", unit = " /100k",
1106
+ labels = "qualitative"),
1107
  checked = TRUE),
1108
  list(kind = "radios", group = "crime"),
1109
  list(kind = "layer", value = "migrants-pts",
1110
+ label = "Deaths during migration",
1111
+ tooltip = "Each dot is a location where migrants went died, recorded by the International Organization for Migration.",
1112
+ legend = list(type = "dot", color = COL_YEL, label = "Death location")),
1113
  list(kind = "layer", value = "desap-choro",
1114
+ label = "Missing and no located persons per 100k",
1115
+ tooltip = "People officially reported as disappeared and no located in each municipality, shown per 100,000 residents (2010 to 2024). Source:
1116
+ Registro Nacional de Personas Desaparecidas y No Localizadas (RNPDNO)",
1117
  legend = list(type = "ramp", pal = "desap", unit = " /100k")),
1118
  list(kind = "layer", value = "fosas-choro",
1119
  label = "Clandestine graves by municipality",
1120
  tooltip = "Number of clandestine graves and bodies found per municipality. Source: Plataforma Ciudadana de Fosas (2006-2024)",
1121
+ legend = list(type = "ramp", pal = "fosas", unit = " pits/bodies",
1122
+ labels = "qualitative")),
1123
  list(kind = "radios", group = "fosas"),
1124
  list(kind = "layer", value = "graves-pts",
1125
  label = "Clandestine burial sites",
1126
  tooltip = "Locations where clandestine grave sites have been discovered across Mexico.",
1127
+ legend = list(type = "dot", color = COL_RED, label = "Site location")),
1128
  list(kind = "layer", value = "ocved-pts",
1129
+ label = "Organised criminal violence events",
1130
+ tooltip = "Incidents of organised crime violence such as confrontations, attacks and criminal activities. Source: (OCVED 2.0)",
1131
  legend = list(type = "dot", color = COL_AMBER, label = "Violence event")),
1132
  list(kind = "layer", value = "ged-pts",
1133
+ label = "Armed conflict events",
1134
+ tooltip = "Armed conflict events recorded by Uppsala University's global conflict research dataset. Source: (UCDP-GED)",
1135
  legend = list(type = "dot", color = COL_LIGHT_BLUE, label = "Conflict event")),
1136
  list(kind = "layer", value = "envipe-state",
1137
+ label = "Crime victimisation survey by state",
1138
+ tooltip = "State-level survey on how safe people feel and how many crimes go unreported. Select metric below. Source: National Survey of Victimization and Perception of Public Safety (ENVIPE)",
1139
+ legend = list(type = "ramp", pal = "envipe", unit = " %",
1140
+ labels = "qualitative")),
1141
  list(kind = "radios", group = "envipe"),
1142
  list(kind = "soon", label = "Heat stress index")
1143
  )),
1144
  list(id = "exposure", label = "Exposure", open = FALSE, items = list(
1145
  list(kind = "layer", value = "pam-choro",
1146
+ label = "Irregular migration events by municipality",
1147
+ tooltip = "Events involving people who entered Mexican territory without immigration documents proving their legal stay and were subjected to an Administrative Immigration Procedure (PAM). Source: Unidad de Política Migratoria. Gobierno de México",
1148
  legend = list(type = "ramp", pal = "pam", unit = " events")),
1149
  list(kind = "layer", value = "remesas-choro",
1150
+ label = "Degree of Migration Intensity MEX-USA (GIIM)",
1151
+ tooltip = "Index measuring the intensity of migration from Mexico to the United States, from none to very high. Source: CONAPO",
1152
+ legend = list(type = "cats", pal = "giim", filterable = "remesas")),
1153
  list(kind = "layer", value = "routes-lines",
1154
  label = "Main migration routes through Mexico",
1155
  tooltip = "Key routes migrants use to travel through Mexico towards the northern border.",
 
1159
  tooltip = "Railway lines used by migrants, including freight routes known as 'La Bestia'.",
1160
  legend = list(type = "line", color = "#9E2A3A", label = "Railway", dashed = TRUE)),
1161
  list(kind = "layer", value = "pop-choro",
1162
+ label = "Population (Census 2020)",
1163
  tooltip = "Census 2020 population counts. With the grid view off, each municipality shows the sum of its CPV cells (3 sub-types: total, born abroad, resident abroad in 2015). With the grid view on, each rHEALPix cell shows its own value at the active nivel (n4-n7).",
1164
+ legend = list(type = "ramp", pal = "pop", unit = " people",
1165
+ labels = "qualitative")),
1166
  list(kind = "radios", group = "pop")
1167
  )),
1168
  list(id = "vulnerability", label = "Vulnerability", open = FALSE, items = list(
 
1171
  tooltip = "Shelters, food, health, government, legal and religious assistance points along migrant routes. Click a swatch in the legend to filter.",
1172
  legend = list(type = "cats", pal = "infra_cats", filterable = "infra")),
1173
  list(kind = "layer", value = "patp-choro",
1174
+ label = "Territorial analysis of poverty",
1175
+ tooltip = "Multi-dimensional poverty analysis, including sociodemographics, infrastructure and government. Source: CONEVAL",
1176
+ legend = list(type = "ramp", pal = "patp", unit = "",
1177
+ labels = "qualitative"),
1178
  # Variant legends shown when the matching categorical chip is active.
1179
  # data-variant-key on each wrap is matched by setPATPType in JS.
1180
  legend_extras = list(
 
1187
  list(kind = "layer", value = "pobreza-choro",
1188
  label = "Poverty (CONEVAL 2020)",
1189
  tooltip = "CONEVAL 2020 poverty / extreme poverty share by municipality. Enable the grid view to see this layer.",
1190
+ legend = list(type = "ramp", pal = "pobreza", unit = " %",
1191
+ labels = "qualitative")),
1192
  list(kind = "radios", group = "pobreza")
1193
  ))
1194
  ),
 
1211
  ),
1212
  pop = list(
1213
  list(label = "Total population", value = "POBTOT"),
1214
+ list(label = "Foreign population", value = "PNACOP"),
1215
+ list(label = "Recent resident (abroad in 2015)", value = "PRESOP2015")
1216
  ),
1217
  patp = list(
1218
+ list(label = "Economic Participation rate", value = "t_part"),
1219
  list(label = "Occupation rate", value = "t_ocup"),
1220
  list(label = "PR indicator", value = "pr"),
1221
+ list(label = "connectivity index", value = "ind_conec"),
1222
  list(label = "Predominant rural problem", value = "pr_pred_loc_rur"),
1223
  list(label = "Common-jurisdiction crime", value = "delitos_fc_b_j_a")
1224
  ),
 
1234
  subtitle = "Summary of main indicators for migrants across Mexico.",
1235
  groups = list(
1236
  list(id = "hazards", label = "Hazards", open = TRUE, cards = list(
1237
+ list(title = "Missing Migrants Incidents (IOM)", kind = "stat",
1238
  stats = list(
1239
  list(id = "n_incidents", label = "Recorded incidents", color = COL_YEL),
1240
  list(id = "n_fatalities", label = "Confirmed dead", color = COL_RED)
1241
+ ),
1242
+ charts = list(
1243
+ list(id = "migrants_treemap_cause", label = "Cause of death",
1244
+ height = "70px"),
1245
+ list(id = "migrants_treemap_demo", label = "Demographics (children / female / male)",
1246
+ height = "44px")
1247
  )),
1248
  list(title = "Missing persons and not located (RNPDNO)", kind = "stat",
1249
  stats = list(list(id = "n_desap", label = "Disappearances & no located", color = COL_RISK))),
 
1589
  // first). The chart follows only the most-recent one. ymLo/ymHi drive the
1590
  // map filter (client-side for points, server for PAM). ym encoding:
1591
  // ym = year * 12 + month - 1 (Jan 2010 = 24120, Dec 2026 = 24323).
1592
+ var TEMPORAL_LAYER_IDS = ['migrants-pts','ocved-pts','ged-pts',
1593
+ 'pam-choro','fosas-choro'];
1594
  // Point layers carry a unified 'ym' feature property via load_pts_with_ym
1595
  // so the client filter doesn't need to know each table's date format.
1596
  var TEMPORAL_POINT_COL = {
 
2483
  }
2484
 
2485
  ind_card <- function(title, body, chart_id = NULL, note = NULL, soon = FALSE,
2486
+ half = FALSE, chart_height = "55px",
2487
+ charts = NULL) {
2488
  cls <- paste("ind-card", if (isTRUE(half)) "ind-card--half" else "")
2489
+ # `charts` (plural) is a list of list(id, label, height) for cards that need
2490
+ # more than one plot slot (e.g. the migrants card pairs a cause-of-death
2491
+ # treemap with a demographic-split treemap). If both `chart_id` and `charts`
2492
+ # are NULL the card renders body-only.
2493
+ chart_block <- if (!is.null(charts)) {
2494
+ tagList(lapply(charts, function(c) {
2495
+ div(class = "ind-card-chart",
2496
+ if (!is.null(c$label))
2497
+ tags$div(class = "ind-card-chart-label", c$label),
2498
+ plotOutput(c$id, width = "100%",
2499
+ height = c$height %||% chart_height))
2500
+ }))
2501
+ } else if (!is.null(chart_id)) {
2502
+ div(class = "ind-card-chart",
2503
+ plotOutput(chart_id, width = "100%", height = chart_height))
2504
+ }
2505
  div(class = cls,
2506
  div(class = "ind-card-hdr",
2507
  span(class = "ind-card-title", title),
2508
  if (soon) tags$span(class = "acc-coming-tag", "Soon")
2509
  ),
2510
  if (!is.null(body)) div(class = "ind-card-body", body),
2511
+ chart_block,
 
 
2512
  if (!is.null(note))
2513
  div(class = "ind-card-note", note)
2514
  )
 
2530
  pal <- if (!is.null(lg$pal)) WEB_SPEC$palettes[[lg$pal]] else NULL
2531
  switch(lg$type,
2532
  ramp = {
2533
+ # Layers that switch the painted column via a chip (crime, fosas, ...)
2534
+ # can't show a single column's min/max -- the bounds change with every
2535
+ # chip pick. `labels = "qualitative"` opts those layers into static
2536
+ # "Low"/"High" labels.
2537
+ if (identical(lg$labels, "qualitative")) {
2538
+ acc_legend_ramp(pal,
2539
+ lo = lg$lo %||% "Low",
2540
+ hi = lg$hi %||% "High")
2541
+ } else {
2542
+ # Prefer hardcoded lo/hi when provided (back-compat); otherwise pull
2543
+ # the real min/max from the cached data and format with the unit.
2544
+ bnds <- legend_bounds(lg$pal, lg$unit %||% "")
2545
+ acc_legend_ramp(pal,
2546
+ lo = lg$lo %||% bnds$lo,
2547
+ hi = lg$hi %||% bnds$hi)
2548
+ }
2549
  },
2550
  cats = acc_legend_cats(pal, filterable_key = lg$filterable),
2551
  dot = acc_legend_dot(lg$color, lg$label %||% "Event / point"),
 
2614
  )
2615
  ind_card(card$title, body,
2616
  chart_id = card$chart,
2617
+ charts = card$charts,
2618
  note = card$note,
2619
  half = isTRUE(card$half),
2620
  chart_height = "55px")
 
4343
  as.character(pl$cats %||% character(0)))
4344
  }, ignoreInit = TRUE, ignoreNULL = TRUE)
4345
 
4346
+ # --- Legend filter: remesas-choro by GIIM category --------------------------
4347
+ # Same click-a-swatch-to-toggle pattern; column is `giim_mun` (Spanish
4348
+ # category strings stored in dim_remesas_mun.giim_mun). "No aplica" and
4349
+ # "Nulo" are not in GIIM_COLORS at all, so they paint transparent via
4350
+ # match-fallback and never appear in the legend.
4351
+ observeEvent(input$remesas_filter, {
4352
+ pl <- input$remesas_filter
4353
+ if (is.null(pl) || !isTRUE(lazy_added$remesas)) return()
4354
+ visible <- as.character(pl$cats %||% character(0))
4355
+ prx <- maplibre_proxy("map")
4356
+ if (length(visible) == 0) {
4357
+ prx |> set_filter("remesas-choro",
4358
+ list("==", list("get", "giim_mun"), "__none__"))
4359
+ } else if (length(visible) >= length(GIIM_COLORS)) {
4360
+ prx |> set_filter("remesas-choro", NULL)
4361
+ } else {
4362
+ prx |> set_filter("remesas-choro",
4363
+ list("in", list("get", "giim_mun"),
4364
+ list("literal", as.list(visible))))
4365
+ }
4366
+ }, ignoreInit = TRUE, ignoreNULL = TRUE)
4367
+
4368
  # --- Nav-bar search: fly to picked result -----------------------------------
4369
  observeEvent(input$search_flyto, {
4370
  pt <- input$search_flyto
 
4506
  )
4507
  })
4508
 
4509
+ # --- Migrants treemaps (replaces the old single time-series plot) ----------
4510
+ # Two slot card: cause-of-death and demographic split. Both honour the
4511
+ # current panel selection (whole-country when no polygon is drawn, restricted
4512
+ # to the in-polygon subset when one is). Treemap geometry is a 2-row slice-
4513
+ # and-dice -- the dominant category gets the top row, smaller categories
4514
+ # tile the bottom row. Avoids the treemapify dependency.
4515
+ CAUSE_PAL <- c("Other / unknown" = "#6b7280",
4516
+ "Drowning" = "#0ea5e9",
4517
+ "Harsh environment" = "#f59e0b",
4518
+ "Vehicle accident" = "#a855f7",
4519
+ "Violence" = "#ef4444")
4520
+ DEMO_PAL <- c(Children = "#f97316",
4521
+ Female = "#ec4899",
4522
+ Male = "#0987A8")
4523
+
4524
+ treemap_layout <- function(df) {
4525
+ # df: data.frame(label, value, color); returns df with xmin/xmax/ymin/ymax
4526
+ df <- df[!is.na(df$value) & df$value > 0, , drop = FALSE]
4527
+ if (nrow(df) == 0) return(NULL)
4528
+ df <- df[order(-df$value), , drop = FALSE]
4529
+ total <- sum(df$value)
4530
+ # Top row holds the leader; remainder tiles the bottom row.
4531
+ leader <- df[1, , drop = FALSE]
4532
+ rest <- if (nrow(df) > 1) df[-1, , drop = FALSE] else NULL
4533
+ leader$xmin <- 0; leader$xmax <- 1
4534
+ leader_share <- leader$value / total
4535
+ leader$ymax <- 1; leader$ymin <- 1 - leader_share
4536
+ if (!is.null(rest) && nrow(rest) > 0) {
4537
+ rest_share <- rest$value / sum(rest$value)
4538
+ rest$xmax <- cumsum(rest_share)
4539
+ rest$xmin <- c(0, head(rest$xmax, -1))
4540
+ rest$ymin <- 0; rest$ymax <- 1 - leader_share
4541
+ rbind(leader, rest)
4542
+ } else {
4543
+ leader$ymin <- 0; leader$ymax <- 1
4544
+ leader
4545
+ }
4546
+ }
4547
 
4548
+ treemap_plot <- function(df, pal, base_size = 7) {
4549
+ laid <- treemap_layout(df)
4550
+ if (is.null(laid)) return(NULL)
4551
+ laid$pct <- round(100 * laid$value / sum(laid$value))
4552
+ laid$label_full <- paste0(laid$label, "\n", laid$pct, "%")
4553
+ ggplot(laid, aes(xmin = xmin, xmax = xmax,
4554
+ ymin = ymin, ymax = ymax,
4555
+ fill = label)) +
4556
+ geom_rect(color = "#0f172a", linewidth = 0.4) +
4557
+ geom_text(aes(x = (xmin + xmax) / 2,
4558
+ y = (ymin + ymax) / 2,
4559
+ label = label_full),
4560
+ size = base_size / .pt * 2.4, color = "white",
4561
+ fontface = "bold", lineheight = 0.85) +
4562
+ scale_fill_manual(values = pal, na.value = "#334155") +
4563
+ coord_cartesian(expand = FALSE, xlim = c(0, 1), ylim = c(0, 1)) +
4564
+ theme_void() +
4565
+ theme(legend.position = "none",
4566
+ plot.background = element_rect(fill = "transparent", color = NA),
4567
+ panel.background = element_rect(fill = "transparent", color = NA),
4568
+ plot.margin = margin(0, 0, 0, 0))
4569
+ }
4570
+
4571
+ # Source data: the whole migrants table by default, or just the in-polygon
4572
+ # subset when the user has drawn a selection (mirrors n_incidents handling).
4573
+ migrants_filtered <- reactive({
4574
+ poly_wkt <- rv$temporal_filter_wkt
4575
+ if (is.null(poly_wkt)) {
4576
+ tryCatch(DBI::dbGetQuery(con,
4577
+ "SELECT cause_cat, n_dead, n_children, n_females, n_males
4578
+ FROM migrants"),
4579
+ error = function(e) { message("migrants_filtered: ", e$message); NULL })
4580
+ } else {
4581
+ sql <- glue::glue(
4582
+ "SELECT m.cause_cat, m.n_dead, m.n_children, m.n_females, m.n_males
4583
+ FROM migrants m
4584
+ WHERE m.lon IS NOT NULL AND m.lat IS NOT NULL
4585
+ AND ST_Within(ST_Point(m.lon, m.lat), ST_GeomFromText('{poly_wkt}'))",
4586
+ .open = "{", .close = "}")
4587
+ tryCatch(DBI::dbGetQuery(con, sql),
4588
+ error = function(e) { message("migrants_filtered poly: ", e$message); NULL })
4589
  }
4590
+ })
4591
+
4592
+ output$migrants_treemap_cause <- renderPlot({
4593
+ df <- migrants_filtered()
4594
+ if (is.null(df) || nrow(df) == 0) return(NULL)
4595
+ df$cause_cat[is.na(df$cause_cat) | df$cause_cat == ""] <- "Other / unknown"
4596
+ agg <- aggregate(n_dead ~ cause_cat, data = df, FUN = function(x) sum(x, na.rm = TRUE))
4597
+ summary_df <- data.frame(label = agg$cause_cat,
4598
+ value = agg$n_dead,
4599
+ color = unname(CAUSE_PAL[agg$cause_cat]),
4600
+ stringsAsFactors = FALSE)
4601
+ treemap_plot(summary_df, CAUSE_PAL, base_size = 8)
4602
+ }, bg = "transparent", res = 110)
4603
+
4604
+ output$migrants_treemap_demo <- renderPlot({
4605
+ df <- migrants_filtered()
4606
+ if (is.null(df) || nrow(df) == 0) return(NULL)
4607
+ summary_df <- data.frame(
4608
+ label = c("Children", "Female", "Male"),
4609
+ value = c(sum(df$n_children, na.rm = TRUE),
4610
+ sum(df$n_females, na.rm = TRUE),
4611
+ sum(df$n_males, na.rm = TRUE)),
4612
+ color = unname(DEMO_PAL),
4613
+ stringsAsFactors = FALSE
4614
+ )
4615
+ treemap_plot(summary_df, DEMO_PAL, base_size = 8)
4616
+ }, bg = "transparent", res = 110)
4617
 
4618
  # Shared helper: temporal bar chart by year for point event tables
4619
  make_temporal_yr <- function(tbl, yr_col, line_color) {
 
4728
  ))
4729
  }, ignoreInit = TRUE)
4730
 
4731
+ # Generic ym-window re-aggregation for polygon temporal layers. Each entry
4732
+ # below pairs a layer id with: the lazy-load key (so we don't try to swap a
4733
+ # source on a layer that hasn't been added yet) and the SQL template that
4734
+ # rebuilds the per-muni aggregate filtered to [ym_lo, ym_hi]. The
4735
+ # short-circuit, the JS debounce, and the set_source swap from the PAM fix
4736
+ # all apply uniformly.
4737
+ TEMPORAL_POLY_SOURCES <- list(
4738
+ "pam-choro" = list(lazy_key = "pam", sql = "src_pam_ym"),
4739
+ "fosas-choro" = list(lazy_key = "fosas", sql = "src_fosas_ym")
4740
+ )
4741
  observeEvent(input$temporal_filter, {
4742
  pl <- input$temporal_filter
4743
  if (is.null(pl)) return()
4744
  active <- pl$active %||% character(0)
 
4745
  ym_lo <- pl$start %||% TEMPORAL_RANGE[1]
4746
  ym_hi <- pl$end %||% TEMPORAL_RANGE[2]
4747
+ for (lid in intersect(active, names(TEMPORAL_POLY_SOURCES))) {
4748
+ spec <- TEMPORAL_POLY_SOURCES[[lid]]
4749
+ if (!isTRUE(lazy_added[[spec$lazy_key]])) next
4750
+ # Full-range = unfiltered layer already on the map IS the answer.
4751
+ df_cache <- .APP_CACHE$temporal[[lid]]
4752
+ data_lo <- if (!is.null(df_cache)) min(df_cache$ym) else TEMPORAL_RANGE[1]
4753
+ data_hi <- if (!is.null(df_cache)) max(df_cache$ym) else TEMPORAL_RANGE[2]
4754
+ if (ym_lo <= data_lo && ym_hi >= data_hi) next
4755
+ sf_data <- tryCatch({
4756
+ DBI::dbGetQuery(con,
4757
+ web_sql(spec$sql, ym_lo = ym_lo, ym_hi = ym_hi)) |>
4758
+ sf::st_as_sf(wkt = "wkt", crs = 4326) |>
4759
+ dplyr::select(-wkt)
4760
+ }, error = function(e) {
4761
+ message(lid, " ym re-query: ", e$message); NULL
4762
+ })
4763
+ if (is.null(sf_data)) next
4764
+ # set_source swaps the source data on the existing layer without
4765
+ # touching the paint expression / tooltip -- avoids the source-already-
4766
+ # exists collision that the previous remove + re-add pattern caused.
4767
+ maplibre_proxy("map") |>
4768
+ set_source(layer_id = lid, source = sf_data)
4769
+ }
 
 
 
4770
  }, ignoreNULL = TRUE, ignoreInit = TRUE)
4771
 
4772
  # --- PAM temporal plot (irregular migration events over time) ---------------
 
4878
  # (otherwise hidden outputs stay suspended and read "---"). Cost: one extra
4879
  # render per output per session -- negligible (cached strings + small plots).
4880
  for (.oid in c(
4881
+ "temporal_pam", "migrants_treemap_cause", "migrants_treemap_demo",
4882
+ "temporal_ocved", "temporal_ged",
4883
  "breakdown_giim", "breakdown_infra",
4884
  "n_incidents", "n_fatalities", "n_graves", "n_facilities", "n_events",
4885
  "ocved_n", "n_ged", "n_mun", "n_desap", "n_pam", "n_fosas", "n_cuerpos",