edgarodriguez commited on
Commit
1a5b84a
·
verified ·
1 Parent(s): 040cdee

Upload 2 files

Browse files
Files changed (2) hide show
  1. depth_interactive_map_alpha.R +409 -10
  2. styles.css +159 -0
depth_interactive_map_alpha.R CHANGED
@@ -248,6 +248,35 @@ POBREZA_COLS <- list(
248
  breaks = c(2, 5, 10, 20, 35, 55))
249
  )
250
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
251
  #######################################################################################
252
  # WEB_SQL -- canonical query templates (single source for Shiny + docs converter)
253
  # Static templates use {placeholder} tokens substituted by web_sql().
@@ -539,6 +568,33 @@ WEB_SQL_TEMPLATES <- list(
539
  FROM dim_fosas_mun
540
  WHERE CVEGEO IN ({cvegeo_list})",
541
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
542
  temporal_yr_all =
543
  "SELECT {yr_expr} AS yr, COUNT(*) AS n FROM {tbl}
544
  WHERE {yr_col} IS NOT NULL
@@ -775,7 +831,23 @@ ddb_minmax <- function(con, tbl, col, filter_zero = TRUE) {
775
  pop = ddb_minmax(con, "crime_mun", "poblacion", TRUE),
776
  fosas = ddb_minmax(con, "dim_fosas_mun", "fosas_total", TRUE),
777
  envipe = ddb_minmax(con, "state_polygons", "perception_insecurity_perc", FALSE)
778
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
779
  )
780
  }
781
  .APP_CACHE <- .load_app_cache()
@@ -1297,6 +1369,11 @@ LUCIDE_JS <- tags$head(
1297
  description: 'Tilt the map into a 3D perspective view.',
1298
  side: 'bottom', align: 'end'
1299
  }},
 
 
 
 
 
1300
  { element: '#layer-ctrl-body', popover: {
1301
  title: 'Data layers',
1302
  description: 'Turn indicators on and off here. They are grouped into Hazards, Exposure and Vulnerability. Each layer has a short description and its own legend.',
@@ -1363,15 +1440,163 @@ LUCIDE_JS <- tags$head(
1363
  var isOn = !!(input && input.checked);
1364
  var primary = wrap.querySelector(':scope > .acc-legend');
1365
  if (primary) primary.classList.toggle('legend-visible', isOn);
1366
- // Variant legends (categorical chips) -- gate visibility on toggle ON
1367
  wrap.querySelectorAll(':scope > .acc-legend-extra').forEach(function(v) {
1368
  v.classList.toggle('legend-visible', isOn);
1369
  });
1370
  });
1371
- // Re-apply the active-variant marker so the right extras stay visible.
1372
  if (typeof refreshLegendVariants === 'function') refreshLegendVariants();
1373
  if (typeof Shiny !== 'undefined')
1374
  Shiny.setInputValue('active_layers', checked, { priority: 'event' });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1375
  }
1376
 
1377
  // Selects which legend variant is visible for each layer family. Called
