Upload 2 files
Browse files- depth_interactive_map_alpha.R +178 -160
- 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",
|
| 262 |
-
#
|
| 263 |
-
# expression in both places so the
|
| 264 |
-
|
| 265 |
-
|
|
|
|
| 266 |
kind = "point"),
|
| 267 |
"ocved-pts" = list(label = "OCVED violence", color = COL_AMBER,
|
| 268 |
-
table = "ocved_events",
|
| 269 |
-
|
| 270 |
-
|
| 271 |
kind = "point"),
|
| 272 |
"ged-pts" = list(label = "GED conflict", color = COL_LIGHT_BLUE,
|
| 273 |
-
table = "ged_events",
|
| 274 |
-
|
| 275 |
-
|
| 276 |
kind = "point"),
|
| 277 |
"pam-choro" = list(label = "PAM events", color = COL_COUNT,
|
| 278 |
table = "dim_pam_mun",
|
| 279 |
-
|
| 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
|
| 285 |
-
|
| 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 |
-
#
|
| 342 |
-
# `
|
| 343 |
-
|
| 344 |
-
load_pts_with_year =
|
| 345 |
"SELECT row_number() OVER () AS row_id, lon, lat,
|
| 346 |
-
{
|
| 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.
|
| 591 |
-
#
|
| 592 |
-
#
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
"SELECT {yr_expr} AS yr, {agg} AS n
|
| 596 |
FROM {tbl}
|
| 597 |
-
WHERE {
|
| 598 |
-
GROUP BY
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
#
|
| 603 |
-
|
|
|
|
| 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)
|
|
|
|
| 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-
|
| 854 |
-
# data.frame(
|
| 855 |
temporal = lapply(TEMPORAL_LAYERS, function(info) {
|
| 856 |
-
|
| 857 |
agg_expr <- info$agg %||% "COUNT(*)"
|
| 858 |
sql <- glue::glue_data(
|
| 859 |
-
list(
|
| 860 |
-
|
| 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 ? '▲' : '▼';
|
| 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 |
-
//
|
| 1471 |
-
//
|
| 1472 |
-
//
|
| 1473 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1474 |
var TEMPORAL_LAYER_IDS = ['migrants-pts','ocved-pts','ged-pts','pam-choro'];
|
| 1475 |
-
//
|
| 1476 |
-
//
|
| 1477 |
-
// know each table's underlying year column.
|
| 1478 |
var TEMPORAL_POINT_COL = {
|
| 1479 |
-
'migrants-pts': '
|
| 1480 |
-
'ocved-pts': '
|
| 1481 |
-
'ged-pts': '
|
| 1482 |
};
|
| 1483 |
var _temporal = {
|
| 1484 |
active: [], // most-recent-first
|
| 1485 |
-
|
| 1486 |
-
|
| 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,
|
| 1520 |
-
//
|
| 1521 |
-
//
|
| 1522 |
-
if (becameVisible
|
| 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,
|
| 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 (
|
| 1548 |
m.setFilter(layerId, null);
|
| 1549 |
} else {
|
| 1550 |
m.setFilter(layerId, ['all',
|
| 1551 |
-
['>=', ['to-number', ['get', col]],
|
| 1552 |
-
['<=', ['to-number', ['get', col]],
|
| 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
|
| 1562 |
-
var
|
| 1563 |
-
if (
|
| 1564 |
-
_temporal.
|
| 1565 |
// Client: apply for each active POINT layer
|
| 1566 |
_temporal.active.forEach(function(id) {
|
| 1567 |
-
if (TEMPORAL_POINT_COL[id]) applyTemporalFilterClient(id,
|
| 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:
|
| 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 + '
|
| 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
|
| 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
|
| 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
|
| 2749 |
tags$span(id = "temporal-year-label",
|
| 2750 |
class = "temporal-year-label",
|
| 2751 |
-
|
|
|
|
| 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
|
| 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 |
-
#
|
| 3389 |
-
# filter can read it via ['get', '
|
| 3390 |
-
|
| 3391 |
tryCatch({
|
| 3392 |
-
DBI::dbGetQuery(con, web_sql("
|
| 3393 |
-
tbl = tbl,
|
| 3394 |
sf::st_as_sf(coords = c("lon", "lat"), crs = 4326)
|
| 3395 |
}, error = function(e) {
|
| 3396 |
-
message(" [pts+
|
| 3397 |
})
|
| 3398 |
}
|
| 3399 |
|
|
@@ -3848,7 +3890,7 @@ server <- function(input, output, session) {
|
|
| 3848 |
}
|
| 3849 |
}, ignoreNULL = TRUE, ignoreInit = TRUE)
|
| 3850 |
|
| 3851 |
-
# Lazy layer loader helpers
|
| 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 |
-
|
| 3866 |
if (isTRUE(lazy_added[[key]]) || is.null(con)) return()
|
| 3867 |
-
pts <- if (!is.null(
|
| 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 |
-
|
| 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 |
-
|
| 4020 |
if ("ged-pts" %in% active) lazy_add_pts("ged", "ged_events", "ged-pts", COL_LIGHT_BLUE, 1, 80000,
|
| 4021 |
-
|
| 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
|
| 4382 |
-
#
|
| 4383 |
-
#
|
| 4384 |
-
#
|
| 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 |
-
|
| 4390 |
-
|
| 4391 |
-
|
| 4392 |
-
|
| 4393 |
-
|
| 4394 |
-
|
| 4395 |
-
|
| 4396 |
-
|
| 4397 |
-
|
| 4398 |
-
|
| 4399 |
-
|
| 4400 |
-
|
| 4401 |
-
|
| 4402 |
-
|
| 4403 |
-
|
| 4404 |
-
|
| 4405 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4406 |
|
| 4407 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 = "#
|
| 4412 |
-
axis.title
|
| 4413 |
axis.text.x = element_text(size = 8, color = "#cbd5e1"),
|
| 4414 |
-
axis.
|
| 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(
|
| 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
|
| 4461 |
-
# active, re-query
|
| 4462 |
-
#
|
| 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 |
-
|
| 4469 |
-
|
| 4470 |
sf_data <- tryCatch({
|
| 4471 |
DBI::dbGetQuery(con,
|
| 4472 |
-
web_sql("
|
| 4473 |
sf::st_as_sf(wkt = "wkt", crs = 4326) |>
|
| 4474 |
dplyr::select(-wkt)
|
| 4475 |
-
}, error = function(e) { message("PAM
|
| 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 ? '▲' : '▼';
|
| 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;
|