edgarodriguez commited on
Commit
0fee998
·
verified ·
1 Parent(s): f3b35c8

Upload 2 files

Browse files
Files changed (2) hide show
  1. depth_interactive_map_alpha.R +178 -160
  2. styles.css +21 -0
depth_interactive_map_alpha.R CHANGED
@@ -256,34 +256,37 @@ POBREZA_COLS <- list(
256
  # sums pam_n so the line tracks total events not muni-row count)
257
  # color -- line/series colour matching the map layer paint
258
  # kind -- "point" (client-side set_filter) or "polygon" (server re-query)
 
 
 
 
259
  TEMPORAL_LAYERS <- list(
260
  "migrants-pts" = list(label = "Missing migrants", color = COL_YEL,
261
- table = "migrants", year_col = "incident_year",
262
- # year_expr drives both cache + point loader. Same DuckDB
263
- # expression in both places so the chart and the map agree.
264
- year_expr = "incident_year",
265
- point_yr_expr = "incident_year",
 
266
  kind = "point"),
267
  "ocved-pts" = list(label = "OCVED violence", color = COL_AMBER,
268
- table = "ocved_events", year_col = "year",
269
- year_expr = "CAST(SUBSTR(date_str, 1, 4) AS INTEGER)",
270
- point_yr_expr = "CAST(SUBSTR(date_str, 1, 4) AS INTEGER)",
271
  kind = "point"),
272
  "ged-pts" = list(label = "GED conflict", color = COL_LIGHT_BLUE,
273
- table = "ged_events", year_col = "year",
274
- year_expr = "CAST(date_part('year', date_start) AS INTEGER)",
275
- point_yr_expr = "CAST(date_part('year', date_start) AS INTEGER)",
276
  kind = "point"),
277
  "pam-choro" = list(label = "PAM events", color = COL_COUNT,
278
  table = "dim_pam_mun",
279
- year_col = "yr",
280
- year_expr = "CAST(date_part('year', date) AS INTEGER)",
281
  agg = "SUM(pam_n)",
282
  kind = "polygon")
283
  )
284
- # Outer bounds for the slider (union across the four tables, with a safety
285
- # margin). Inner per-layer ranges are computed at startup from the data.
286
- TEMPORAL_RANGE <- c(2010L, 2026L)
287
 
288
  #######################################################################################
289
  # WEB_SQL -- canonical query templates (single source for Shiny + docs converter)
@@ -338,12 +341,11 @@ WEB_SQL_TEMPLATES <- list(
338
  WHERE lon IS NOT NULL AND lat IS NOT NULL
339
  LIMIT {limit}",
340
 
341
- # Year-bearing variant: returns a `yr` column the client filter can read.
342
- # `yr_expr` is whatever DuckDB expression yields the integer year for that
343
- # table (e.g. plain column, SUBSTR of date_str, date_part of timestamp).
344
- load_pts_with_year =
345
  "SELECT row_number() OVER () AS row_id, lon, lat,
346
- {yr_expr} AS yr
347
  FROM {tbl}
348
  WHERE lon IS NOT NULL AND lat IS NOT NULL
349
  LIMIT {limit}",
@@ -587,20 +589,20 @@ WEB_SQL_TEMPLATES <- list(
587
  WHERE CVEGEO IN ({cvegeo_list})",
588
 
589
  # --- Temporal strip support -----------------------------------------------
590
- # Counts per year for each of the 4 temporal layers. Used to draw the
591
- # histogram in the strip. `yr_expr` is either the raw year column or a
592
- # date_part() expression; `agg` is COUNT(*) for point tables / SUM(pam_n)
593
- # for PAM. Cached at startup -- they don't change.
594
- ts_yr_counts =
595
- "SELECT {yr_expr} AS yr, {agg} AS n
596
  FROM {tbl}
597
- WHERE {yr_col_or_expr} IS NOT NULL
598
- GROUP BY yr HAVING yr >= 2000 AND yr <= 2030
599
- ORDER BY yr",
600
-
601
- # Year-window-filtered PAM aggregate (used when the user moves the strip
602
- # handles). Same shape as src_pam but with a WHERE on date_part(year).
603
- src_pam_year =
 
604
  "SELECT c.municipio, c.entidad, c.CVEGEO,
605
  COALESCE(CAST(p.pam_total AS INTEGER), 0) AS pam_n,
606
  c.wkt
@@ -609,7 +611,8 @@ WEB_SQL_TEMPLATES <- list(
609
  SELECT CVEGEO, SUM(pam_n) AS pam_total
610
  FROM dim_pam_mun
611
  WHERE date IS NOT NULL
612
- AND CAST(date_part('year', date) AS INTEGER) BETWEEN {yr_lo} AND {yr_hi}
 
613
  GROUP BY CVEGEO
614
  ) p ON c.CVEGEO = p.CVEGEO",
615
 
@@ -850,15 +853,14 @@ ddb_minmax <- function(con, tbl, col, filter_zero = TRUE) {
850
  fosas = ddb_minmax(con, "dim_fosas_mun", "fosas_total", TRUE),
851
  envipe = ddb_minmax(con, "state_polygons", "perception_insecurity_perc", FALSE)
852
  ),
853
- # Per-year counts for each temporal-strip layer. Each entry is a tidy
854
- # data.frame(yr, n) or NULL when the table isn't available.
855
  temporal = lapply(TEMPORAL_LAYERS, function(info) {
856
- yr_expr <- info$year_expr %||% info$year_col
857
  agg_expr <- info$agg %||% "COUNT(*)"
858
  sql <- glue::glue_data(
859
- list(yr_expr = yr_expr, agg = agg_expr,
860
- tbl = info$table, yr_col_or_expr = yr_expr),
861
- WEB_SQL_TEMPLATES$ts_yr_counts,
862
  .open = "{", .close = "}"
863
  )
864
  tryCatch(DBI::dbGetQuery(con, sql),
@@ -1445,11 +1447,24 @@ LUCIDE_JS <- tags$head(
1445
  icon.innerHTML = hidden ? '&#9650;' : '&#9660;';
1446
  }
1447
 
 
 
 
1448
  function updateActiveLayers() {
1449
  var checked = [];
1450
  document.querySelectorAll('.layer-check:checked').forEach(function(cb) {
1451
  checked.push(cb.value);
 
 
 
 
 
 
 
 
 
1452
  });
 
1453
  // Sync legend visibility: use class so CSS controls display type per legend shape
1454
  document.querySelectorAll('.acc-item-wrap').forEach(function(wrap) {
1455
  var input = wrap.querySelector('.acc-toggle-input');
@@ -1467,27 +1482,51 @@ LUCIDE_JS <- tags$head(
1467
  if (typeof syncTemporalStrip === 'function') syncTemporalStrip(checked);
1468
  }
1469
 
1470
- // -- Temporal strip: bottom-of-map year-range filter ------------------------
1471
- // _temporal.active holds the LAST 2 toggled-on temporal layers (most recent
1472
- // first). The chart and the dual y-axis follow them. yrLo/yrHi drive the
1473
- // map filter for those layers (client-side for points, server for PAM).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1474
  var TEMPORAL_LAYER_IDS = ['migrants-pts','ocved-pts','ged-pts','pam-choro'];
1475
- // All three point layers now load a unified 'yr' property via the
1476
- // load_pts_with_year SQL template, so the client filter doesn't have to
1477
- // know each table's underlying year column.
1478
  var TEMPORAL_POINT_COL = {
1479
- 'migrants-pts': 'yr',
1480
- 'ocved-pts': 'yr',
1481
- 'ged-pts': 'yr'
1482
  };
1483
  var _temporal = {
1484
  active: [], // most-recent-first
1485
- yrLo: null,
1486
- yrHi: null,
1487
  playing: false,
1488
  playTimer: null
1489
  };
1490
 
 
 
 
 
 
 
 
1491
  function syncTemporalStrip(checkedIds) {
1492
  // Derive the active layer set from the DOM if the caller didn't pass it.
1493
  if (!checkedIds) {
@@ -1516,11 +1555,12 @@ LUCIDE_JS <- tags$head(
1516
  var becameVisible = stripVisible &&
1517
  (!strip || strip.style.display !== 'flex');
1518
  if (strip) strip.style.display = stripVisible ? 'flex' : 'none';
1519
- // First time the strip is shown in this session, ensure Lucide swapped
1520
- // the play-icon placeholder into an SVG. After that, refresh on every
1521
- // paint so the play/pause toggle stays up to date.
1522
- if (becameVisible && window.lucide) {
1523
- setTimeout(function() { lucide.createIcons(); }, 30);
 
1524
  }
1525
  // Tell Shiny which layers the chart should draw (server reactive)
1526
  if (typeof Shiny !== 'undefined') {
@@ -1536,7 +1576,7 @@ LUCIDE_JS <- tags$head(
1536
  return widget.map || (widget.getMap && widget.getMap()) || null;
1537
  }
1538
 
1539
- function applyTemporalFilterClient(layerId, yrLo, yrHi) {
1540
  var m = _getMap();
1541
  if (!m || !m.getLayer || !m.getLayer(layerId)) return;
1542
  var col = TEMPORAL_POINT_COL[layerId];
@@ -1544,12 +1584,12 @@ LUCIDE_JS <- tags$head(
1544
  // Full-range = clear the filter so feature culling is fastest
1545
  var startMin = parseInt(document.getElementById('temporal-start').min, 10);
1546
  var endMax = parseInt(document.getElementById('temporal-end').max, 10);
1547
- if (yrLo <= startMin && yrHi >= endMax) {
1548
  m.setFilter(layerId, null);
1549
  } else {
1550
  m.setFilter(layerId, ['all',
1551
- ['>=', ['to-number', ['get', col]], yrLo],
1552
- ['<=', ['to-number', ['get', col]], yrHi]
1553
  ]);
1554
  }
1555
  }
@@ -1558,18 +1598,18 @@ LUCIDE_JS <- tags$head(
1558
  var startEl = document.getElementById('temporal-start');
1559
  var endEl = document.getElementById('temporal-end');
1560
  if (!startEl || !endEl) return;
1561
- var yrLo = parseInt(startEl.value, 10);
1562
- var yrHi = parseInt(endEl.value, 10);
1563
- if (yrLo > yrHi) { var t = yrLo; yrLo = yrHi; yrHi = t; }
1564
- _temporal.yrLo = yrLo; _temporal.yrHi = yrHi;
1565
  // Client: apply for each active POINT layer
1566
  _temporal.active.forEach(function(id) {
1567
- if (TEMPORAL_POINT_COL[id]) applyTemporalFilterClient(id, yrLo, yrHi);
1568
  });
1569
  // Server: send range so PAM can re-query if it's active
1570
  if (typeof Shiny !== 'undefined') {
1571
  Shiny.setInputValue('temporal_filter', {
1572
- start: yrLo, end: yrHi, active: _temporal.active, ts: Date.now()
1573
  }, { priority: 'event' });
1574
  }
1575
  }
@@ -1587,7 +1627,7 @@ LUCIDE_JS <- tags$head(
1587
  if (document.activeElement === startEl) { hi = lo; endEl.value = hi; }
1588
  else { lo = hi; startEl.value = lo; }
1589
  }
1590
- if (label) label.textContent = lo + ' — ' + hi;
1591
  if (fill) {
1592
  var min = parseInt(startEl.min, 10);
1593
  var max = parseInt(startEl.max, 10);
@@ -2135,6 +2175,7 @@ acc_group <- function(id, label, items_list, open = TRUE) {
2135
  acc_layer <- function(value, label, tooltip = NULL, legend = NULL, checked = FALSE,
2136
  legend_extras = NULL) {
2137
  div(class = "acc-item-wrap",
 
2138
  div(class = "acc-item",
2139
  tags$label(class = "acc-toggle-label",
2140
  tags$input(
@@ -2712,7 +2753,7 @@ ui <- page_fillable(
2712
  div(id = "temporal-strip", class = "temporal-strip",
2713
  style = "display:none;",
2714
  role = "region",
2715
- `aria-label` = "Temporal year-range filter",
2716
  div(class = "temporal-strip-chart",
2717
  plotOutput("temporal_strip_chart",
2718
  width = "100%", height = "85px")),
@@ -2736,7 +2777,7 @@ ui <- page_fillable(
2736
  step = "1",
2737
  value = TEMPORAL_RANGE[1],
2738
  oninput = "onTemporalSliderInput()",
2739
- `aria-label` = "Start year"),
2740
  tags$input(type = "range",
2741
  id = "temporal-end",
2742
  class = "temporal-slider-input temporal-slider-end",
@@ -2745,16 +2786,17 @@ ui <- page_fillable(
2745
  step = "1",
2746
  value = TEMPORAL_RANGE[2],
2747
  oninput = "onTemporalSliderInput()",
2748
- `aria-label` = "End year")),
2749
  tags$span(id = "temporal-year-label",
2750
  class = "temporal-year-label",
2751
- paste0(TEMPORAL_RANGE[1], " — ", TEMPORAL_RANGE[2])),
 
2752
  tags$button(id = "temporal-reset-btn",
2753
  type = "button",
2754
  class = "temporal-reset-btn",
2755
  onclick = "resetTemporalRange()",
2756
  `aria-label` = "Reset to full range",
2757
- title = "Reset to all years",
2758
  "All"))
2759
  ),
2760
 
@@ -3385,15 +3427,15 @@ server <- function(input, output, session) {
3385
  error = function(e) data.frame(cat = character(0), n = integer(0)))
3386
  }
3387
 
3388
- # Year-bearing point loader: selects `yr` as an extra column so the client
3389
- # filter can read it via ['get', 'yr'].
3390
- load_pts_with_year <- function(tbl, yr_expr, limit = 80000) {
3391
  tryCatch({
3392
- DBI::dbGetQuery(con, web_sql("load_pts_with_year",
3393
- tbl = tbl, yr_expr = yr_expr, limit = limit)) |>
3394
  sf::st_as_sf(coords = c("lon", "lat"), crs = 4326)
3395
  }, error = function(e) {
3396
- message(" [pts+yr] ", tbl, ": ", e$message); NULL
3397
  })
3398
  }
3399
 
@@ -3848,7 +3890,7 @@ server <- function(input, output, session) {
3848
  }
3849
  }, ignoreNULL = TRUE, ignoreInit = TRUE)
3850
 
3851
- # Lazy layer loader helpers — load on first use via proxy.
3852
  lazy_add_fill <- function(key, sql_name, id, make_layer_fn) {
3853
  if (isTRUE(lazy_added[[key]]) || is.null(con)) return()
3854
  sf_data <- tryCatch({
@@ -3859,12 +3901,13 @@ server <- function(input, output, session) {
3859
  if (!is.null(sf_data)) {
3860
  make_layer_fn(maplibre_proxy("map"), sf_data)
3861
  lazy_added[[key]] <<- TRUE
 
3862
  }
3863
  }
3864
  lazy_add_pts <- function(key, tbl, id, color, radius = 2, limit = 80000,
3865
- yr_expr = NULL) {
3866
  if (isTRUE(lazy_added[[key]]) || is.null(con)) return()
3867
- pts <- if (!is.null(yr_expr)) load_pts_with_year(tbl, yr_expr, limit)
3868
  else load_pts(tbl, limit)
3869
  if (!is.null(pts)) {
3870
  maplibre_proxy("map") |>
@@ -3877,6 +3920,7 @@ server <- function(input, output, session) {
3877
  visibility = if (id %in% (input$active_layers %||% "")) "visible" else "none"
3878
  )
3879
  lazy_added[[key]] <<- TRUE
 
3880
  }
3881
  }
3882
 
@@ -3898,6 +3942,7 @@ server <- function(input, output, session) {
3898
  visibility = if ("infra-pts" %in% (input$active_layers %||% "")) "visible" else "none"
3899
  )
3900
  lazy_added$infra <<- TRUE
 
3901
  }
3902
  }
3903
  lazy_add_grid <- function(nv_chr) {
@@ -4012,13 +4057,13 @@ server <- function(input, output, session) {
4012
 
4013
  # Point layers
4014
  if ("migrants-pts" %in% active) lazy_add_pts("migrants", "migrants", "migrants-pts", COL_YEL, 2, 8000,
4015
- yr_expr = TEMPORAL_LAYERS[["migrants-pts"]]$point_yr_expr)
4016
  if ("graves-pts" %in% active) lazy_add_pts("graves", "graves", "graves-pts", COL_RED, 2, 5000)
4017
  if ("infra-pts" %in% active) lazy_add_infra()
4018
  if ("ocved-pts" %in% active) lazy_add_pts("ocved", "ocved_events", "ocved-pts", COL_AMBER, 1, 80000,
4019
- yr_expr = TEMPORAL_LAYERS[["ocved-pts"]]$point_yr_expr)
4020
  if ("ged-pts" %in% active) lazy_add_pts("ged", "ged_events", "ged-pts", COL_LIGHT_BLUE, 1, 80000,
4021
- yr_expr = TEMPORAL_LAYERS[["ged-pts"]]$point_yr_expr)
4022
 
4023
  # Grid: only load the nivel the user actually selected. Loading all three
4024
  # eagerly used to make first-click block the session on HF (3 sequential
@@ -4378,101 +4423,74 @@ server <- function(input, output, session) {
4378
  output$temporal_ocved <- make_temporal_yr("ocved_events", "year", COL_AMBER)
4379
  output$temporal_ged <- make_temporal_yr("ged_events", "year", COL_LIGHT_BLUE)
4380
 
4381
- # --- Temporal strip chart (bottom-of-map year-range filter) ---------------
4382
- # Renders up to 2 lines from .APP_CACHE$temporal, one per active temporal
4383
- # layer (active list driven by input$temporal_active). When 2 are active,
4384
- # the chart has dual y-axes (left = first/primary, right = secondary)
4385
- # because event magnitudes differ wildly (PAM ~1.6M vs migrants ~6k).
4386
  output$temporal_strip_chart <- renderPlot({
4387
  active <- input$temporal_active %||% character(0)
4388
  if (length(active) == 0) return(NULL)
4389
- yr_range <- TEMPORAL_RANGE
4390
- rect_df <- {
4391
- pl <- input$temporal_filter
4392
- if (is.null(pl)) NULL
4393
- else data.frame(xmin = (pl$start %||% yr_range[1]) - 0.5,
4394
- xmax = (pl$end %||% yr_range[2]) + 0.5)
4395
- }
4396
- pull_df <- function(id) {
4397
- df <- .APP_CACHE$temporal[[id]]
4398
- if (is.null(df) || nrow(df) == 0) return(NULL)
4399
- info <- TEMPORAL_LAYERS[[id]]
4400
- data.frame(yr = df$yr, n = df$n, layer = info$label,
4401
- color = info$color, stringsAsFactors = FALSE)
4402
- }
4403
- primary <- pull_df(active[1])
4404
- secondary <- if (length(active) >= 2) pull_df(active[2]) else NULL
4405
- if (is.null(primary)) return(NULL)
 
 
 
 
 
 
 
 
4406
 
4407
- base_thm <- theme_minimal(base_size = 9) +
 
 
 
 
 
 
4408
  theme(plot.background = element_rect(fill = "transparent", color = NA),
4409
  panel.background = element_rect(fill = "transparent", color = NA),
4410
  panel.grid = element_blank(),
4411
- panel.grid.major.y = element_line(color = "#ffffff20", linewidth = 0.3),
4412
- axis.title.x = element_blank(),
4413
  axis.text.x = element_text(size = 8, color = "#cbd5e1"),
4414
- axis.title.y = element_text(size = 8, color = "#cbd5e1"),
4415
- axis.text.y = element_text(size = 7, color = "#94a3b8"),
4416
- axis.title.y.right = element_text(size = 8, color = "#cbd5e1"),
4417
- axis.text.y.right = element_text(size = 7, color = "#94a3b8"),
4418
  axis.ticks = element_blank(),
4419
  legend.position = "none",
4420
- plot.margin = margin(2, 8, 2, 8))
4421
-
4422
- p <- ggplot()
4423
- if (!is.null(rect_df))
4424
- p <- p + geom_rect(data = rect_df,
4425
- aes(xmin = xmin, xmax = xmax,
4426
- ymin = -Inf, ymax = Inf),
4427
- fill = "#ffffff", alpha = 0.06,
4428
- inherit.aes = FALSE)
4429
-
4430
- if (is.null(secondary)) {
4431
- p <- p +
4432
- geom_line(data = primary, aes(x = yr, y = n),
4433
- color = primary$color[1], linewidth = 1) +
4434
- geom_point(data = primary, aes(x = yr, y = n),
4435
- color = primary$color[1], size = 1.4) +
4436
- scale_y_continuous(primary$layer[1])
4437
- } else {
4438
- max_p <- max(primary$n, na.rm = TRUE)
4439
- max_s <- max(secondary$n, na.rm = TRUE)
4440
- scale_factor <- if (max_s > 0) max_p / max_s else 1
4441
- p <- p +
4442
- geom_line(data = primary, aes(x = yr, y = n),
4443
- color = primary$color[1], linewidth = 1) +
4444
- geom_point(data = primary, aes(x = yr, y = n),
4445
- color = primary$color[1], size = 1.4) +
4446
- geom_line(data = secondary, aes(x = yr, y = n * scale_factor),
4447
- color = secondary$color[1], linewidth = 1, linetype = "32") +
4448
- geom_point(data = secondary, aes(x = yr, y = n * scale_factor),
4449
- color = secondary$color[1], size = 1.4) +
4450
- scale_y_continuous(
4451
- name = primary$layer[1],
4452
- sec.axis = sec_axis(~ . / scale_factor, name = secondary$layer[1]))
4453
- }
4454
- p +
4455
- scale_x_continuous(breaks = scales::pretty_breaks(n = 6),
4456
- limits = c(yr_range[1] - 0.5, yr_range[2] + 0.5)) +
4457
- base_thm
4458
  }, bg = "transparent", res = 110)
4459
 
4460
- # PAM year-window re-aggregation: when the strip range changes and PAM is
4461
- # active, re-query src_pam_year + push the new fill via the proxy. The
4462
- # current layer is removed and re-added with the year-filtered source.
4463
  observeEvent(input$temporal_filter, {
4464
  pl <- input$temporal_filter
4465
  if (is.null(pl)) return()
4466
  active <- pl$active %||% character(0)
4467
  if (!("pam-choro" %in% active) || !isTRUE(lazy_added$pam)) return()
4468
- yr_lo <- pl$start %||% TEMPORAL_RANGE[1]
4469
- yr_hi <- pl$end %||% TEMPORAL_RANGE[2]
4470
  sf_data <- tryCatch({
4471
  DBI::dbGetQuery(con,
4472
- web_sql("src_pam_year", yr_lo = yr_lo, yr_hi = yr_hi)) |>
4473
  sf::st_as_sf(wkt = "wkt", crs = 4326) |>
4474
  dplyr::select(-wkt)
4475
- }, error = function(e) { message("PAM year re-query: ", e$message); NULL })
4476
  if (is.null(sf_data)) return()
4477
  maplibre_proxy("map") |>
4478
  remove_layer("pam-choro") |>
 
256
  # sums pam_n so the line tracks total events not muni-row count)
257
  # color -- line/series colour matching the map layer paint
258
  # kind -- "point" (client-side set_filter) or "polygon" (server re-query)
259
+ # Temporal layers carry month-level resolution. The encoding `ym = year*12 +
260
+ # month - 1` packs (year, month) into a single integer the Shiny slider can
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",
274
+ ym_expr = "CAST(SUBSTR(date_str, 1, 4) AS INTEGER) * 12 + CAST(SUBSTR(date_str, 6, 2) AS INTEGER) - 1",
275
+ point_ym_expr = "CAST(SUBSTR(date_str, 1, 4) AS INTEGER) * 12 + CAST(SUBSTR(date_str, 6, 2) AS INTEGER) - 1",
276
  kind = "point"),
277
  "ged-pts" = list(label = "GED conflict", color = COL_LIGHT_BLUE,
278
+ table = "ged_events",
279
+ ym_expr = "CAST(date_part('year', date_start) AS INTEGER) * 12 + CAST(date_part('month', date_start) AS INTEGER) - 1",
280
+ point_ym_expr = "CAST(date_part('year', date_start) AS INTEGER) * 12 + CAST(date_part('month', date_start) AS INTEGER) - 1",
281
  kind = "point"),
282
  "pam-choro" = list(label = "PAM events", color = COL_COUNT,
283
  table = "dim_pam_mun",
284
+ ym_expr = "CAST(date_part('year', date) AS INTEGER) * 12 + CAST(date_part('month', date) AS INTEGER) - 1",
 
285
  agg = "SUM(pam_n)",
286
  kind = "polygon")
287
  )
288
+ # Outer bounds for the slider in ym encoding: Jan 2010 .. Dec 2026.
289
+ TEMPORAL_RANGE <- c(2010L * 12L, 2026L * 12L + 11L)
 
290
 
291
  #######################################################################################
292
  # WEB_SQL -- canonical query templates (single source for Shiny + docs converter)
 
341
  WHERE lon IS NOT NULL AND lat IS NOT NULL
342
  LIMIT {limit}",
343
 
344
+ # Month-year variant: returns a `ym` column the client filter can read.
345
+ # `ym_expr` is the DuckDB expression that yields ym = year*12 + month - 1.
346
+ load_pts_with_ym =
 
347
  "SELECT row_number() OVER () AS row_id, lon, lat,
348
+ {ym_expr} AS ym
349
  FROM {tbl}
350
  WHERE lon IS NOT NULL AND lat IS NOT NULL
351
  LIMIT {limit}",
 
589
  WHERE CVEGEO IN ({cvegeo_list})",
590
 
591
  # --- Temporal strip support -----------------------------------------------
592
+ # Counts per (year, month) for each of the 4 temporal layers. Drives the
593
+ # mini-chart in the strip. `ym_expr` yields ym = year*12 + month - 1; `agg`
594
+ # is COUNT(*) for points / SUM(pam_n) for PAM. Cached at startup.
595
+ ts_ym_counts =
596
+ "SELECT {ym_expr} AS ym, {agg} AS n
 
597
  FROM {tbl}
598
+ WHERE {ym_expr} IS NOT NULL
599
+ GROUP BY ym
600
+ HAVING ym BETWEEN 24000 AND 24400
601
+ ORDER BY ym",
602
+
603
+ # ym-window-filtered PAM aggregate (used when the user moves the strip
604
+ # handles). Same shape as src_pam but with a WHERE on the ym expression.
605
+ src_pam_ym =
606
  "SELECT c.municipio, c.entidad, c.CVEGEO,
607
  COALESCE(CAST(p.pam_total AS INTEGER), 0) AS pam_n,
608
  c.wkt
 
611
  SELECT CVEGEO, SUM(pam_n) AS pam_total
612
  FROM dim_pam_mun
613
  WHERE date IS NOT NULL
614
+ AND (CAST(date_part('year', date) AS INTEGER) * 12 +
615
+ CAST(date_part('month', date) AS INTEGER) - 1) BETWEEN {ym_lo} AND {ym_hi}
616
  GROUP BY CVEGEO
617
  ) p ON c.CVEGEO = p.CVEGEO",
618
 
 
853
  fosas = ddb_minmax(con, "dim_fosas_mun", "fosas_total", TRUE),
854
  envipe = ddb_minmax(con, "state_polygons", "perception_insecurity_perc", FALSE)
855
  ),
856
+ # Per-month counts for each temporal-strip layer. Each entry is a tidy
857
+ # data.frame(ym, n) or NULL when the table isn't available.
858
  temporal = lapply(TEMPORAL_LAYERS, function(info) {
859
+ ym_expr <- info$ym_expr
860
  agg_expr <- info$agg %||% "COUNT(*)"
861
  sql <- glue::glue_data(
862
+ list(ym_expr = ym_expr, agg = agg_expr, tbl = info$table),
863
+ WEB_SQL_TEMPLATES$ts_ym_counts,
 
864
  .open = "{", .close = "}"
865
  )
866
  tryCatch(DBI::dbGetQuery(con, sql),
 
1447
  icon.innerHTML = hidden ? '&#9650;' : '&#9660;';
1448
  }
1449
 
1450
+ // Flag: first call comes from the init() pass and just reflects the default
1451
+ // map state -- no lazy-load fires for those layers, so don't spin.
1452
+ var _uaLayersInited = false;
1453
  function updateActiveLayers() {
1454
  var checked = [];
1455
  document.querySelectorAll('.layer-check:checked').forEach(function(cb) {
1456
  checked.push(cb.value);
1457
+ var wrap = cb.closest('.acc-item-wrap');
1458
+ if (!wrap) return;
1459
+ if (!_uaLayersInited) {
1460
+ // Default-on layers are already on the map at startup -- no spinner.
1461
+ wrap.classList.add('is-loaded');
1462
+ } else if (!wrap.classList.contains('is-loaded')) {
1463
+ // First-time toggle: show spinner until the server confirms.
1464
+ wrap.classList.add('is-loading');
1465
+ }
1466
  });
1467
+ _uaLayersInited = true;
1468
  // Sync legend visibility: use class so CSS controls display type per legend shape
1469
  document.querySelectorAll('.acc-item-wrap').forEach(function(wrap) {
1470
  var input = wrap.querySelector('.acc-toggle-input');
 
1482
  if (typeof syncTemporalStrip === 'function') syncTemporalStrip(checked);
1483
  }
1484
 
1485
+ // Server sends `layer_loaded` after a lazy_add_* finishes the first dbGetQuery
1486
+ // + add_layer round-trip. Clear the spinner and remember the layer as loaded
1487
+ // so re-toggles don't show the spinner again.
1488
+ if (typeof Shiny !== 'undefined' && Shiny.addCustomMessageHandler) {
1489
+ Shiny.addCustomMessageHandler('layer_loaded', function(msg) {
1490
+ if (!msg || !msg.layer_id) return;
1491
+ var wraps = document.querySelectorAll('.acc-item-wrap');
1492
+ for (var i = 0; i < wraps.length; i++) {
1493
+ if (wraps[i].getAttribute('data-layer-id') === msg.layer_id) {
1494
+ wraps[i].classList.remove('is-loading');
1495
+ wraps[i].classList.add('is-loaded');
1496
+ break;
1497
+ }
1498
+ }
1499
+ });
1500
+ }
1501
+
1502
+ // -- Temporal strip: bottom-of-map month-year range filter ------------------
1503
+ // _temporal.active holds the LAST 2 toggled-on temporal layers (most-recent
1504
+ // first). The chart follows only the most-recent one. ymLo/ymHi drive the
1505
+ // map filter (client-side for points, server for PAM). ym encoding:
1506
+ // ym = year * 12 + month - 1 (Jan 2010 = 24120, Dec 2026 = 24323).
1507
  var TEMPORAL_LAYER_IDS = ['migrants-pts','ocved-pts','ged-pts','pam-choro'];
1508
+ // Point layers carry a unified 'ym' feature property via load_pts_with_ym
1509
+ // so the client filter doesn't need to know each table's date format.
 
1510
  var TEMPORAL_POINT_COL = {
1511
+ 'migrants-pts': 'ym',
1512
+ 'ocved-pts': 'ym',
1513
+ 'ged-pts': 'ym'
1514
  };
1515
  var _temporal = {
1516
  active: [], // most-recent-first
1517
+ ymLo: null,
1518
+ ymHi: null,
1519
  playing: false,
1520
  playTimer: null
1521
  };
1522
 
1523
+ // Format a ym integer back to year-dash-month (e.g. 2014-03) for the label.
1524
+ function _ymFmt(v) {
1525
+ var y = Math.floor(v / 12);
1526
+ var m = (v % 12) + 1;
1527
+ return y + '-' + (m < 10 ? '0' + m : '' + m);
1528
+ }
1529
+
1530
  function syncTemporalStrip(checkedIds) {
1531
  // Derive the active layer set from the DOM if the caller didn't pass it.
1532
  if (!checkedIds) {
 
1555
  var becameVisible = stripVisible &&
1556
  (!strip || strip.style.display !== 'flex');
1557
  if (strip) strip.style.display = stripVisible ? 'flex' : 'none';
1558
+ // First time the strip is shown in this session, refresh Lucide so the
1559
+ // play placeholder swaps into an SVG, and force the label + fill to
1560
+ // reflect the current slider values (server-side renders a placeholder).
1561
+ if (becameVisible) {
1562
+ if (window.lucide) setTimeout(function() { lucide.createIcons(); }, 30);
1563
+ onTemporalSliderInput();
1564
  }
1565
  // Tell Shiny which layers the chart should draw (server reactive)
1566
  if (typeof Shiny !== 'undefined') {
 
1576
  return widget.map || (widget.getMap && widget.getMap()) || null;
1577
  }
1578
 
1579
+ function applyTemporalFilterClient(layerId, ymLo, ymHi) {
1580
  var m = _getMap();
1581
  if (!m || !m.getLayer || !m.getLayer(layerId)) return;
1582
  var col = TEMPORAL_POINT_COL[layerId];
 
1584
  // Full-range = clear the filter so feature culling is fastest
1585
  var startMin = parseInt(document.getElementById('temporal-start').min, 10);
1586
  var endMax = parseInt(document.getElementById('temporal-end').max, 10);
1587
+ if (ymLo <= startMin && ymHi >= endMax) {
1588
  m.setFilter(layerId, null);
1589
  } else {
1590
  m.setFilter(layerId, ['all',
1591
+ ['>=', ['to-number', ['get', col]], ymLo],
1592
+ ['<=', ['to-number', ['get', col]], ymHi]
1593
  ]);
1594
  }
1595
  }
 
1598
  var startEl = document.getElementById('temporal-start');
1599
  var endEl = document.getElementById('temporal-end');
1600
  if (!startEl || !endEl) return;
1601
+ var ymLo = parseInt(startEl.value, 10);
1602
+ var ymHi = parseInt(endEl.value, 10);
1603
+ if (ymLo > ymHi) { var t = ymLo; ymLo = ymHi; ymHi = t; }
1604
+ _temporal.ymLo = ymLo; _temporal.ymHi = ymHi;
1605
  // Client: apply for each active POINT layer
1606
  _temporal.active.forEach(function(id) {
1607
+ if (TEMPORAL_POINT_COL[id]) applyTemporalFilterClient(id, ymLo, ymHi);
1608
  });
1609
  // Server: send range so PAM can re-query if it's active
1610
  if (typeof Shiny !== 'undefined') {
1611
  Shiny.setInputValue('temporal_filter', {
1612
+ start: ymLo, end: ymHi, active: _temporal.active, ts: Date.now()
1613
  }, { priority: 'event' });
1614
  }
1615
  }
 
1627
  if (document.activeElement === startEl) { hi = lo; endEl.value = hi; }
1628
  else { lo = hi; startEl.value = lo; }
1629
  }
1630
+ if (label) label.textContent = _ymFmt(lo) + ' -- ' + _ymFmt(hi);
1631
  if (fill) {
1632
  var min = parseInt(startEl.min, 10);
1633
  var max = parseInt(startEl.max, 10);
 
2175
  acc_layer <- function(value, label, tooltip = NULL, legend = NULL, checked = FALSE,
2176
  legend_extras = NULL) {
2177
  div(class = "acc-item-wrap",
2178
+ `data-layer-id` = value,
2179
  div(class = "acc-item",
2180
  tags$label(class = "acc-toggle-label",
2181
  tags$input(
 
2753
  div(id = "temporal-strip", class = "temporal-strip",
2754
  style = "display:none;",
2755
  role = "region",
2756
+ `aria-label` = "Temporal month-year range filter",
2757
  div(class = "temporal-strip-chart",
2758
  plotOutput("temporal_strip_chart",
2759
  width = "100%", height = "85px")),
 
2777
  step = "1",
2778
  value = TEMPORAL_RANGE[1],
2779
  oninput = "onTemporalSliderInput()",
2780
+ `aria-label` = "Start month"),
2781
  tags$input(type = "range",
2782
  id = "temporal-end",
2783
  class = "temporal-slider-input temporal-slider-end",
 
2786
  step = "1",
2787
  value = TEMPORAL_RANGE[2],
2788
  oninput = "onTemporalSliderInput()",
2789
+ `aria-label` = "End month")),
2790
  tags$span(id = "temporal-year-label",
2791
  class = "temporal-year-label",
2792
+ # Placeholder; JS fills with "YYYY-MM -- YYYY-MM" on init.
2793
+ "----"),
2794
  tags$button(id = "temporal-reset-btn",
2795
  type = "button",
2796
  class = "temporal-reset-btn",
2797
  onclick = "resetTemporalRange()",
2798
  `aria-label` = "Reset to full range",
2799
+ title = "Reset to full range",
2800
  "All"))
2801
  ),
2802
 
 
3427
  error = function(e) data.frame(cat = character(0), n = integer(0)))
3428
  }
3429
 
3430
+ # Month-year point loader: selects `ym` as an extra column so the client
3431
+ # filter can read it via ['get', 'ym'].
3432
+ load_pts_with_ym <- function(tbl, ym_expr, limit = 80000) {
3433
  tryCatch({
3434
+ DBI::dbGetQuery(con, web_sql("load_pts_with_ym",
3435
+ tbl = tbl, ym_expr = ym_expr, limit = limit)) |>
3436
  sf::st_as_sf(coords = c("lon", "lat"), crs = 4326)
3437
  }, error = function(e) {
3438
+ message(" [pts+ym] ", tbl, ": ", e$message); NULL
3439
  })
3440
  }
3441
 
 
3890
  }
3891
  }, ignoreNULL = TRUE, ignoreInit = TRUE)
3892
 
3893
+ # Lazy layer loader helpers -- load on first use via proxy.
3894
  lazy_add_fill <- function(key, sql_name, id, make_layer_fn) {
3895
  if (isTRUE(lazy_added[[key]]) || is.null(con)) return()
3896
  sf_data <- tryCatch({
 
3901
  if (!is.null(sf_data)) {
3902
  make_layer_fn(maplibre_proxy("map"), sf_data)
3903
  lazy_added[[key]] <<- TRUE
3904
+ session$sendCustomMessage("layer_loaded", list(layer_id = id))
3905
  }
3906
  }
3907
  lazy_add_pts <- function(key, tbl, id, color, radius = 2, limit = 80000,
3908
+ ym_expr = NULL) {
3909
  if (isTRUE(lazy_added[[key]]) || is.null(con)) return()
3910
+ pts <- if (!is.null(ym_expr)) load_pts_with_ym(tbl, ym_expr, limit)
3911
  else load_pts(tbl, limit)
3912
  if (!is.null(pts)) {
3913
  maplibre_proxy("map") |>
 
3920
  visibility = if (id %in% (input$active_layers %||% "")) "visible" else "none"
3921
  )
3922
  lazy_added[[key]] <<- TRUE
3923
+ session$sendCustomMessage("layer_loaded", list(layer_id = id))
3924
  }
3925
  }
3926
 
 
3942
  visibility = if ("infra-pts" %in% (input$active_layers %||% "")) "visible" else "none"
3943
  )
3944
  lazy_added$infra <<- TRUE
3945
+ session$sendCustomMessage("layer_loaded", list(layer_id = "infra-pts"))
3946
  }
3947
  }
3948
  lazy_add_grid <- function(nv_chr) {
 
4057
 
4058
  # Point layers
4059
  if ("migrants-pts" %in% active) lazy_add_pts("migrants", "migrants", "migrants-pts", COL_YEL, 2, 8000,
4060
+ ym_expr = TEMPORAL_LAYERS[["migrants-pts"]]$point_ym_expr)
4061
  if ("graves-pts" %in% active) lazy_add_pts("graves", "graves", "graves-pts", COL_RED, 2, 5000)
4062
  if ("infra-pts" %in% active) lazy_add_infra()
4063
  if ("ocved-pts" %in% active) lazy_add_pts("ocved", "ocved_events", "ocved-pts", COL_AMBER, 1, 80000,
4064
+ ym_expr = TEMPORAL_LAYERS[["ocved-pts"]]$point_ym_expr)
4065
  if ("ged-pts" %in% active) lazy_add_pts("ged", "ged_events", "ged-pts", COL_LIGHT_BLUE, 1, 80000,
4066
+ ym_expr = TEMPORAL_LAYERS[["ged-pts"]]$point_ym_expr)
4067
 
4068
  # Grid: only load the nivel the user actually selected. Loading all three
4069
  # eagerly used to make first-click block the session on HF (3 sequential
 
4423
  output$temporal_ocved <- make_temporal_yr("ocved_events", "year", COL_AMBER)
4424
  output$temporal_ged <- make_temporal_yr("ged_events", "year", COL_LIGHT_BLUE)
4425
 
4426
+ # --- Temporal strip chart (bottom-of-map month-year range filter) -----------
4427
+ # Single layer (the most-recently toggled). Mirrors the analysis-panel
4428
+ # temporal plots: subtle area, full line for the in-range window, ghost line
4429
+ # for out-of-range months. No dual y-axis, no legend.
 
4430
  output$temporal_strip_chart <- renderPlot({
4431
  active <- input$temporal_active %||% character(0)
4432
  if (length(active) == 0) return(NULL)
4433
+ primary_id <- active[1]
4434
+ df_all <- .APP_CACHE$temporal[[primary_id]]
4435
+ if (is.null(df_all) || nrow(df_all) == 0) return(NULL)
4436
+ info <- TEMPORAL_LAYERS[[primary_id]]
4437
+ line_color <- info$color
4438
+
4439
+ ym_range <- TEMPORAL_RANGE
4440
+ pl <- input$temporal_filter
4441
+ ym_lo <- if (is.null(pl)) ym_range[1] else (pl$start %||% ym_range[1])
4442
+ ym_hi <- if (is.null(pl)) ym_range[2] else (pl$end %||% ym_range[2])
4443
+ df_sel <- df_all[df_all$ym >= ym_lo & df_all$ym <= ym_hi, , drop = FALSE]
4444
+
4445
+ # Year-only x ticks: pick every Jan in the data range.
4446
+ yr_lo <- ym_range[1] %/% 12L
4447
+ yr_hi <- ym_range[2] %/% 12L
4448
+ yr_seq <- seq(yr_lo, yr_hi, by = if (yr_hi - yr_lo > 12) 2L else 1L)
4449
+ yr_breaks <- yr_seq * 12L
4450
+ yr_labels <- as.character(yr_seq)
4451
+
4452
+ p <- ggplot(df_all, aes(x = ym, y = n)) +
4453
+ geom_area(fill = line_color, alpha = 0.10, color = NA) +
4454
+ geom_line(color = line_color, linewidth = 0.5, alpha = 0.35)
4455
+ if (nrow(df_sel) > 0)
4456
+ p <- p + geom_line(data = df_sel, aes(x = ym, y = n),
4457
+ color = line_color, linewidth = 1.1)
4458
 
4459
+ p +
4460
+ scale_x_continuous(breaks = yr_breaks, labels = yr_labels,
4461
+ limits = c(ym_range[1] - 0.5, ym_range[2] + 0.5),
4462
+ expand = expansion(mult = c(0, 0))) +
4463
+ scale_y_continuous(expand = expansion(mult = c(0, 0.08))) +
4464
+ labs(x = NULL, y = NULL) +
4465
+ theme_minimal(base_size = 9) +
4466
  theme(plot.background = element_rect(fill = "transparent", color = NA),
4467
  panel.background = element_rect(fill = "transparent", color = NA),
4468
  panel.grid = element_blank(),
4469
+ panel.grid.major.y = element_line(color = "#ffffff14", linewidth = 0.25),
4470
+ axis.title = element_blank(),
4471
  axis.text.x = element_text(size = 8, color = "#cbd5e1"),
4472
+ axis.text.y = element_blank(),
 
 
 
4473
  axis.ticks = element_blank(),
4474
  legend.position = "none",
4475
+ plot.margin = margin(1, 6, 1, 6))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4476
  }, bg = "transparent", res = 110)
4477
 
4478
+ # PAM month-window re-aggregation: when the strip range changes and PAM is
4479
+ # active, re-query src_pam_ym + push the new fill via the proxy. The current
4480
+ # layer is removed and re-added with the ym-filtered source.
4481
  observeEvent(input$temporal_filter, {
4482
  pl <- input$temporal_filter
4483
  if (is.null(pl)) return()
4484
  active <- pl$active %||% character(0)
4485
  if (!("pam-choro" %in% active) || !isTRUE(lazy_added$pam)) return()
4486
+ ym_lo <- pl$start %||% TEMPORAL_RANGE[1]
4487
+ ym_hi <- pl$end %||% TEMPORAL_RANGE[2]
4488
  sf_data <- tryCatch({
4489
  DBI::dbGetQuery(con,
4490
+ web_sql("src_pam_ym", ym_lo = ym_lo, ym_hi = ym_hi)) |>
4491
  sf::st_as_sf(wkt = "wkt", crs = 4326) |>
4492
  dplyr::select(-wkt)
4493
+ }, error = function(e) { message("PAM ym re-query: ", e$message); NULL })
4494
  if (is.null(sf_data)) return()
4495
  maplibre_proxy("map") |>
4496
  remove_layer("pam-choro") |>
styles.css CHANGED
@@ -878,6 +878,27 @@
878
  color: var(--c-text);
879
  }
880
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
881
  .acc-info-wrap {
882
  flex-shrink: 0;
883
  cursor: help;
 
878
  color: var(--c-text);
879
  }
880
 
881
+ /* -- Loading indicator (first-time fetch of a heavy layer) ------------------ */
882
+ /* Spinner sits in the toggle-track, on top of the thumb. Cleared by the
883
+ `layer_loaded` custom message from the server. */
884
+ .acc-item-wrap.is-loading .acc-toggle-text::after {
885
+ content: "";
886
+ display: inline-block;
887
+ width: 10px;
888
+ height: 10px;
889
+ margin-left: 6px;
890
+ vertical-align: -1px;
891
+ border: 1.5px solid var(--c-accent);
892
+ border-top-color: transparent;
893
+ border-radius: 50%;
894
+ animation: acc-spin 0.7s linear infinite;
895
+ }
896
+ .acc-item-wrap.is-loading .acc-toggle-text {
897
+ color: var(--c-text);
898
+ opacity: 0.85;
899
+ }
900
+ @keyframes acc-spin { to { transform: rotate(360deg); } }
901
+
902
  .acc-info-wrap {
903
  flex-shrink: 0;
904
  cursor: help;