@@ -2438,6 +2663,58 @@ ui <- page_fillable(
2438
 
2439
  maplibreOutput("map", width = "100%", height = "100%"),
2440
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2441
  # --- Accordion layer panel (bottom-left) --------------------------------
2442
  tags$section(class = "layer-ctrl-panel",
2443
  role = "region",
@@ -2765,12 +3042,22 @@ server <- function(input, output, session) {
2765
  list("case", list("==", list("get", col), 0), "rgba(0,0,0,0)", inner)
2766
  }
2767
 
 
 
 
 
 
 
 
 
 
2768
  giim_match_expr <- function() {
2769
  pairs <- unlist(
2770
  lapply(names(GIIM_COLORS), function(nm) list(nm, unname(GIIM_COLORS[[nm]]))),
2771
  recursive = FALSE
2772
  )
2773
- c(list("match", list("get", "giim_mun")), pairs, list("#cccccc"))
 
2774
  }
2775
 
2776
  # Categorical paint expression for infra-pts (colour by normalised category).
@@ -2797,7 +3084,8 @@ server <- function(input, output, session) {
2797
 
2798
  desap_qstep_expr <- function() {
2799
  breaks <- qb$desap %||% c(5, 15, 40, 80, 150)
2800
- step_expr("desap_per_100k", breaks, DESAP_PAL)
 
2801
  }
2802
 
2803
  pam_qstep_expr <- function() {
@@ -2811,7 +3099,7 @@ server <- function(input, output, session) {
2811
  pop_grid_expr <- function(pop_type = "POBTOT", mode = "grid") {
2812
  info <- POP_COLS[[pop_type]] %||% POP_COLS[["POBTOT"]]
2813
  breaks <- if (identical(mode, "mun")) info$breaks_mun else info$breaks_grid
2814
- step_expr(info$col, breaks, POP_PAL)
2815
  }
2816
 
2817
  # Envipe on the GRID: each grid cell carries its containing state's
@@ -2819,11 +3107,12 @@ server <- function(input, output, session) {
2819
  # grid query). The sub-type radio swaps the column the step expression reads.
2820
  envipe_grid_expr <- function(envipe_type = "insecurity") {
2821
  info <- ENVIPE_COLS[[envipe_type]] %||% ENVIPE_COLS[["insecurity"]]
2822
- step_expr(info$col, info$breaks, ENVIPE_PAL)
2823
  }
2824
 
2825
  # PATP-CONEVAL on the GRID. Each sub-type is either "num" (step paint via
2826
  # TRY_CAST'd column) or "cat" (match paint over a fixed Spanish-label palette).
 
2827
  patp_grid_expr <- function(patp_type = "t_part") {
2828
  info <- PATP_COLS[[patp_type]] %||% PATP_COLS[["t_part"]]
2829
  if (identical(info$kind, "cat")) {
@@ -2834,16 +3123,18 @@ server <- function(input, output, session) {
2834
  lapply(names(pal), function(nm) list(nm, unname(pal[[nm]]))),
2835
  recursive = FALSE
2836
  )
2837
- c(list("match", list("get", info$col)), pairs, list("#bdbdbd"))
 
 
2838
  } else {
2839
- step_expr(info$col, info$breaks, PATP_PAL)
2840
  }
2841
  }
2842
 
2843
  # Pobreza (CONEVAL 2020). Two numeric sub-types only.
2844
  pobreza_grid_expr <- function(pobreza_type = "pobreza_pct") {
2845
  info <- POBREZA_COLS[[pobreza_type]] %||% POBREZA_COLS[["pobreza_pct"]]
2846
- step_expr(info$col, info$breaks, POBREZA_PAL)
2847
  }
2848
 
2849
  # Return the fill-color expression for a given original layer ID
@@ -4027,6 +4318,114 @@ server <- function(input, output, session) {
4027
  output$temporal_ocved <- make_temporal_yr("ocved_events", "year", COL_AMBER)
4028
  output$temporal_ged <- make_temporal_yr("ged_events", "year", COL_LIGHT_BLUE)
4029
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4030
  # --- PAM temporal plot (irregular migration events over time) ---------------
4031
  # dim_pam_mun is municipality-aggregated by date. National = SUM over all
4032
  # munis; selection = SUM over munis in rv$mun_df (CVEGEO list).
 
248
  breaks = c(2, 5, 10, 20, 35, 55))
249
  )
250
 
251
+ # Temporal-strip layers. Each entry drives the bottom-of-map year filter:
252
+ # year_col -- the column carrying the integer year (used in WHERE / GROUP)
253
+ # year_expr -- optional: full DuckDB expression when the year isn't a column
254
+ # directly (e.g. PAM stores DATE; we extract date_part('year', ...))
255
+ # agg -- aggregation used in the strip chart (default COUNT(*); PAM
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
+ kind = "point"),
263
+ "ocved-pts" = list(label = "OCVED violence", color = COL_AMBER,
264
+ table = "ocved_events", year_col = "year",
265
+ kind = "point"),
266
+ "ged-pts" = list(label = "GED conflict", color = COL_LIGHT_BLUE,
267
+ table = "ged_events", year_col = "year",
268
+ kind = "point"),
269
+ "pam-choro" = list(label = "PAM events", color = COL_COUNT,
270
+ table = "dim_pam_mun",
271
+ year_col = "yr",
272
+ year_expr = "CAST(date_part('year', date) AS INTEGER)",
273
+ agg = "SUM(pam_n)",
274
+ kind = "polygon")
275
+ )
276
+ # Outer bounds for the slider (union across the four tables, with a safety
277
+ # margin). Inner per-layer ranges are computed at startup from the data.
278
+ TEMPORAL_RANGE <- c(2010L, 2026L)
279
+
280
  #######################################################################################
281
  # WEB_SQL -- canonical query templates (single source for Shiny + docs converter)
282
  # Static templates use {placeholder} tokens substituted by web_sql().
 
568
  FROM dim_fosas_mun
569
  WHERE CVEGEO IN ({cvegeo_list})",
570
 
571
+ # --- Temporal strip support -----------------------------------------------
572
+ # Counts per year for each of the 4 temporal layers. Used to draw the
573
+ # histogram in the strip. `yr_expr` is either the raw year column or a
574
+ # date_part() expression; `agg` is COUNT(*) for point tables / SUM(pam_n)
575
+ # for PAM. Cached at startup -- they don't change.
576
+ ts_yr_counts =
577
+ "SELECT {yr_expr} AS yr, {agg} AS n
578
+ FROM {tbl}
579
+ WHERE {yr_col_or_expr} IS NOT NULL
580
+ GROUP BY yr HAVING yr >= 2000 AND yr <= 2030
581
+ ORDER BY yr",
582
+
583
+ # Year-window-filtered PAM aggregate (used when the user moves the strip
584
+ # handles). Same shape as src_pam but with a WHERE on date_part(year).
585
+ src_pam_year =
586
+ "SELECT c.municipio, c.entidad, c.CVEGEO,
587
+ COALESCE(CAST(p.pam_total AS INTEGER), 0) AS pam_n,
588
+ c.wkt
589
+ FROM crime_mun c
590
+ LEFT JOIN (
591
+ SELECT CVEGEO, SUM(pam_n) AS pam_total
592
+ FROM dim_pam_mun
593
+ WHERE date IS NOT NULL
594
+ AND CAST(date_part('year', date) AS INTEGER) BETWEEN {yr_lo} AND {yr_hi}
595
+ GROUP BY CVEGEO
596
+ ) p ON c.CVEGEO = p.CVEGEO",
597
+
598
  temporal_yr_all =
599
  "SELECT {yr_expr} AS yr, COUNT(*) AS n FROM {tbl}
600
  WHERE {yr_col} IS NOT NULL
 
831
  pop = ddb_minmax(con, "crime_mun", "poblacion", TRUE),
832
  fosas = ddb_minmax(con, "dim_fosas_mun", "fosas_total", TRUE),
833
  envipe = ddb_minmax(con, "state_polygons", "perception_insecurity_perc", FALSE)
834
+ ),
835
+ # Per-year counts for each temporal-strip layer. Each entry is a tidy
836
+ # data.frame(yr, n) or NULL when the table isn't available.
837
+ temporal = lapply(TEMPORAL_LAYERS, function(info) {
838
+ yr_expr <- info$year_expr %||% info$year_col
839
+ yr_chk <- info$year_col %||% "yr"
840
+ yr_chk <- if (is.null(info$year_expr)) info$year_col else info$year_expr
841
+ agg_expr <- info$agg %||% "COUNT(*)"
842
+ sql <- glue::glue_data(
843
+ list(yr_expr = yr_expr, agg = agg_expr,
844
+ tbl = info$table, yr_col_or_expr = yr_chk),
845
+ WEB_SQL_TEMPLATES$ts_yr_counts,
846
+ .open = "{", .close = "}"
847
+ )
848
+ tryCatch(DBI::dbGetQuery(con, sql),
849
+ error = function(e) { message("[cache] temporal ", info$label, ": ", e$message); NULL })
850
+ })
851
  )
852
  }
