Upload 2 files
Browse files- depth_interactive_map_alpha.R +409 -10
- 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
+
}
|