853
  .APP_CACHE <- .load_app_cache()
 
1369
  description: 'Tilt the map into a 3D perspective view.',
1370
  side: 'bottom', align: 'end'
1371
  }},
1372
+ { element: '#tooltip-toggle-btn', popover: {
1373
+ title: 'Hover tooltips',
1374
+ description: 'Toggle the dark info bubble that appears when you hover a municipality, grid cell, or point. Useful when you want to read the map without it popping up over every feature.',
1375
+ side: 'bottom', align: 'end'
1376
+ }},
1377
  { element: '#layer-ctrl-body', popover: {
1378
  title: 'Data layers',
1379
  description: 'Turn indicators on and off here. They are grouped into Hazards, Exposure and Vulnerability. Each layer has a short description and its own legend.',
 
1440
  var isOn = !!(input && input.checked);
1441
  var primary = wrap.querySelector(':scope > .acc-legend');
1442
  if (primary) primary.classList.toggle('legend-visible', isOn);
 
1443
  wrap.querySelectorAll(':scope > .acc-legend-extra').forEach(function(v) {
1444
  v.classList.toggle('legend-visible', isOn);
1445
  });
1446
  });
 
1447
  if (typeof refreshLegendVariants === 'function') refreshLegendVariants();
1448
  if (typeof Shiny !== 'undefined')
1449
  Shiny.setInputValue('active_layers', checked, { priority: 'event' });
1450
+ // Update temporal strip visibility + which layers it's tracking
1451
+ if (typeof syncTemporalStrip === 'function') syncTemporalStrip(checked);
1452
+ }
1453
+
1454
+ // -- Temporal strip: bottom-of-map year-range filter ------------------------
1455
+ // _temporal.active holds the LAST 2 toggled-on temporal layers (most recent
1456
+ // first). The chart and the dual y-axis follow them. yrLo/yrHi drive the
1457
+ // map filter for those layers (client-side for points, server for PAM).
1458
+ var TEMPORAL_LAYER_IDS = ['migrants-pts','ocved-pts','ged-pts','pam-choro'];
1459
+ var TEMPORAL_POINT_COL = {
1460
+ 'migrants-pts': 'incident_year',
1461
+ 'ocved-pts': 'year',
1462
+ 'ged-pts': 'year'
1463
+ };
1464
+ var _temporal = {
1465
+ active: [], // most-recent-first
1466
+ yrLo: null,
1467
+ yrHi: null,
1468
+ playing: false,
1469
+ playTimer: null
1470
+ };
1471
+
1472
+ function syncTemporalStrip(checkedIds) {
1473
+ var prevActive = _temporal.active.slice();
1474
+ var nowOn = (checkedIds || []).filter(function(id) {
1475
+ return TEMPORAL_LAYER_IDS.indexOf(id) !== -1;
1476
+ });
1477
+ // Keep MRU order: anything newly turned on goes to the front; anything
1478
+ // turned off drops out. Then trim to the most recent 2.
1479
+ var stillOn = prevActive.filter(function(id) { return nowOn.indexOf(id) !== -1; });
1480
+ var newOnes = nowOn.filter(function(id) { return stillOn.indexOf(id) === -1; });
1481
+ _temporal.active = newOnes.concat(stillOn).slice(0, 2);
1482
+ var strip = document.getElementById('temporal-strip');
1483
+ if (strip) strip.style.display = _temporal.active.length ? 'flex' : 'none';
1484
+ // Tell Shiny which layers the chart should draw (server reactive)
1485
+ if (typeof Shiny !== 'undefined') {
1486
+ Shiny.setInputValue('temporal_active', _temporal.active, { priority: 'event' });
1487
+ }
1488
+ // Re-apply current year filter to the newly-active layers
1489
+ pushTemporalFilter();
1490
+ }
1491
+
1492
+ function _getMap() {
1493
+ var widget = window.HTMLWidgets && HTMLWidgets.find('#map');
1494
+ if (!widget) return null;
1495
+ return widget.map || (widget.getMap && widget.getMap()) || null;
1496
+ }
1497
+
1498
+ function applyTemporalFilterClient(layerId, yrLo, yrHi) {
1499
+ var m = _getMap();
1500
+ if (!m || !m.getLayer || !m.getLayer(layerId)) return;
1501
+ var col = TEMPORAL_POINT_COL[layerId];
1502
+ if (!col) return;
1503
+ // Full-range = clear the filter so feature culling is fastest
1504
+ var startMin = parseInt(document.getElementById('temporal-start').min, 10);
1505
+ var endMax = parseInt(document.getElementById('temporal-end').max, 10);
1506
+ if (yrLo <= startMin && yrHi >= endMax) {
1507
+ m.setFilter(layerId, null);
1508
+ } else {
1509
+ m.setFilter(layerId, ['all',
1510
+ ['>=', ['to-number', ['get', col]], yrLo],
1511
+ ['<=', ['to-number', ['get', col]], yrHi]
1512
+ ]);
1513
+ }
1514
+ }
1515
+
1516
+ function pushTemporalFilter() {
1517
+ var startEl = document.getElementById('temporal-start');
1518
+ var endEl = document.getElementById('temporal-end');
1519
+ if (!startEl || !endEl) return;
1520
+ var yrLo = parseInt(startEl.value, 10);
1521
+ var yrHi = parseInt(endEl.value, 10);
1522
+ if (yrLo > yrHi) { var t = yrLo; yrLo = yrHi; yrHi = t; }
1523
+ _temporal.yrLo = yrLo; _temporal.yrHi = yrHi;
1524
+ // Client: apply for each active POINT layer
1525
+ _temporal.active.forEach(function(id) {
1526
+ if (TEMPORAL_POINT_COL[id]) applyTemporalFilterClient(id, yrLo, yrHi);
1527
+ });
1528
+ // Server: send range so PAM can re-query if it's active
1529
+ if (typeof Shiny !== 'undefined') {
1530
+ Shiny.setInputValue('temporal_filter', {
1531
+ start: yrLo, end: yrHi, active: _temporal.active, ts: Date.now()
1532
+ }, { priority: 'event' });
1533
+ }
1534
+ }
1535
+
1536
+ function onTemporalSliderInput() {
1537
+ var startEl = document.getElementById('temporal-start');
1538
+ var endEl = document.getElementById('temporal-end');
1539
+ var fill = document.getElementById('temporal-slider-fill');
1540
+ var label = document.getElementById('temporal-year-label');
1541
+ if (!startEl || !endEl) return;
1542
+ var lo = parseInt(startEl.value, 10);
1543
+ var hi = parseInt(endEl.value, 10);
1544
+ // Prevent crossing handles
1545
+ if (lo > hi) {
1546
+ if (document.activeElement === startEl) { hi = lo; endEl.value = hi; }
1547
+ else { lo = hi; startEl.value = lo; }
1548
+ }
1549
+ if (label) label.textContent = lo + ' — ' + hi;
1550
+ if (fill) {
1551
+ var min = parseInt(startEl.min, 10);
1552
+ var max = parseInt(startEl.max, 10);
1553
+ var span = (max - min) || 1;
1554
+ fill.style.left = (((lo - min) / span) * 100) + '%';
1555
+ fill.style.right = ((1 - ((hi - min) / span)) * 100) + '%';
1556
+ }
1557
+ pushTemporalFilter();
1558
+ }
1559
+
1560
+ function toggleTemporalPlay() {
1561
+ _temporal.playing = !_temporal.playing;
1562
+ var btn = document.getElementById('temporal-play-btn');
1563
+ var icon = btn ? btn.querySelector('i') : null;
1564
+ if (icon) {
1565
+ icon.setAttribute('data-lucide', _temporal.playing ? 'pause' : 'play');
1566
+ if (window.lucide) lucide.createIcons();
1567
+ }
1568
+ if (_temporal.playing) {
1569
+ _temporal.playTimer = setInterval(function() {
1570
+ var endEl = document.getElementById('temporal-end');
1571
+ if (!endEl) return;
1572
+ var max = parseInt(endEl.max, 10);
1573
+ var cur = parseInt(endEl.value, 10);
1574
+ var next = cur + 1;
1575
+ if (next > max) {
1576
+ // Loop back to start: reset both handles
1577
+ var startEl = document.getElementById('temporal-start');
1578
+ var min = parseInt(endEl.min, 10);
1579
+ if (startEl) startEl.value = min;
1580
+ endEl.value = min + 1;
1581
+ } else {
1582
+ endEl.value = next;
1583
+ }
1584
+ onTemporalSliderInput();
1585
+ }, 600);
1586
+ } else if (_temporal.playTimer) {
1587
+ clearInterval(_temporal.playTimer);
1588
+ _temporal.playTimer = null;
1589
+ }
1590
+ }
1591
+
1592
+ function resetTemporalRange() {
1593
+ var startEl = document.getElementById('temporal-start');
1594
+ var endEl = document.getElementById('temporal-end');
1595
+ if (!startEl || !endEl) return;
1596
+ startEl.value = startEl.min;
1597
+ endEl.value = endEl.max;
1598
+ if (_temporal.playing) toggleTemporalPlay();
1599
+ onTemporalSliderInput();
1600
  }
1601
 
1602
  // Selects which legend variant is visible for each layer family. Called
 
2663
 
2664
  maplibreOutput("map", width = "100%", height = "100%"),
2665
 
2666
+ # --- Temporal strip (bottom-of-map, hidden until a temporal layer is on)
2667
+ # Built for migrants / OCVED / GED / PAM. Filter applies via JS for
2668
+ # point layers and via server re-query for PAM (polygon).
2669
+ div(id = "temporal-strip", class = "temporal-strip",
2670
+ style = "display:none;",
2671
+ role = "region",
2672
+ `aria-label` = "Temporal year-range filter",
2673
+ div(class = "temporal-strip-chart",
2674
+ plotOutput("temporal_strip_chart",
2675
+ width = "100%", height = "85px")),
2676
+ div(class = "temporal-strip-controls",
2677
+ tags$button(id = "temporal-play-btn",
2678
+ type = "button",
2679
+ class = "temporal-play-btn",
2680
+ onclick = "toggleTemporalPlay()",
2681
+ `aria-label` = "Play or pause the temporal animation",
2682
+ title = "Play / pause",
2683
+ HTML('<i data-lucide="play" style="width:16px;height:16px;stroke-width:2"></i>')),
2684
+ div(class = "temporal-slider-wrap",
2685
+ div(class = "temporal-slider-track"),
2686
+ div(id = "temporal-slider-fill",
2687
+ class = "temporal-slider-fill"),
2688
+ tags$input(type = "range",
2689
+ id = "temporal-start",
2690
+ class = "temporal-slider-input temporal-slider-start",
2691
+ min = TEMPORAL_RANGE[1],
2692
+ max = TEMPORAL_RANGE[2],
2693
+ step = "1",
2694
+ value = TEMPORAL_RANGE[1],
2695
+ oninput = "onTemporalSliderInput()",
2696
+ `aria-label` = "Start year"),
2697
+ tags$input(type = "range",
2698
+ id = "temporal-end",
2699
+ class = "temporal-slider-input temporal-slider-end",
2700
+ min = TEMPORAL_RANGE[1],
2701
+ max = TEMPORAL_RANGE[2],
2702
+ step = "1",
2703
+ value = TEMPORAL_RANGE[2],
2704
+ oninput = "onTemporalSliderInput()",
2705
+ `aria-label` = "End year")),
2706
+ tags$span(id = "temporal-year-label",
2707
+ class = "temporal-year-label",
2708
+ paste0(TEMPORAL_RANGE[1], " — ", TEMPORAL_RANGE[2])),
2709
+ tags$button(id = "temporal-reset-btn",
2710
+ type = "button",
2711
+ class = "temporal-reset-btn",
2712
+ onclick = "resetTemporalRange()",
2713
+ `aria-label` = "Reset to full range",
2714
+ title = "Reset to all years",
2715
+ "All"))
2716
+ ),
2717
+
2718
  # --- Accordion layer panel (bottom-left) --------------------------------
2719
  tags$section(class = "layer-ctrl-panel",
2720
  role = "region",
 
3042
  list("case", list("==", list("get", col), 0), "rgba(0,0,0,0)", inner)
3043
  }
3044
 
3045
+ # Wrap any paint expression so that cells whose column is NULL / missing
3046
+ # render fully transparent (instead of the lightest palette stop, which
3047
+ # makes "no data" indistinguishable from "minimum data").
3048
+ na_transparent <- function(col, expr) {
3049
+ list("case",
3050
+ list("==", list("typeof", list("get", col)), "null"), "rgba(0,0,0,0)",
3051
+ expr)
3052
+ }
3053
+
3054
  giim_match_expr <- function() {
3055
  pairs <- unlist(
3056
  lapply(names(GIIM_COLORS), function(nm) list(nm, unname(GIIM_COLORS[[nm]]))),
3057
  recursive = FALSE
3058
  )
3059
+ # No-match fallback is transparent so munis with NULL giim_mun show no fill.
3060
+ c(list("match", list("get", "giim_mun")), pairs, list("rgba(0,0,0,0)"))
3061
  }
3062
 
3063
  # Categorical paint expression for infra-pts (colour by normalised category).
 
3084
 
3085
  desap_qstep_expr <- function() {
3086
  breaks <- qb$desap %||% c(5, 15, 40, 80, 150)
3087
+ na_transparent("desap_per_100k",
3088
+ step_expr("desap_per_100k", breaks, DESAP_PAL))
3089
  }
3090
 
3091
  pam_qstep_expr <- function() {
 
3099
  pop_grid_expr <- function(pop_type = "POBTOT", mode = "grid") {
3100
  info <- POP_COLS[[pop_type]] %||% POP_COLS[["POBTOT"]]
3101
  breaks <- if (identical(mode, "mun")) info$breaks_mun else info$breaks_grid
3102
+ na_transparent(info$col, step_expr(info$col, breaks, POP_PAL))
3103
  }
3104
 
3105
  # Envipe on the GRID: each grid cell carries its containing state's
 
3107
  # grid query). The sub-type radio swaps the column the step expression reads.
3108
  envipe_grid_expr <- function(envipe_type = "insecurity") {
3109
  info <- ENVIPE_COLS[[envipe_type]] %||% ENVIPE_COLS[["insecurity"]]
3110
+ na_transparent(info$col, step_expr(info$col, info$breaks, ENVIPE_PAL))
3111
  }
3112
 
3113
  # PATP-CONEVAL on the GRID. Each sub-type is either "num" (step paint via
3114
  # TRY_CAST'd column) or "cat" (match paint over a fixed Spanish-label palette).
3115
+ # In both cases, NULL / unmatched values render fully transparent.
3116
  patp_grid_expr <- function(patp_type = "t_part") {
3117
  info <- PATP_COLS[[patp_type]] %||% PATP_COLS[["t_part"]]
3118
  if (identical(info$kind, "cat")) {
 
3123
  lapply(names(pal), function(nm) list(nm, unname(pal[[nm]]))),
3124
  recursive = FALSE
3125
  )
3126
+ # No-match fallback transparent; wrap in na_transparent in case the col is NULL
3127
+ na_transparent(info$col,
3128
+ c(list("match", list("get", info$col)), pairs, list("rgba(0,0,0,0)")))
3129
  } else {
3130
+ na_transparent(info$col, step_expr(info$col, info$breaks, PATP_PAL))
3131
  }
3132
  }
3133
 
3134
  # Pobreza (CONEVAL 2020). Two numeric sub-types only.
3135
  pobreza_grid_expr <- function(pobreza_type = "pobreza_pct") {
3136
  info <- POBREZA_COLS[[pobreza_type]] %||% POBREZA_COLS[["pobreza_pct"]]
3137
+ na_transparent(info$col, step_expr(info$col, info$breaks, POBREZA_PAL))
3138
  }
3139
 
3140
  # Return the fill-color expression for a given original layer ID
 
4318
  output$temporal_ocved <- make_temporal_yr("ocved_events", "year", COL_AMBER)
4319
  output$temporal_ged <- make_temporal_yr("ged_events", "year", COL_LIGHT_BLUE)
4320
 
4321
+ # --- Temporal strip chart (bottom-of-map year-range filter) ---------------
4322
+ # Renders up to 2 lines from .APP_CACHE$temporal, one per active temporal
4323
+ # layer (active list driven by input$temporal_active). When 2 are active,
4324
+ # the chart has dual y-axes (left = first/primary, right = secondary)
4325
+ # because event magnitudes differ wildly (PAM ~1.6M vs migrants ~6k).
4326
+ output$temporal_strip_chart <- renderPlot({
4327
+ active <- input$temporal_active %||% character(0)
4328
+ if (length(active) == 0) return(NULL)
4329
+ yr_range <- TEMPORAL_RANGE
4330
+ rect_df <- {
4331
+ pl <- input$temporal_filter
4332
+ if (is.null(pl)) NULL
4333
+ else data.frame(xmin = (pl$start %||% yr_range[1]) - 0.5,
4334
+ xmax = (pl$end %||% yr_range[2]) + 0.5)
4335
+ }
4336
+ pull_df <- function(id) {
4337
+ df <- .APP_CACHE$temporal[[id]]
4338
+ if (is.null(df) || nrow(df) == 0) return(NULL)
4339
+ info <- TEMPORAL_LAYERS[[id]]
4340
+ data.frame(yr = df$yr, n = df$n, layer = info$label,
4341
+ color = info$color, stringsAsFactors = FALSE)
4342
+ }
4343
+ primary <- pull_df(active[1])
4344
+ secondary <- if (length(active) >= 2) pull_df(active[2]) else NULL
4345
+ if (is.null(primary)) return(NULL)
4346
+
4347
+ base_thm <- theme_minimal(base_size = 9) +
4348
+ theme(plot.background = element_rect(fill = "transparent", color = NA),
4349
+ panel.background = element_rect(fill = "transparent", color = NA),
4350
+ panel.grid = element_blank(),
4351
+ panel.grid.major.y = element_line(color = "#ffffff20", linewidth = 0.3),
4352
+ axis.title.x = element_blank(),
4353
+ axis.text.x = element_text(size = 8, color = "#cbd5e1"),
4354
+ axis.title.y = element_text(size = 8, color = "#cbd5e1"),
4355
+ axis.text.y = element_text(size = 7, color = "#94a3b8"),
4356
+ axis.title.y.right = element_text(size = 8, color = "#cbd5e1"),
4357
+ axis.text.y.right = element_text(size = 7, color = "#94a3b8"),
4358
+ axis.ticks = element_blank(),
4359
+ legend.position = "none",
4360
+ plot.margin = margin(2, 8, 2, 8))
4361
+
4362
+ p <- ggplot()
4363
+ if (!is.null(rect_df))
4364
+ p <- p + geom_rect(data = rect_df,
4365
+ aes(xmin = xmin, xmax = xmax,
4366
+ ymin = -Inf, ymax = Inf),
4367
+ fill = "#ffffff", alpha = 0.06,
4368
+ inherit.aes = FALSE)
4369
+
4370
+ if (is.null(secondary)) {
4371
+ p <- p +
4372
+ geom_line(data = primary, aes(x = yr, y = n),
4373
+ color = primary$color[1], linewidth = 1) +
4374
+ geom_point(data = primary, aes(x = yr, y = n),
4375
+ color = primary$color[1], size = 1.4) +
4376
+ scale_y_continuous(primary$layer[1])
4377
+ } else {
4378
+ max_p <- max(primary$n, na.rm = TRUE)
4379
+ max_s <- max(secondary$n, na.rm = TRUE)
4380
+ scale_factor <- if (max_s > 0) max_p / max_s else 1
4381
+ p <- p +
4382
+ geom_line(data = primary, aes(x = yr, y = n),
4383
+ color = primary$color[1], linewidth = 1) +
4384
+ geom_point(data = primary, aes(x = yr, y = n),
4385
+ color = primary$color[1], size = 1.4) +
4386
+ geom_line(data = secondary, aes(x = yr, y = n * scale_factor),
4387
+ color = secondary$color[1], linewidth = 1, linetype = "32") +
4388
+ geom_point(data = secondary, aes(x = yr, y = n * scale_factor),
4389
+ color = secondary$color[1], size = 1.4) +
4390
+ scale_y_continuous(
4391
+ name = primary$layer[1],
4392
+ sec.axis = sec_axis(~ . / scale_factor, name = secondary$layer[1]))
4393
+ }
4394
+ p +
4395
+ scale_x_continuous(breaks = scales::pretty_breaks(n = 6),
4396
+ limits = c(yr_range[1] - 0.5, yr_range[2] + 0.5)) +
4397
+ base_thm
4398
+ }, bg = "transparent", res = 110)
4399
+
4400
+ # PAM year-window re-aggregation: when the strip range changes and PAM is
4401
+ # active, re-query src_pam_year + push the new fill via the proxy. The
4402
+ # current layer is removed and re-added with the year-filtered source.
4403
+ observeEvent(input$temporal_filter, {
4404
+ pl <- input$temporal_filter
4405
+ if (is.null(pl)) return()
4406
+ active <- pl$active %||% character(0)
4407
+ if (!("pam-choro" %in% active) || !isTRUE(lazy_added$pam)) return()
4408
+ yr_lo <- pl$start %||% TEMPORAL_RANGE[1]
4409
+ yr_hi <- pl$end %||% TEMPORAL_RANGE[2]
4410
+ sf_data <- tryCatch({
4411
+ DBI::dbGetQuery(con,
4412
+ web_sql("src_pam_year", yr_lo = yr_lo, yr_hi = yr_hi)) |>
4413
+ sf::st_as_sf(wkt = "wkt", crs = 4326) |>
4414
+ dplyr::select(-wkt)
4415
+ }, error = function(e) { message("PAM year re-query: ", e$message); NULL })
4416
+ if (is.null(sf_data)) return()
4417
+ maplibre_proxy("map") |>
4418
+ remove_layer("pam-choro") |>
4419
+ add_fill_layer(
4420
+ id = "pam-choro",
4421
+ source = sf_data,
4422
+ fill_color = pam_qstep_expr(),
4423
+ fill_opacity = 0.7,
4424
+ tooltip = list("concat", "<b>", list("get","municipio"), "</b>",
4425
+ "<br>Irregular migration events: ",
4426
+ list("to-string", list("get","pam_n"))))
4427
+ }, ignoreNULL = TRUE, ignoreInit = TRUE)
4428
+
4429
  # --- PAM temporal plot (irregular migration events over time) ---------------
4430
  # dim_pam_mun is municipality-aggregated by date. National = SUM over all
4431
  # munis; selection = SUM over munis in rv$mun_df (CVEGEO list).
styles.css CHANGED
@@ -2463,3 +2463,162 @@ body.tooltips-off .maplibregl-popup { display: none !important; }
2463
  .acc-legend.acc-legend-hidden-by-variant {
2464
  display: none !important;
2465
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2463
  .acc-legend.acc-legend-hidden-by-variant {
2464
  display: none !important;
2465
  }
2466
+
2467
+ /* =========================================================================
2468
+ Temporal strip (bottom-of-map year-range filter)
2469
+ ========================================================================= */
2470
+ .temporal-strip {
2471
+ position: absolute;
2472
+ bottom: 0;
2473
+ left: 0;
2474
+ right: 0;
2475
+ z-index: 950; /* below nav (1000) + popovers, above map */
2476
+ background: oklch(15% 0.012 250 / 0.92);
2477
+ backdrop-filter: blur(12px) saturate(140%);
2478
+ -webkit-backdrop-filter: blur(12px) saturate(140%);
2479
+ border-top: 1px solid oklch(100% 0 0 / 0.08);
2480
+ padding: 6px 14px 8px;
2481
+ display: flex;
2482
+ flex-direction: column;
2483
+ gap: 6px;
2484
+ pointer-events: auto;
2485
+ }
2486
+
2487
+ .temporal-strip-chart {
2488
+ width: 100%;
2489
+ }
2490
+ .temporal-strip-chart .shiny-plot-output {
2491
+ background: transparent;
2492
+ }
2493
+
2494
+ .temporal-strip-controls {
2495
+ display: flex;
2496
+ align-items: center;
2497
+ gap: 14px;
2498
+ }
2499
+
2500
+ .temporal-play-btn,
2501
+ .temporal-reset-btn {
2502
+ background: transparent;
2503
+ color: oklch(95% 0 0);
2504
+ border: 1px solid oklch(100% 0 0 / 0.18);
2505
+ cursor: pointer;
2506
+ font-family: var(--font-ui);
2507
+ transition: background var(--t-fast) var(--ease-out),
2508
+ border-color var(--t-fast) var(--ease-out),
2509
+ color var(--t-fast) var(--ease-out);
2510
+ }
2511
+ .temporal-play-btn {
2512
+ width: 32px;
2513
+ height: 32px;
2514
+ border-radius: 50%;
2515
+ display: inline-flex;
2516
+ align-items: center;
2517
+ justify-content: center;
2518
+ flex-shrink: 0;
2519
+ }
2520
+ .temporal-reset-btn {
2521
+ font-size: 10px;
2522
+ font-weight: 600;
2523
+ letter-spacing: 0.06em;
2524
+ text-transform: uppercase;
2525
+ padding: 5px 10px;
2526
+ border-radius: var(--radius-sm);
2527
+ flex-shrink: 0;
2528
+ }
2529
+ .temporal-play-btn:hover,
2530
+ .temporal-reset-btn:hover {
2531
+ background: oklch(100% 0 0 / 0.10);
2532
+ border-color: oklch(100% 0 0 / 0.3);
2533
+ }
2534
+ .temporal-play-btn:focus-visible,
2535
+ .temporal-reset-btn:focus-visible {
2536
+ outline: 2px solid var(--c-focus);
2537
+ outline-offset: 2px;
2538
+ }
2539
+
2540
+ /* ---- Dual-handle range slider ---- */
2541
+ .temporal-slider-wrap {
2542
+ position: relative;
2543
+ flex: 1;
2544
+ height: 24px;
2545
+ display: flex;
2546
+ align-items: center;
2547
+ }
2548
+ .temporal-slider-track {
2549
+ position: absolute;
2550
+ left: 0; right: 0;
2551
+ top: 50%;
2552
+ transform: translateY(-50%);
2553
+ height: 3px;
2554
+ background: oklch(100% 0 0 / 0.2);
2555
+ border-radius: 2px;
2556
+ pointer-events: none;
2557
+ }
2558
+ .temporal-slider-fill {
2559
+ position: absolute;
2560
+ top: 50%;
2561
+ transform: translateY(-50%);
2562
+ height: 3px;
2563
+ background: var(--c-accent);
2564
+ border-radius: 2px;
2565
+ left: 0;
2566
+ right: 0;
2567
+ pointer-events: none;
2568
+ }
2569
+ .temporal-slider-input {
2570
+ position: absolute;
2571
+ left: 0;
2572
+ right: 0;
2573
+ width: 100%;
2574
+ height: 24px;
2575
+ background: transparent;
2576
+ pointer-events: none;
2577
+ -webkit-appearance: none;
2578
+ appearance: none;
2579
+ margin: 0;
2580
+ }
2581
+ .temporal-slider-input::-webkit-slider-thumb {
2582
+ -webkit-appearance: none;
2583
+ appearance: none;
2584
+ pointer-events: auto;
2585
+ width: 14px;
2586
+ height: 14px;
2587
+ border-radius: 50%;
2588
+ background: #ffffff;
2589
+ border: 2px solid oklch(15% 0.012 250);
2590
+ box-shadow: 0 1px 4px oklch(0% 0 0 / 0.4);
2591
+ cursor: pointer;
2592
+ margin-top: 0;
2593
+ }
2594
+ .temporal-slider-input::-moz-range-thumb {
2595
+ pointer-events: auto;
2596
+ width: 14px;
2597
+ height: 14px;
2598
+ border-radius: 50%;
2599
+ background: #ffffff;
2600
+ border: 2px solid oklch(15% 0.012 250);
2601
+ box-shadow: 0 1px 4px oklch(0% 0 0 / 0.4);
2602
+ cursor: pointer;
2603
+ }
2604
+ .temporal-slider-input::-webkit-slider-runnable-track,
2605
+ .temporal-slider-input::-moz-range-track {
2606
+ background: transparent;
2607
+ border: none;
2608
+ }
2609
+ /* The 'end' input sits on top of 'start' so dragging the end thumb works at
2610
+ the right edge; pointer-events on the thumb only (above). */
2611
+ .temporal-slider-end { z-index: 2; }
2612
+ .temporal-slider-start { z-index: 1; }
2613
+
2614
+ .temporal-year-label {
2615
+ color: oklch(95% 0 0);
2616
+ font-family: var(--font-mono);
2617
+ font-variant-numeric: tabular-nums;
2618
+ font-size: 12px;
2619
+ font-weight: 500;
2620
+ min-width: 110px;
2621
+ text-align: center;
2622
+ letter-spacing: -0.01em;
2623
+ flex-shrink: 0;
2624
+ }