edgarodriguez commited on
Commit
707b5cb
Β·
verified Β·
1 Parent(s): d0e5c3f

Upload 5 files

Browse files
Files changed (5) hide show
  1. 10.08_styles.css +1309 -0
  2. 10.11_draw_query_indicators_v10_HF.R +2320 -0
  3. Dockerfile +27 -14
  4. README.md +34 -8
  5. helpers.R +84 -0
10.08_styles.css ADDED
@@ -0,0 +1,1309 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================================================
2
+ DEPTH geospatial risk map β€” shared by V6 (10.08) and V7 (10.09)
3
+ ============================================================================ */
4
+
5
+ @import url('https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@500;600;700&family=Space+Mono:ital,wght@0,400;0,700&display=swap');
6
+
7
+ /* -- Design tokens --------------------------------------------------------- */
8
+ :root {
9
+ /* 4pt spacing scale */
10
+ --space-1: 4px;
11
+ --space-2: 8px;
12
+ --space-3: 12px;
13
+ --space-4: 16px;
14
+ --space-6: 24px;
15
+
16
+ /* Light overlay surfaces β€” tinted toward brand navy (hue 265)
17
+ Works against both dark-matter and positron map styles */
18
+ --c-surf: oklch(99% 0.003 265);
19
+ --c-surf-hover: oklch(95% 0.005 265);
20
+ --c-border: oklch(87% 0.005 265);
21
+ --c-border-str: oklch(77% 0.007 265);
22
+
23
+ /* Text on light surfaces */
24
+ --c-text: oklch(20% 0.010 265);
25
+ --c-text-mid: oklch(43% 0.008 265);
26
+ --c-text-dim: oklch(62% 0.007 265);
27
+
28
+ /* Deep teal β€” accent for interactive & risk indicators */
29
+ --c-navy: #032425;
30
+ --c-navy-dim: #0a4042;
31
+
32
+ /* Rights Lab teal (darkened to pass WCAG AA on white)
33
+ oklch(43% 0.12 218) β‰ˆ #006B82 contrast on white: ~5.5:1 */
34
+ --c-teal: oklch(43% 0.12 218);
35
+ --c-teal-pale: oklch(95% 0.03 218);
36
+
37
+ /* Brand teal at full brightness β€” used only for decorative border-bottom */
38
+ --c-teal-brand: oklch(68% 0.12 218);
39
+
40
+ /* Typography */
41
+ --font-label: 'DM Sans', system-ui, sans-serif;
42
+ --font-body: 'Space Mono', system-ui, sans-serif;
43
+ --font-mono: 'Space Mono', monospace;
44
+
45
+ /* Radii β€” restrained */
46
+ --radius-sm: 3px;
47
+ --radius-md: 6px;
48
+ --radius-lg: 10px;
49
+ }
50
+
51
+ /* -- Page shell ------------------------------------------------------------ */
52
+ .bslib-page-fill {
53
+ display: flex !important;
54
+ flex-direction: column !important;
55
+ height: 100dvh !important;
56
+ overflow: hidden !important;
57
+ }
58
+
59
+ /* depth-shell: row container β€” map takes ~70 %, panel fills ~30 % */
60
+ .depth-shell {
61
+ flex: 1;
62
+ display: flex;
63
+ flex-direction: row;
64
+ overflow: hidden;
65
+ min-height: 0;
66
+ }
67
+
68
+ /* Map area: all overlay controls are absolute inside here */
69
+ .depth-map {
70
+ flex: 1;
71
+ position: relative;
72
+ min-width: 0;
73
+ min-height: 0;
74
+ overflow: hidden;
75
+ background: oklch(18% 0.015 265);
76
+ }
77
+
78
+ .depth-map .htmlwidget,
79
+ .depth-map .maplibregl-map {
80
+ width: 100% !important;
81
+ height: 100% !important;
82
+ }
83
+
84
+ /* -- Top bar: crime selector + grid controls ------------------------------- */
85
+ .map-overlay-top {
86
+ position: absolute;
87
+ top: var(--space-3);
88
+ left: 50%;
89
+ transform: translateX(-50%);
90
+ z-index: 999;
91
+ background: var(--c-surf);
92
+ border: 1px solid var(--c-border);
93
+ border-radius: var(--radius-md);
94
+ padding: var(--space-1) var(--space-3);
95
+ display: flex;
96
+ align-items: center;
97
+ gap: var(--space-2);
98
+ white-space: nowrap;
99
+ max-width: calc(100vw - 220px);
100
+ box-shadow: 0 2px 8px oklch(20% 0.01 265 / 0.16);
101
+ }
102
+
103
+ .map-overlay-top .form-group { margin-bottom: 0 !important; }
104
+
105
+ .map-overlay-top select {
106
+ background: var(--c-surf) !important;
107
+ color: var(--c-text) !important;
108
+ border: 1px solid var(--c-border-str) !important;
109
+ border-radius: var(--radius-md) !important;
110
+ font-size: 11px !important;
111
+ font-family: var(--font-label) !important;
112
+ font-weight: 700 !important;
113
+ letter-spacing: 0.06em !important;
114
+ text-transform: uppercase !important;
115
+ padding: 5px var(--space-2) !important;
116
+ cursor: pointer;
117
+ outline: none;
118
+ box-shadow: 0 2px 6px oklch(20% 0.01 265 / 0.12) !important;
119
+ }
120
+
121
+ .map-overlay-top span {
122
+ color: var(--c-text-mid);
123
+ font-size: 11px;
124
+ font-family: var(--font-label);
125
+ font-weight: 700;
126
+ letter-spacing: 0.10em;
127
+ text-transform: uppercase;
128
+ }
129
+
130
+ .map-overlay-sep {
131
+ width: 1px;
132
+ height: 18px;
133
+ background: var(--c-border);
134
+ flex-shrink: 0;
135
+ }
136
+
137
+ /* -- Draw mode buttons (below draw toolbar, top-right) --------------------- */
138
+ .draw-extra-ctrl {
139
+ position: absolute;
140
+ top: 262px;
141
+ right: 10px;
142
+ left: auto;
143
+ z-index: 999;
144
+ display: flex;
145
+ flex-direction: column;
146
+ gap: 2px;
147
+ }
148
+
149
+ .draw-extra-btn {
150
+ width: 30px;
151
+ height: 30px;
152
+ background: var(--c-surf);
153
+ border: 1px solid var(--c-border);
154
+ border-radius: var(--radius-sm);
155
+ display: flex;
156
+ align-items: center;
157
+ justify-content: center;
158
+ cursor: pointer;
159
+ color: var(--c-text-mid);
160
+ box-shadow: 0 1px 4px oklch(20% 0.01 265 / 0.12);
161
+ transition: background 0.12s, color 0.12s;
162
+ }
163
+
164
+ .draw-extra-btn:hover {
165
+ background: var(--c-surf-hover);
166
+ color: var(--c-text);
167
+ }
168
+
169
+ .draw-extra-btn.active {
170
+ background: var(--c-teal-pale) !important;
171
+ color: var(--c-teal) !important;
172
+ border-color: var(--c-teal);
173
+ }
174
+
175
+ /* -- Right column: grid + style + 3D (below draw-extra-ctrl) --------------- */
176
+ .btn-controls {
177
+ position: absolute;
178
+ top: 200px;
179
+ right: 10px;
180
+ left: auto;
181
+ z-index: 999;
182
+ display: flex;
183
+ flex-direction: column;
184
+ gap: var(--space-1);
185
+ align-items: flex-end;
186
+ width: 10px;
187
+ }
188
+
189
+ /* -- Style picker: icon button + dropdown ---------------------------------- */
190
+ .style-btn-wrap {
191
+ position: relative;
192
+ }
193
+
194
+ .style-picker {
195
+ position: absolute;
196
+ right: 0;
197
+ left: auto;
198
+ top: calc(100% + var(--space-1));
199
+ background: var(--c-surf);
200
+ border: 1px solid var(--c-border);
201
+ border-radius: var(--radius-md);
202
+ padding: var(--space-1);
203
+ min-width: 152px;
204
+ box-shadow: 0 4px 14px oklch(20% 0.01 265 / 0.18);
205
+ z-index: 1001;
206
+ }
207
+
208
+ .style-option {
209
+ display: block;
210
+ width: 100%;
211
+ padding: var(--space-1) var(--space-3);
212
+ background: none;
213
+ border: none;
214
+ text-align: left;
215
+ font-size: 10px;
216
+ font-family: var(--font-label);
217
+ font-weight: 600;
218
+ letter-spacing: 0.05em;
219
+ color: var(--c-text-dim);
220
+ cursor: pointer;
221
+ border-radius: var(--radius-sm);
222
+ transition: background 0.1s, color 0.1s;
223
+ }
224
+
225
+ .style-option:hover {
226
+ background: var(--c-surf-hover);
227
+ color: var(--c-text);
228
+ }
229
+
230
+ .style-option.active {
231
+ color: var(--c-navy);
232
+ background: var(--c-teal-pale);
233
+ }
234
+
235
+ .btn-ctrl {
236
+ background: var(--c-surf) !important;
237
+ color: var(--c-text) !important;
238
+ border: 1px solid var(--c-border) !important;
239
+ border-radius: var(--radius-md) !important;
240
+ font-size: 10px !important;
241
+ font-family: var(--font-label) !important;
242
+ font-weight: 700 !important;
243
+ letter-spacing: 0.10em !important;
244
+ text-transform: uppercase;
245
+ padding: var(--space-2) var(--space-3) !important;
246
+ cursor: pointer !important;
247
+ box-shadow: 0 2px 6px oklch(20% 0.01 265 / 0.14) !important;
248
+ transition: background 0.12s, border-color 0.12s !important;
249
+ display: inline-flex !important;
250
+ align-items: center !important;
251
+ gap: var(--space-1) !important;
252
+ }
253
+
254
+ .btn-ctrl:hover {
255
+ background: var(--c-surf-hover) !important;
256
+ border-color: var(--c-border-str) !important;
257
+ }
258
+
259
+ /* -- Layer control panel (bottom-left) ------------------------------------- */
260
+ .layer-ctrl-panel {
261
+ position: absolute;
262
+ bottom: var(--space-3);
263
+ left: 11px;
264
+ z-index: 999;
265
+ background: var(--c-surf);
266
+ border: 1px solid var(--c-border);
267
+ border-radius: var(--radius-lg);
268
+ overflow: hidden;
269
+ box-shadow: 0 2px 8px oklch(20% 0.01 265 / 0.16);
270
+ min-width: 198px;
271
+ max-width: 228px;
272
+ font-family: var(--font-body);
273
+ }
274
+
275
+ .layer-ctrl-header {
276
+ display: flex;
277
+ align-items: center;
278
+ justify-content: space-between;
279
+ padding: var(--space-2) var(--space-3);
280
+ cursor: pointer;
281
+ color: var(--c-text);
282
+ font-size: 10px;
283
+ font-family: var(--font-label);
284
+ font-weight: 700;
285
+ letter-spacing: 0.10em;
286
+ text-transform: uppercase;
287
+ user-select: none;
288
+ transition: background 0.1s;
289
+ }
290
+
291
+ .layer-ctrl-header:hover { background: var(--c-surf-hover); }
292
+
293
+ .layer-ctrl-icon {
294
+ font-size: 10px;
295
+ color: var(--c-text-dim);
296
+ }
297
+
298
+ .layer-ctrl-body {
299
+ max-height: 300px;
300
+ overflow-y: auto;
301
+ overflow-x: hidden;
302
+ padding: var(--space-1) var(--space-3) var(--space-2);
303
+ border-top: 1px solid var(--c-border);
304
+ }
305
+
306
+ .layer-ctrl-body::-webkit-scrollbar { width: 4px; }
307
+ .layer-ctrl-body::-webkit-scrollbar-thumb {
308
+ border-radius: 2px;
309
+ background-color: var(--c-border-str);
310
+ }
311
+ .layer-ctrl-body::-webkit-scrollbar-track { background: transparent; }
312
+
313
+ .layer-ctrl-body .form-check {
314
+ padding: 3px 0 3px 20px;
315
+ margin: 0;
316
+ }
317
+
318
+ .layer-ctrl-body .form-check-input {
319
+ margin-left: -16px;
320
+ cursor: pointer;
321
+ accent-color: var(--c-navy);
322
+ }
323
+
324
+ .layer-ctrl-body .form-check-label {
325
+ color: var(--c-text-dim);
326
+ font-size: 11px;
327
+ font-family: var(--font-body);
328
+ cursor: pointer;
329
+ transition: color 0.1s;
330
+ }
331
+
332
+ .layer-ctrl-body .form-check-input:checked ~ .form-check-label {
333
+ color: var(--c-text);
334
+ }
335
+
336
+ .layer-ctrl-body .shiny-input-container { margin: 0 !important; }
337
+ .layer-ctrl-body > .shiny-input-container > .form-group { margin: 0 !important; }
338
+
339
+ /* -- Analysis panel β€” right column, 30 % of layout ----------------------- */
340
+ .depth-panel {
341
+ width: 360px;
342
+ flex-shrink: 0;
343
+ background: var(--c-surf);
344
+ border-left: 1px solid var(--c-teal-pale);
345
+ display: flex;
346
+ flex-direction: column;
347
+ overflow: hidden;
348
+ font-family: var(--font-body);
349
+ color: var(--c-text);
350
+ }
351
+
352
+ /* All-light header: brand teal appears only as a 2px bottom border */
353
+ .dp-header {
354
+ background: var(--c-surf);
355
+ color: var(--c-text);
356
+ padding: var(--space-3) var(--space-4);
357
+ flex-shrink: 0;
358
+ display: flex;
359
+ flex-direction: column;
360
+ gap: 4px;
361
+ border-bottom: 1px solid var(--c-teal-pale);
362
+ }
363
+
364
+ .dp-title {
365
+ font-family: var(--font-label);
366
+ font-size: 24px;
367
+ font-weight: 800;
368
+ letter-spacing: 0.06em;
369
+ text-transform: uppercase;
370
+ margin: 0 0 3px;
371
+ color: var(--c-navy);
372
+ }
373
+
374
+ .dp-sub {
375
+ font-size: 11px;
376
+ color: var(--c-text-mid);
377
+ margin: 0;
378
+ line-height: 1.45;
379
+ }
380
+
381
+ /* -- Scrollable body β€” sections divided by lines, not nested cards --------- */
382
+ .dp-scroll {
383
+ flex: 1;
384
+ overflow-y: auto;
385
+ padding: 0 var(--space-4);
386
+ display: flex;
387
+ flex-direction: column;
388
+ }
389
+
390
+ .dp-scroll::-webkit-scrollbar { width: 4px; }
391
+ .dp-scroll::-webkit-scrollbar-thumb {
392
+ border-radius: 2px;
393
+ background-color: var(--c-border-str);
394
+ }
395
+ .dp-scroll::-webkit-scrollbar-track { background: transparent; }
396
+
397
+ .dp-card {
398
+ padding: var(--space-3) 0;
399
+ flex-shrink: 0;
400
+ }
401
+
402
+ .dp-card + .dp-card {
403
+ border-top: 1px solid var(--c-border);
404
+ }
405
+
406
+ .dp-label {
407
+ font-family: var(--font-label);
408
+ font-size: 9px;
409
+ font-weight: 700;
410
+ letter-spacing: 0.14em;
411
+ text-transform: uppercase;
412
+ color: var(--c-text-dim);
413
+ margin: 0 0 var(--space-3);
414
+ }
415
+
416
+ /* -- Big stats grid -------------------------------------------------------- */
417
+ .stat-grid {
418
+ display: grid;
419
+ grid-template-columns: 1fr 1fr;
420
+ gap: var(--space-3) var(--space-6);
421
+ margin-bottom: var(--space-3);
422
+ }
423
+
424
+ .stat-val {
425
+ font-family: var(--font-mono);
426
+ font-size: 28px;
427
+ font-weight: 700;
428
+ line-height: 1;
429
+ margin-bottom: 3px;
430
+ letter-spacing: -0.02em;
431
+ }
432
+
433
+ .stat-lbl {
434
+ font-family: var(--font-label);
435
+ font-size: 9px;
436
+ font-weight: 700;
437
+ letter-spacing: 0.12em;
438
+ text-transform: uppercase;
439
+ color: var(--c-text-dim);
440
+ }
441
+
442
+ .stat-sub {
443
+ font-size: 10px;
444
+ color: var(--c-text-mid);
445
+ margin-top: 3px;
446
+ line-height: 1.3;
447
+ min-height: 13px;
448
+ }
449
+
450
+ /* -- Mini stats grid ------------------------------------------------------- */
451
+ .mini-grid {
452
+ display: grid;
453
+ grid-template-columns: 1fr 1fr 1fr;
454
+ gap: var(--space-2) var(--space-3);
455
+ padding-top: var(--space-3);
456
+ border-top: 1px solid var(--c-border);
457
+ }
458
+
459
+ .mini-val {
460
+ font-family: var(--font-mono);
461
+ font-size: 16px;
462
+ font-weight: 700;
463
+ line-height: 1;
464
+ margin-bottom: 1px;
465
+ }
466
+
467
+ .mini-lbl {
468
+ font-family: var(--font-label);
469
+ font-size: 8px;
470
+ font-weight: 700;
471
+ letter-spacing: 0.10em;
472
+ text-transform: uppercase;
473
+ color: var(--c-text-dim);
474
+ }
475
+
476
+ /* -- Municipality rank list ------------------------------------------------ */
477
+ .rank-list {
478
+ list-style: none;
479
+ margin: 0;
480
+ padding: 0;
481
+ }
482
+
483
+ .rank-item {
484
+ display: flex;
485
+ align-items: flex-start;
486
+ gap: var(--space-2);
487
+ padding: var(--space-2) 0;
488
+ }
489
+
490
+ .rank-item + .rank-item {
491
+ border-top: 1px solid var(--c-border);
492
+ }
493
+
494
+ .rank-num {
495
+ font-family: var(--font-label);
496
+ font-size: 10px;
497
+ font-weight: 700;
498
+ color: var(--c-text-dim);
499
+ min-width: 18px;
500
+ padding-top: 3px;
501
+ text-align: right;
502
+ }
503
+
504
+ .rank-body {
505
+ flex: 1;
506
+ min-width: 0;
507
+ }
508
+
509
+ .rank-name {
510
+ font-size: 12px;
511
+ font-weight: 600;
512
+ color: var(--c-text);
513
+ white-space: nowrap;
514
+ overflow: hidden;
515
+ text-overflow: ellipsis;
516
+ }
517
+
518
+ .rank-meta {
519
+ font-size: 10px;
520
+ color: var(--c-text-dim);
521
+ margin: 1px 0 var(--space-1);
522
+ }
523
+
524
+ .rank-track {
525
+ height: 2px;
526
+ background: var(--c-border);
527
+ border-radius: 1px;
528
+ overflow: hidden;
529
+ }
530
+
531
+ .rank-fill {
532
+ height: 100%;
533
+ border-radius: 1px;
534
+ transition: width 0.35s cubic-bezier(0.16, 1, 0.3, 1);
535
+ }
536
+
537
+ .rank-rate {
538
+ font-family: var(--font-mono);
539
+ font-size: 11px;
540
+ font-weight: 700;
541
+ color: var(--c-text-mid);
542
+ padding-top: 2px;
543
+ white-space: nowrap;
544
+ }
545
+
546
+ /* -- States ---------------------------------------------------------------- */
547
+ .dp-empty {
548
+ padding: var(--space-6) var(--space-2);
549
+ color: var(--c-text-dim);
550
+ font-size: 12px;
551
+ text-align: center;
552
+ line-height: 1.6;
553
+ }
554
+
555
+ .dp-more {
556
+ font-family: var(--font-label);
557
+ font-size: 10px;
558
+ letter-spacing: 0.04em;
559
+ color: var(--c-text-dim);
560
+ text-align: center;
561
+ padding: var(--space-1) 0 var(--space-2);
562
+ }
563
+
564
+ /* -- Analysis panel: selection badge --------------------------------------- */
565
+ .dp-selection-badge {
566
+ display: inline-flex;
567
+ align-items: center;
568
+ gap: 4px;
569
+ font-family: var(--font-label);
570
+ font-size: 9px;
571
+ font-weight: 600;
572
+ letter-spacing: 0.06em;
573
+ color: var(--c-teal);
574
+ background: var(--c-teal-pale);
575
+ border-radius: 10px;
576
+ padding: 2px 8px;
577
+ margin-top: var(--space-1);
578
+ }
579
+
580
+ /* -- Analysis panel collapsible section headers ---------------------------- */
581
+ .dp-group {
582
+ border-bottom: 1px solid var(--c-border);
583
+ margin-bottom: var(--space-1);
584
+ }
585
+
586
+ .dp-group-hdr {
587
+ display: flex;
588
+ justify-content: space-between;
589
+ align-items: center;
590
+ cursor: pointer;
591
+ padding: var(--space-2) var(--space-1);
592
+ user-select: none;
593
+ border-radius: var(--radius-sm);
594
+ }
595
+
596
+ .dp-group-hdr:hover { background: var(--c-surf-hover); }
597
+
598
+ .dp-group-label {
599
+ font-family: var(--font-label);
600
+ font-size: 10px;
601
+ font-weight: 700;
602
+ letter-spacing: 0.14em;
603
+ text-transform: uppercase;
604
+ color: var(--c-text);
605
+ }
606
+
607
+ .dp-group-icon {
608
+ font-size: 7px;
609
+ color: var(--c-text-dim);
610
+ flex-shrink: 0;
611
+ }
612
+
613
+ .dp-group-body {
614
+ padding: var(--space-1) 0 var(--space-2) 0;
615
+ }
616
+
617
+ /* -- Indicator cards inside panel groups ----------------------------------- */
618
+ .ind-card {
619
+ border: 1px solid var(--c-border);
620
+ border-radius: var(--radius-md);
621
+ margin-bottom: var(--space-2);
622
+ overflow: hidden;
623
+ background: oklch(99.5% 0.001 265);
624
+ }
625
+
626
+ .ind-card-hdr {
627
+ display: flex;
628
+ align-items: center;
629
+ justify-content: space-between;
630
+ padding: 5px 10px;
631
+ background: var(--c-surf-hover);
632
+ border-bottom: 1px solid var(--c-border);
633
+ }
634
+
635
+ .ind-card-title {
636
+ font-family: var(--font-label);
637
+ font-size: 8px;
638
+ font-weight: 700;
639
+ letter-spacing: 0.12em;
640
+ text-transform: uppercase;
641
+ color: var(--c-text-mid);
642
+ }
643
+
644
+ .ind-card-body {
645
+ padding: var(--space-2) 10px;
646
+ }
647
+
648
+ .ind-card-body .stat-grid {
649
+ margin-bottom: 0;
650
+ }
651
+
652
+ .ind-card-chart {
653
+ border-top: 1px solid var(--c-border);
654
+ padding: var(--space-1) 10px var(--space-2);
655
+ }
656
+
657
+ .ind-card-note {
658
+ font-size: 8px;
659
+ color: var(--c-text-dim);
660
+ font-style: italic;
661
+ padding: 0 10px 4px;
662
+ }
663
+
664
+ /* -- Horizontal stat row (label left, value right) ------------------------- */
665
+ .dp-stat {
666
+ display: flex;
667
+ justify-content: space-between;
668
+ align-items: center;
669
+ padding: 4px 0;
670
+ border-bottom: 1px solid var(--c-border);
671
+ }
672
+
673
+ .dp-stat:last-child { border-bottom: none; }
674
+
675
+ .dp-stat-lbl {
676
+ font-family: var(--font-label);
677
+ font-size: 10px;
678
+ color: var(--c-text-mid);
679
+ flex: 1;
680
+ padding-right: var(--space-2);
681
+ }
682
+
683
+ .dp-stat-val {
684
+ font-family: var(--font-mono);
685
+ font-size: 12px;
686
+ font-weight: 700;
687
+ flex-shrink: 0;
688
+ }
689
+
690
+ /* -- Clear button (light header context) ----------------------------------- */
691
+ .btn-clear {
692
+ background: transparent !important;
693
+ color: var(--c-text-mid) !important;
694
+ border: 1px solid var(--c-border-str) !important;
695
+ border-radius: var(--radius-sm) !important;
696
+ font-size: 9px !important;
697
+ font-family: var(--font-label) !important;
698
+ font-weight: 700 !important;
699
+ letter-spacing: 0.08em !important;
700
+ text-transform: uppercase !important;
701
+ padding: 3px var(--space-2) !important;
702
+ flex-shrink: 0 !important;
703
+ white-space: nowrap !important;
704
+ transition: background 0.1s, color 0.1s !important;
705
+ }
706
+
707
+ .btn-clear:hover {
708
+ background: var(--c-surf-hover) !important;
709
+ color: var(--c-text) !important;
710
+ }
711
+
712
+ /* -- Footer: download button β€” navy accent --------------------------------- */
713
+ .dp-footer {
714
+ padding: var(--space-2) var(--space-4) var(--space-3);
715
+ flex-shrink: 0;
716
+ border-top: 1px solid var(--c-border);
717
+ }
718
+
719
+ .dp-footer .btn {
720
+ width: 100%;
721
+ padding: var(--space-2) 0;
722
+ background: var(--c-navy) !important;
723
+ color: oklch(97% 0.003 265) !important;
724
+ border: none;
725
+ border-radius: var(--radius-md);
726
+ font-size: 11px;
727
+ font-family: var(--font-label);
728
+ font-weight: 700;
729
+ letter-spacing: 0.08em;
730
+ text-transform: uppercase;
731
+ transition: background 0.15s;
732
+ }
733
+
734
+ .dp-footer .btn:hover {
735
+ background: var(--c-navy-dim) !important;
736
+ }
737
+
738
+ /* -- Equal-width icon buttons (29px matches MapLibre native controls) ------- */
739
+ .btn-ctrl-sq {
740
+ width: 29px !important;
741
+ min-width: 29px !important;
742
+ height: 29px !important;
743
+ padding: 0 !important;
744
+ justify-content: center !important;
745
+ }
746
+
747
+ .btn-ctrl-sq.active {
748
+ background: var(--c-teal-pale) !important;
749
+ color: var(--c-teal) !important;
750
+ border-color: var(--c-teal) !important;
751
+ }
752
+
753
+ /* -- Expanded layer panel for accordion ------------------------------------ */
754
+ .layer-ctrl-panel {
755
+ max-width: 280px !important;
756
+ min-width: 240px !important;
757
+ }
758
+
759
+ .layer-ctrl-body {
760
+ max-height: 480px !important;
761
+ padding: 0 0 var(--space-1) !important;
762
+ }
763
+
764
+ /* -- Accordion groups ------------------------------------------------------ */
765
+ .acc-group + .acc-group {
766
+ border-top: 1px solid var(--c-border);
767
+ }
768
+
769
+ .acc-header {
770
+ display: flex;
771
+ align-items: center;
772
+ justify-content: space-between;
773
+ padding: 5px var(--space-3);
774
+ cursor: pointer;
775
+ user-select: none;
776
+ transition: background 0.1s;
777
+ }
778
+
779
+ .acc-header:hover {
780
+ background: var(--c-surf-hover);
781
+ }
782
+
783
+ .acc-label {
784
+ font-family: var(--font-label);
785
+ font-size: 9px;
786
+ font-weight: 700;
787
+ letter-spacing: 0.12em;
788
+ text-transform: uppercase;
789
+ color: var(--c-text-mid);
790
+ }
791
+
792
+ .acc-icon {
793
+ font-size: 7px;
794
+ color: var(--c-text-dim);
795
+ }
796
+
797
+ .acc-body {
798
+ padding-bottom: 4px;
799
+ }
800
+
801
+ .acc-item-wrap {
802
+ border-bottom: 1px solid transparent;
803
+ }
804
+
805
+ .acc-item {
806
+ display: flex;
807
+ align-items: center;
808
+ justify-content: space-between;
809
+ padding: 4px var(--space-3);
810
+ gap: var(--space-1);
811
+ }
812
+
813
+ /* -- iOS-style toggle switch ------------------------------------------------ */
814
+ .acc-toggle-label {
815
+ display: flex;
816
+ align-items: center;
817
+ gap: 6px;
818
+ cursor: pointer;
819
+ flex: 1;
820
+ min-width: 0;
821
+ }
822
+
823
+ .acc-toggle-input {
824
+ position: absolute;
825
+ opacity: 0;
826
+ width: 0;
827
+ height: 0;
828
+ pointer-events: none;
829
+ }
830
+
831
+ .acc-toggle-track {
832
+ display: inline-flex;
833
+ align-items: center;
834
+ width: 28px;
835
+ height: 15px;
836
+ background: var(--c-border-str);
837
+ border-radius: 8px;
838
+ position: relative;
839
+ flex-shrink: 0;
840
+ transition: background 0.2s;
841
+ }
842
+
843
+ .acc-toggle-thumb {
844
+ position: absolute;
845
+ left: 2px;
846
+ width: 11px;
847
+ height: 11px;
848
+ background: white;
849
+ border-radius: 50%;
850
+ box-shadow: 0 1px 3px rgba(0,0,0,0.25);
851
+ transition: left 0.2s;
852
+ }
853
+
854
+ .acc-toggle-label:has(.acc-toggle-input:checked) .acc-toggle-track {
855
+ background: var(--c-navy);
856
+ }
857
+
858
+ .acc-toggle-label:has(.acc-toggle-input:checked) .acc-toggle-thumb {
859
+ left: 15px;
860
+ }
861
+
862
+ .acc-toggle-text {
863
+ font-size: 11px;
864
+ color: var(--c-text-dim);
865
+ font-family: var(--font-body);
866
+ transition: color 0.1s;
867
+ line-height: 1.35;
868
+ overflow: hidden;
869
+ text-overflow: ellipsis;
870
+ }
871
+
872
+ .acc-toggle-label:has(.acc-toggle-input:checked) .acc-toggle-text {
873
+ color: var(--c-text);
874
+ }
875
+
876
+ .acc-info-wrap {
877
+ flex-shrink: 0;
878
+ cursor: help;
879
+ display: inline-flex;
880
+ align-items: center;
881
+ opacity: 0.55;
882
+ transition: opacity 0.1s;
883
+ }
884
+
885
+ .acc-info-wrap:hover { opacity: 1; }
886
+
887
+ /* -- Layer legends ---------------------------------------------------------- */
888
+ .acc-legend {
889
+ display: none;
890
+ padding: 2px var(--space-3) 6px calc(var(--space-3) + 34px);
891
+ }
892
+
893
+ /* Legend visibility driven by JS class toggle (preserves correct display type per shape) */
894
+ .acc-legend.legend-visible { display: block; }
895
+ .acc-legend-dot.legend-visible { display: flex; }
896
+ .acc-legend-line-wrap.legend-visible { display: flex; }
897
+ .acc-legend-cats.legend-visible { display: flex; }
898
+
899
+ .acc-legend-ramp .acc-legend-bar {
900
+ height: 8px;
901
+ border-radius: 3px;
902
+ margin-bottom: 3px;
903
+ }
904
+
905
+ .acc-legend-labels {
906
+ display: flex;
907
+ justify-content: space-between;
908
+ font-size: 9px;
909
+ color: var(--c-text-dim);
910
+ font-family: var(--font-label);
911
+ }
912
+
913
+ .acc-legend-bound {
914
+ font-size: 7px;
915
+ font-weight: 700;
916
+ text-transform: uppercase;
917
+ letter-spacing: 0.06em;
918
+ color: var(--c-text-dim);
919
+ opacity: 0.7;
920
+ margin-right: 2px;
921
+ }
922
+
923
+ .acc-legend-bound.bound-max {
924
+ margin-right: 0;
925
+ margin-left: 2px;
926
+ }
927
+
928
+ .acc-legend-cats {
929
+ flex-direction: column;
930
+ gap: 2px;
931
+ }
932
+
933
+ .acc-legend-cat {
934
+ display: flex;
935
+ align-items: center;
936
+ gap: 5px;
937
+ font-size: 9px;
938
+ color: var(--c-text-dim);
939
+ font-family: var(--font-label);
940
+ }
941
+
942
+ .acc-legend-swatch {
943
+ width: 9px;
944
+ height: 9px;
945
+ border-radius: 2px;
946
+ flex-shrink: 0;
947
+ border: 1px solid rgba(0,0,0,0.08);
948
+ }
949
+
950
+ .acc-legend-dot {
951
+ align-items: center;
952
+ gap: 6px;
953
+ }
954
+
955
+ .acc-legend-circle {
956
+ width: 9px;
957
+ height: 9px;
958
+ border-radius: 50%;
959
+ flex-shrink: 0;
960
+ border: 1px solid rgba(0,0,0,0.15);
961
+ }
962
+
963
+ .acc-legend-dot-lbl {
964
+ font-size: 9px;
965
+ color: var(--c-text-dim);
966
+ font-family: var(--font-label);
967
+ }
968
+
969
+ .acc-legend-line-wrap {
970
+ align-items: center;
971
+ gap: 6px;
972
+ }
973
+
974
+ .acc-legend-line-seg {
975
+ display: inline-block;
976
+ width: 24px;
977
+ flex-shrink: 0;
978
+ }
979
+
980
+ .acc-legend-line-lbl {
981
+ font-size: 9px;
982
+ color: var(--c-text-dim);
983
+ font-family: var(--font-label);
984
+ }
985
+
986
+ /* Crime sub-type radio chips */
987
+ .acc-radios {
988
+ display: flex;
989
+ flex-wrap: wrap;
990
+ gap: 3px;
991
+ padding: 2px var(--space-3) 5px calc(var(--space-3) + 34px);
992
+ }
993
+
994
+ .acc-radio {
995
+ font-family: var(--font-label);
996
+ font-size: 9px;
997
+ font-weight: 600;
998
+ letter-spacing: 0.04em;
999
+ color: var(--c-text-dim);
1000
+ background: var(--c-border);
1001
+ border: none;
1002
+ border-radius: 2px;
1003
+ padding: 2px 5px;
1004
+ cursor: pointer;
1005
+ transition: background 0.1s, color 0.1s;
1006
+ line-height: 1.4;
1007
+ }
1008
+
1009
+ .acc-radio:hover {
1010
+ background: var(--c-border-str);
1011
+ color: var(--c-text);
1012
+ }
1013
+
1014
+ .acc-radio.active {
1015
+ background: var(--c-navy);
1016
+ color: #fff;
1017
+ }
1018
+
1019
+ /* Static ENVIPE sub-labels */
1020
+ .acc-subs {
1021
+ padding: 0 var(--space-3) 3px calc(var(--space-3) + 17px);
1022
+ }
1023
+
1024
+ .acc-sub {
1025
+ font-size: 9px;
1026
+ color: var(--c-text-dim);
1027
+ font-family: var(--font-body);
1028
+ line-height: 1.7;
1029
+ }
1030
+
1031
+ /* Disabled / coming-soon items */
1032
+ .acc-disabled {
1033
+ opacity: 0.45;
1034
+ pointer-events: none;
1035
+ }
1036
+
1037
+ .acc-coming-tag {
1038
+ font-family: var(--font-label);
1039
+ font-size: 8px;
1040
+ font-weight: 700;
1041
+ letter-spacing: 0.06em;
1042
+ color: var(--c-teal);
1043
+ background: var(--c-teal-pale);
1044
+ border-radius: 2px;
1045
+ padding: 1px 4px;
1046
+ flex-shrink: 0;
1047
+ }
1048
+
1049
+ /* -- Grid toggle panel (expands horizontally to the left of the button) ---- */
1050
+ .grid-btn-wrap {
1051
+ position: relative;
1052
+ display: flex;
1053
+ flex-direction: row;
1054
+ align-items: center;
1055
+ gap: 3px;
1056
+ }
1057
+
1058
+ .grid-panel {
1059
+ position: absolute;
1060
+ right: calc(100% + 4px); /* opens to the left of the btn-wrap */
1061
+ left: auto;
1062
+ top: 0;
1063
+ background: var(--c-surf);
1064
+ border: 1px solid var(--c-border);
1065
+ border-radius: var(--radius-md);
1066
+ padding: 3px;
1067
+ display: flex;
1068
+ flex-direction: row;
1069
+ gap: 2px;
1070
+ box-shadow: 0 4px 12px oklch(20% 0.01 265 / 0.18);
1071
+ z-index: 1001;
1072
+ white-space: nowrap;
1073
+ /* animate open/close via JS .open class */
1074
+ opacity: 0;
1075
+ transform: scaleX(0.85);
1076
+ transform-origin: right center;
1077
+ transition: opacity 0.15s ease, transform 0.15s ease;
1078
+ pointer-events: none;
1079
+ }
1080
+
1081
+ .grid-panel.open {
1082
+ opacity: 1;
1083
+ transform: scaleX(1);
1084
+ pointer-events: auto;
1085
+ }
1086
+
1087
+ .grid-nivel-btn {
1088
+ width: 29px;
1089
+ height: 23px;
1090
+ background: none;
1091
+ border: none;
1092
+ border-radius: var(--radius-sm);
1093
+ font-family: var(--font-label);
1094
+ font-size: 9px;
1095
+ font-weight: 700;
1096
+ letter-spacing: 0.06em;
1097
+ color: var(--c-text-dim);
1098
+ cursor: pointer;
1099
+ transition: background 0.1s, color 0.1s;
1100
+ position: relative;
1101
+ }
1102
+
1103
+ .grid-nivel-btn:not(.soon):hover {
1104
+ background: var(--c-surf-hover);
1105
+ color: var(--c-text);
1106
+ }
1107
+
1108
+ .grid-nivel-btn.active {
1109
+ background: var(--c-navy);
1110
+ color: #fff;
1111
+ }
1112
+
1113
+ /* N7/N8 coming-soon buttons */
1114
+ .grid-nivel-btn.soon {
1115
+ opacity: 0.4;
1116
+ pointer-events: none;
1117
+ }
1118
+
1119
+ .grid-nivel-btn.soon::after {
1120
+ content: 'soon';
1121
+ position: absolute;
1122
+ top: -5px;
1123
+ right: -3px;
1124
+ font-size: 6px;
1125
+ font-weight: 700;
1126
+ letter-spacing: 0;
1127
+ color: var(--c-teal);
1128
+ background: var(--c-teal-pale);
1129
+ border-radius: 2px;
1130
+ padding: 1px 2px;
1131
+ line-height: 1;
1132
+ }
1133
+
1134
+ /* -- JS-positioned tooltip -------------------------------------------------- */
1135
+ .depth-tooltip {
1136
+ position: fixed;
1137
+ background: var(--c-navy);
1138
+ color: #fff;
1139
+ font-size: 10px;
1140
+ font-family: var(--font-body);
1141
+ line-height: 1.45;
1142
+ padding: 6px 9px;
1143
+ border-radius: var(--radius-md);
1144
+ max-width: 210px;
1145
+ z-index: 99999;
1146
+ pointer-events: none;
1147
+ transform: translateY(-50%);
1148
+ box-shadow: 0 4px 12px rgba(0,0,0,0.3);
1149
+ word-break: break-word;
1150
+ display: none;
1151
+ }
1152
+
1153
+ /* -- Hide MapLibre Draw buttons not needed for area selection --------------- */
1154
+ /* mapbox-gl-draw_polygon IS the freehand/lasso β€” keep it visible */
1155
+ /* trash is replaced by our own btn_delete in draw-extra-ctrl */
1156
+ button.mapbox-gl-draw_line_string,
1157
+ button.mapbox-gl-draw_point,
1158
+ button.mapbox-gl-draw_trash,
1159
+ button.mapbox-gl-draw_combine,
1160
+ button.mapbox-gl-draw_uncombine { display: none !important; }
1161
+
1162
+ /* (draw-extra-ctrl position now set in the overrides section below) */
1163
+
1164
+ /* -- Viewport info bar (top-center of map) ---------------------------------- */
1165
+ .viewport-bar {
1166
+ position: absolute;
1167
+ top: var(--space-3);
1168
+ bottom: auto;
1169
+ left: 50%;
1170
+ transform: translateX(-50%);
1171
+ z-index: 999;
1172
+ background: var(--c-surf);
1173
+ border: 1px solid var(--c-border);
1174
+ border-radius: var(--radius-md);
1175
+ padding: 4px var(--space-3);
1176
+ display: flex;
1177
+ align-items: center;
1178
+ gap: var(--space-2);
1179
+ white-space: nowrap;
1180
+ box-shadow: 0 2px 8px oklch(20% 0.01 265 / 0.14);
1181
+ pointer-events: none;
1182
+ }
1183
+
1184
+ .vp-item {
1185
+ display: flex;
1186
+ align-items: baseline;
1187
+ gap: 4px;
1188
+ }
1189
+
1190
+ .vp-key {
1191
+ font-family: var(--font-label);
1192
+ font-size: 8px;
1193
+ font-weight: 700;
1194
+ letter-spacing: 0.10em;
1195
+ text-transform: uppercase;
1196
+ color: var(--c-text-dim);
1197
+ }
1198
+
1199
+ .vp-val {
1200
+ font-family: var(--font-label);
1201
+ font-size: 10px;
1202
+ font-weight: 700;
1203
+ color: var(--c-text);
1204
+ min-width: 38px;
1205
+ text-align: right;
1206
+ }
1207
+
1208
+ .vp-sep {
1209
+ width: 1px;
1210
+ height: 14px;
1211
+ background: var(--c-border);
1212
+ flex-shrink: 0;
1213
+ }
1214
+
1215
+ /* -- Position overrides: draw-extra-ctrl below draw toolbar with safe gap ---- */
1216
+ /* draw ctrl-group: starts at 10px, 3 visible buttons + padding ~126px bottom */
1217
+ .draw-extra-ctrl { top: 136px; right: 10px; left: auto; }
1218
+
1219
+ /* btn-controls: below draw-extra-ctrl (3 btns x 30px + 2 gaps x 2px = 94px) */
1220
+ /* 136 + 94 + 6px gap = 236 -> start at 236px */
1221
+ .btn-controls { top: 236px !important; right: 10px !important; width: auto !important; }
1222
+
1223
+ /* State picker: opens left of draw-extra-ctrl, aligned to its top */
1224
+ .state-picker-panel { top: 136px !important; right: 48px !important; }
1225
+
1226
+ /* -- Reposition draw measurement display from bottom-left to top-left ------- */
1227
+ .maplibregl-ctrl-bottom-left {
1228
+ top: 46px;
1229
+ bottom: auto;
1230
+ }
1231
+
1232
+ /* -- State filter picker panel ---------------------------------------------- */
1233
+ .state-picker-panel {
1234
+ position: absolute;
1235
+ top: 10px;
1236
+ right: 48px;
1237
+ z-index: 1000;
1238
+ background: var(--c-surf);
1239
+ border: 1px solid var(--c-border);
1240
+ border-radius: var(--radius-md);
1241
+ padding: var(--space-2) var(--space-3);
1242
+ box-shadow: 0 2px 8px oklch(20% 0.01 265 / 0.14);
1243
+ min-width: 200px;
1244
+ display: flex;
1245
+ flex-direction: column;
1246
+ gap: var(--space-2);
1247
+ }
1248
+
1249
+ .state-picker-header {
1250
+ display: flex;
1251
+ align-items: center;
1252
+ justify-content: space-between;
1253
+ font-family: var(--font-label);
1254
+ font-size: 9px;
1255
+ font-weight: 700;
1256
+ letter-spacing: 0.08em;
1257
+ text-transform: uppercase;
1258
+ color: var(--c-text-dim);
1259
+ }
1260
+
1261
+ .state-picker-close {
1262
+ background: none;
1263
+ border: none;
1264
+ cursor: pointer;
1265
+ color: var(--c-text-dim);
1266
+ font-size: 14px;
1267
+ line-height: 1;
1268
+ padding: 0;
1269
+ }
1270
+
1271
+ .state-picker-panel .selectize-control,
1272
+ .state-picker-panel select {
1273
+ font-size: 11px;
1274
+ width: 100%;
1275
+ }
1276
+
1277
+ .state-picker-actions {
1278
+ display: flex;
1279
+ gap: var(--space-1);
1280
+ }
1281
+
1282
+ /* -- Temporal analysis card (migrants, bottom-center of map) ---------------- */
1283
+ .temporal-card {
1284
+ position: absolute;
1285
+ bottom: var(--space-3);
1286
+ left: 50%;
1287
+ transform: translateX(-50%);
1288
+ width: 270px;
1289
+ display: none;
1290
+ background: var(--c-surf);
1291
+ border: 1px solid var(--c-border);
1292
+ border-radius: var(--radius-md);
1293
+ padding: 5px 10px 4px;
1294
+ z-index: 100;
1295
+ box-shadow: 0 2px 8px oklch(20% 0.01 265 / 0.14);
1296
+ }
1297
+
1298
+ .temporal-card-header {
1299
+ display: flex;
1300
+ align-items: center;
1301
+ gap: 4px;
1302
+ font-size: 8px;
1303
+ font-family: var(--font-label);
1304
+ font-weight: 700;
1305
+ letter-spacing: 0.08em;
1306
+ text-transform: uppercase;
1307
+ color: var(--c-text-dim);
1308
+ margin-bottom: 1px;
1309
+ }
10.11_draw_query_indicators_v10_HF.R ADDED
@@ -0,0 +1,2320 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #######################################################################################
2
+ # Purpose : Interactive Shiny map v10_HF -- Hugging Face Docker Space build of
3
+ # v9 (10.11). App behaviour is identical to v9; only the deployment
4
+ # wiring differs: the DuckDB path comes from an env var (baked into
5
+ # the image at build time), the connection is read-only, and the
6
+ # server binds 0.0.0.0:$PORT. Keep app-logic changes in v9 and
7
+ # regenerate this file by re-copying v9 + re-applying the 4 edits.
8
+ #
9
+ # Changes vs v8 (10.10)
10
+ # -----------------------------------------------------------------------
11
+ # - All SQL centralised in WEB_SQL (templates + builders) -- no inline glue
12
+ # - Layers / accordion / analysis panel / palettes / niveles centralised in
13
+ # WEB_SPEC and rendered from it (Shiny UI and generated docs cannot drift)
14
+ # - grid_nivel7 loaded + toggled identically to grid_nivel6 (full-load)
15
+ # - Extract-mode guard: source() with options(depth.extract_mode = TRUE)
16
+ # defines every object without launching the server (used by the converter)
17
+ #
18
+ # Inputs
19
+ # -----------------------------------------------------------------------
20
+ # [DDB] depth_mexico.duckdb -> all tables (grid_nivel4/5/6/7,
21
+ # grid_crosswalk, dim_remesas_mun, ...). Path from env var
22
+ # DEPTH_DDB_PATH (default /app/depth_mexico.duckdb). On HF the
23
+ # Dockerfile downloads it from the edgarodriguez/depth_alpha
24
+ # dataset at build time and bakes it into the image.
25
+ # [ENV] DEPTH_DDB_PATH -> DuckDB file path (default /app/...)
26
+ # [ENV] PORT -> server port (default 7860, HF Spaces)
27
+ #
28
+ # Outputs
29
+ # -----------------------------------------------------------------------
30
+ # Interactive Shiny app served on 0.0.0.0:$PORT (no file output).
31
+ # HF Docker Space: CMD ["Rscript", "10.11_draw_query_indicators_v10_HF.R"]
32
+ # Local test:
33
+ # Rscript -e "shiny::runApp('R/10.11_draw_query_indicators_v10_HF.R')"
34
+ # Extract mode (no server, for the converter):
35
+ # Rscript -e "options(depth.extract_mode=TRUE); source('R/10.11_draw_query_indicators_v10_HF.R')"
36
+ #
37
+ # LLM Disclaimer: Generated with the assistance of Claude Code (VSCode extension claude-opus-4-7)
38
+ #######################################################################################
39
+
40
+ library(shiny)
41
+ library(bslib)
42
+ library(mapgl)
43
+ library(DBI)
44
+ library(duckdb)
45
+ library(sf)
46
+ library(dplyr)
47
+ library(jsonlite)
48
+ library(htmltools)
49
+ library(glue)
50
+ library(writexl)
51
+ library(ggplot2)
52
+
53
+ source("helpers.R")
54
+
55
+ `%||%` <- function(x, y) if (is.null(x) || length(x) == 0) y else x
56
+
57
+ # Deployment edit (v10_HF): DuckDB path from env var, baked into the image.
58
+ DB_PATH <- Sys.getenv("DEPTH_DDB_PATH", "/app/depth_mexico.duckdb")
59
+
60
+ # --- Colour constants -----------------------------------------------------------
61
+ COL_RISK <- "#032425"
62
+ COL_INFRA <- "#006B82"
63
+ COL_COUNT <- "#374151"
64
+
65
+ COL_RED <- "#b91c2e"
66
+ COL_YEL <- "#DEB406"
67
+ COL_AMBER <- "#405162"
68
+ COL_PURPLE <- COL_RISK
69
+ COL_GREEN <- COL_INFRA
70
+ COL_BLUE <- COL_RISK
71
+ COL_LIGHT_BLUE <- "#009BC1"
72
+
73
+ BAR_RAMP <- rev(c(
74
+ "#fdba74", "#fb923c", "#f97316", "#ef4444",
75
+ "#dc2626", "#b91c1c", "#991b1b", "#7f1d1d", "#450a0a", "#1c0000"
76
+ ))
77
+
78
+ # Palettes
79
+ CRIME_STEP_PAL <- c("#E8D2CB","#CFAEAE", "#B38B97", "#936A85",
80
+ "#6E4F6E", "#4B3654", "#2A1F3B")
81
+ DESAP_PAL <- c("#FFFFFF00", "#FFF46A", "#FFE935", "#FFDD00", "#FF9441", "#FF4B71","#FF00A2")
82
+ PAM_PAL <- c("#EDF4F1","#C7D9D2","#9ABFB3","#6EA595","#4F8B7A","#3B6F63","#2A524A")
83
+ ENVIPE_PAL <- c("#fff7ed", "#fdba74", "#f97316", "#ea580c", "#c2410c", "#7c2d12")
84
+
85
+ GIIM_COLORS <- c(
86
+ "Nulo" = "#f8fafc",
87
+ "Muy bajo" = "#dbeafe",
88
+ "Bajo" = "#93c5fd",
89
+ "Medio" = "#3b82f6",
90
+ "Alto" = "#1d4ed8",
91
+ "Muy alto" = "#1e3a8a",
92
+ "No aplica" = "#94a3b8"
93
+ )
94
+
95
+ MIGRANTS_PAL <- c("#FDFBF3", "#F8F0CD", "#F2E19B", "#EBD26A", "#E5C338", "#DEB406")
96
+ GRAVES_PAL <- c("#faf5ff", "#ff9999", "#ff6666", "#ff4d4d", "#cc2929", "#660000")
97
+ INFRA_PAL <- c("#f0fdf4", "#bbf7d0", "#22c55e", "#16a34a", "#166534", "#052e16")
98
+ OCVED_PAL <- c("#fffbeb", "#fde68a", "#f59e0b", "#d97706", "#b45309", "#713f12")
99
+ GED_PAL <- c("#eff6ff", "#bfdbfe", "#3b82f6", "#2563eb", "#1d4ed8", "#1e3a8a")
100
+ FOSAS_PAL <- c("#fff7ed", "#fed7aa", "#fb923c", "#c2410c", "#7c2d12", "#3b0402")
101
+
102
+ CRIME_COLS <- list(
103
+ "Homicide rate" = list(col = "homicidio", breaks = c(10, 30, 60, 100, 150)),
104
+ "Robbery rate" = list(col = "robo", breaks = c(100, 300, 700, 1500, 3000)),
105
+ "Kidnapping rate" = list(col = "secuestro", breaks = c(1, 3, 7, 15, 30)),
106
+ "Child trafficking" = list(col = "trafico_menores", breaks = c(0.1, 0.5, 1, 3, 7)),
107
+ "Human trafficking" = list(col = "trata_personas", breaks = c(0.5, 1, 3, 7, 15))
108
+ )
109
+
110
+ MAP_STYLES <- c(
111
+ "Positron" = "positron",
112
+ "Dark Matter" = "dark-matter",
113
+ "Voyager" = "voyager"
114
+
115
+ )
116
+
117
+ GRID_LAYER_CFG <- list(
118
+ "crime-choro" = "crime",
119
+ "desap-choro" = "desap",
120
+ "fosas-choro" = "fosas",
121
+ "remesas-choro" = "remesas",
122
+ "pam-choro" = "pam",
123
+ "migrants-pts" = "migrants",
124
+ "graves-pts" = "graves",
125
+ "infra-pts" = "infra",
126
+ "ocved-pts" = "ocved",
127
+ "ged-pts" = "ged"
128
+ )
129
+ NON_GRID_LAYERS <- c("envipe-state", "routes-lines", "viaferrea-lines")
130
+
131
+ ENVIPE_COLS <- list(
132
+ "insecurity" = list(col = "insecurity_perc", breaks = c(40, 55, 65, 75, 85)),
133
+ "dark_figures" = list(col = "dark_figures_perc", breaks = c(88, 90, 92, 94, 96))
134
+ )
135
+
136
+ FOSAS_COLS <- list(
137
+ "fosas" = list(col = "fosas_total", breaks = c(1, 5, 15, 40, 80)),
138
+ "cuerpos" = list(col = "cuerpos_total", breaks = c(1, 10, 30, 80, 200))
139
+ )
140
+
141
+ #######################################################################################
142
+ # WEB_SQL -- canonical query templates (single source for Shiny + docs converter)
143
+ # Static templates use {placeholder} tokens substituted by web_sql().
144
+ # Dynamic queries (qbreaks, grid_unified) are builder functions.
145
+ # The converter (10.13_build_docs.R) serialises these verbatim into
146
+ # docs/js/queries.js; the browser substitutes the same {placeholder} tokens.
147
+ #######################################################################################
148
+
149
+ WEB_SQL_TEMPLATES <- list(
150
+
151
+ nat_migrants =
152
+ "SELECT COUNT(*) AS n,
153
+ COALESCE(SUM(CAST(n_dead AS INTEGER)), 0) AS n_dead,
154
+ COALESCE(SUM(CAST(n_missing AS INTEGER)), 0) AS n_miss,
155
+ COALESCE(SUM(CAST(n_total AS INTEGER)), 0) AS n_total
156
+ FROM migrants",
157
+
158
+ nat_count = "SELECT COUNT(*) AS n FROM {tbl}",
159
+
160
+ nat_mun_agg =
161
+ "SELECT COUNT(*) AS n_mun,
162
+ COALESCE(SUM(CAST(d.desap_nl AS INTEGER)), 0) AS desap_n,
163
+ COALESCE(SUM(CAST(p.pam_total AS INTEGER)), 0) AS pam_n
164
+ FROM crime_mun c
165
+ LEFT JOIN (SELECT CVEGEO, SUM(final_desap_nl) AS desap_nl FROM dim_desap_mun GROUP BY CVEGEO) d
166
+ ON c.CVEGEO = d.CVEGEO
167
+ LEFT JOIN (SELECT CVEGEO, SUM(pam_n) AS pam_total FROM dim_pam_mun GROUP BY CVEGEO) p
168
+ ON c.CVEGEO = p.CVEGEO",
169
+
170
+ mun_table =
171
+ "SELECT c.municipio, c.entidad, c.CVEGEO,
172
+ CAST(c.homicidio AS DOUBLE) AS homicidio,
173
+ CAST(c.robo AS DOUBLE) AS robo,
174
+ CAST(c.secuestro AS DOUBLE) AS secuestro,
175
+ CAST(c.trafico_menores AS DOUBLE) AS trafico_menores,
176
+ CAST(c.trata_personas AS DOUBLE) AS trata_personas,
177
+ COALESCE(CAST(d.desap_nl AS INTEGER), 0) AS desap_n,
178
+ COALESCE(CAST(p.pam_total AS INTEGER), 0) AS pam_n,
179
+ COALESCE(r.giim_mun, 'No aplica') AS giim_mun
180
+ FROM crime_mun c
181
+ LEFT JOIN (SELECT CVEGEO, SUM(final_desap_nl) AS desap_nl FROM dim_desap_mun GROUP BY CVEGEO) d
182
+ ON c.CVEGEO = d.CVEGEO
183
+ LEFT JOIN (SELECT CVEGEO, SUM(pam_n) AS pam_total FROM dim_pam_mun GROUP BY CVEGEO) p
184
+ ON c.CVEGEO = p.CVEGEO
185
+ LEFT JOIN dim_remesas_mun r ON c.CVEGEO = r.CVEGEO
186
+ ORDER BY c.homicidio DESC NULLS LAST",
187
+
188
+ load_pts =
189
+ "SELECT row_number() OVER () AS row_id, lon, lat
190
+ FROM {tbl}
191
+ WHERE lon IS NOT NULL AND lat IS NOT NULL
192
+ LIMIT {limit}",
193
+
194
+ pts_in_poly =
195
+ "SELECT * FROM {tbl}
196
+ WHERE lon BETWEEN {xmin} AND {xmax}
197
+ AND lat BETWEEN {ymin} AND {ymax}
198
+ AND ST_Within(ST_Point(lon, lat), ST_GeomFromText('{poly_wkt}'))",
199
+
200
+ mun_in_poly =
201
+ "SELECT c.municipio, c.entidad, c.CVEGEO,
202
+ CAST(c.homicidio AS DOUBLE) AS homicidio,
203
+ CAST(c.robo AS DOUBLE) AS robo,
204
+ CAST(c.secuestro AS DOUBLE) AS secuestro,
205
+ CAST(c.trafico_menores AS DOUBLE) AS trafico_menores,
206
+ CAST(c.trata_personas AS DOUBLE) AS trata_personas,
207
+ COALESCE(CAST(d.desap_nl AS INTEGER), 0) AS desap_n,
208
+ COALESCE(CAST(p.pam_total AS INTEGER), 0) AS pam_n,
209
+ COALESCE(r.giim_mun, 'No aplica') AS giim_mun
210
+ FROM crime_mun c
211
+ LEFT JOIN (SELECT CVEGEO, SUM(final_desap_nl) AS desap_nl FROM dim_desap_mun GROUP BY CVEGEO) d
212
+ ON c.CVEGEO = d.CVEGEO
213
+ LEFT JOIN (SELECT CVEGEO, SUM(pam_n) AS pam_total FROM dim_pam_mun GROUP BY CVEGEO) p
214
+ ON c.CVEGEO = p.CVEGEO
215
+ LEFT JOIN dim_remesas_mun r ON c.CVEGEO = r.CVEGEO
216
+ WHERE c.lon BETWEEN {xmin} AND {xmax}
217
+ AND c.lat BETWEEN {ymin} AND {ymax}
218
+ AND ST_Intersects(
219
+ ST_GeomFromText(c.wkt),
220
+ ST_GeomFromText('{poly_wkt}')
221
+ )
222
+ ORDER BY c.homicidio DESC NULLS LAST",
223
+
224
+ src_crime =
225
+ "SELECT municipio, entidad, CVEGEO,
226
+ CAST(homicidio AS DOUBLE) AS homicidio,
227
+ CAST(robo AS DOUBLE) AS robo,
228
+ CAST(secuestro AS DOUBLE) AS secuestro,
229
+ CAST(trafico_menores AS DOUBLE) AS trafico_menores,
230
+ CAST(trata_personas AS DOUBLE) AS trata_personas,
231
+ wkt
232
+ FROM crime_mun",
233
+
234
+ src_desap =
235
+ "SELECT c.municipio, c.entidad, c.CVEGEO,
236
+ COALESCE(CAST(d.desap_rate AS DOUBLE), 0) AS desap_per_100k,
237
+ c.wkt
238
+ FROM crime_mun c
239
+ LEFT JOIN (SELECT CVEGEO, AVG(final_desap_nl_per_100k) AS desap_rate
240
+ FROM dim_desap_mun GROUP BY CVEGEO) d
241
+ ON c.CVEGEO = d.CVEGEO",
242
+
243
+ src_remesas =
244
+ "SELECT c.municipio, c.entidad, c.CVEGEO,
245
+ COALESCE(r.giim_mun, 'No aplica') AS giim_mun,
246
+ c.wkt
247
+ FROM crime_mun c LEFT JOIN dim_remesas_mun r ON c.CVEGEO = r.CVEGEO",
248
+
249
+ src_pam =
250
+ "SELECT c.municipio, c.entidad, c.CVEGEO,
251
+ COALESCE(CAST(p.pam_total AS INTEGER), 0) AS pam_n,
252
+ c.wkt
253
+ FROM crime_mun c
254
+ LEFT JOIN (SELECT CVEGEO, SUM(pam_n) AS pam_total
255
+ FROM dim_pam_mun GROUP BY CVEGEO) p
256
+ ON c.CVEGEO = p.CVEGEO",
257
+
258
+ src_fosas =
259
+ "SELECT c.municipio, c.entidad, c.CVEGEO,
260
+ COALESCE(CAST(f.fosas_total AS INTEGER), 0) AS fosas_total,
261
+ COALESCE(CAST(f.cuerpos_total AS INTEGER), 0) AS cuerpos_total,
262
+ c.wkt
263
+ FROM crime_mun c LEFT JOIN dim_fosas_mun f ON c.CVEGEO = f.CVEGEO",
264
+
265
+ src_state =
266
+ "SELECT CVE_ENT, desc_entidad,
267
+ CAST(perception_insecurity_perc AS DOUBLE) AS insecurity_perc,
268
+ CAST(dark_figures_perc AS DOUBLE) AS dark_figures_perc,
269
+ wkt
270
+ FROM state_polygons",
271
+
272
+ src_routes =
273
+ "SELECT source_geocve, target_geocve, CAST(freq AS DOUBLE) AS freq, wkt
274
+ FROM routed_paths",
275
+
276
+ src_via = "SELECT tipo, condicion, wkt FROM viaferrea",
277
+
278
+ state_choices =
279
+ "SELECT CVE_ENT, {label_col} AS label FROM state_polygons ORDER BY {label_col}",
280
+
281
+ click_mun =
282
+ "SELECT CVEGEO, wkt FROM crime_mun
283
+ WHERE lon BETWEEN {lon_lo} AND {lon_hi}
284
+ AND lat BETWEEN {lat_lo} AND {lat_hi}
285
+ AND ST_Within(ST_Point({lon}, {lat}), ST_GeomFromText(wkt))
286
+ LIMIT 1",
287
+
288
+ state_wkt =
289
+ "SELECT wkt FROM state_polygons WHERE CVE_ENT = '{cve_ent}' LIMIT 1",
290
+
291
+ temporal_mig_all =
292
+ "SELECT {yr_expr} AS yr, COUNT(*) AS n FROM migrants
293
+ WHERE incident_date_ts IS NOT NULL
294
+ GROUP BY yr HAVING yr > 2000 AND yr <= 2030 ORDER BY yr",
295
+
296
+ temporal_mig_sel =
297
+ "SELECT {yr_expr} AS yr, COUNT(*) AS n FROM migrants
298
+ WHERE incident_date_ts IS NOT NULL
299
+ AND lon IS NOT NULL AND lat IS NOT NULL
300
+ AND ST_Within(ST_Point(lon, lat), ST_GeomFromText('{poly_wkt}'))
301
+ GROUP BY yr HAVING yr > 2000 AND yr <= 2030 ORDER BY yr",
302
+
303
+ temporal_yr_all =
304
+ "SELECT {yr_expr} AS yr, COUNT(*) AS n FROM {tbl}
305
+ WHERE {yr_col} IS NOT NULL
306
+ GROUP BY yr HAVING yr > 2000 AND yr <= 2030 ORDER BY yr",
307
+
308
+ temporal_yr_sel =
309
+ "SELECT {yr_expr} AS yr, COUNT(*) AS n FROM {tbl}
310
+ WHERE {yr_col} IS NOT NULL
311
+ AND lon IS NOT NULL AND lat IS NOT NULL
312
+ AND ST_Within(ST_Point(lon, lat), ST_GeomFromText('{poly_wkt}'))
313
+ GROUP BY yr HAVING yr > 2000 AND yr <= 2030 ORDER BY yr"
314
+ )
315
+
316
+ # Substitute {tokens} in a named template (Shiny side). Browser does the same.
317
+ web_sql <- function(name, ...) {
318
+ tmpl <- WEB_SQL_TEMPLATES[[name]]
319
+ if (is.null(tmpl)) stop("Unknown WEB_SQL template: ", name)
320
+ d <- list(...)
321
+ if (length(d) == 0) return(tmpl)
322
+ as.character(glue::glue_data(d, tmpl, .open = "{", .close = "}"))
323
+ }
324
+
325
+ # Dynamic: variable number of percentile terms (mirrors compute_qbreaks)
326
+ web_sql_qbreaks <- function(tbl, col, n_colors, filter_zero = TRUE) {
327
+ n_breaks <- n_colors - 1L
328
+ probs <- seq_len(n_breaks) / n_colors
329
+ where <- if (filter_zero) paste0("WHERE CAST(", col, " AS DOUBLE) > 0") else ""
330
+ sel <- paste(
331
+ paste0("PERCENTILE_CONT(", round(probs, 8),
332
+ ") WITHIN GROUP (ORDER BY CAST(", col, " AS DOUBLE))"),
333
+ collapse = ", "
334
+ )
335
+ paste("SELECT", sel, "FROM", tbl, where)
336
+ }
337
+
338
+ # Dynamic: unified per-nivel grid join (mirrors load_grid_unified). niv 4..8.
339
+ # grid_crosswalk has codigo_n4..codigo_n8, so the equi-join always applies.
340
+ web_sql_grid_unified <- function(niv, has_fosas, limit = NULL) {
341
+ col_n <- paste0("codigo_n", niv)
342
+ limit_clause <- if (!is.null(limit)) paste0("\nLIMIT ", limit) else ""
343
+ fosas_cols <- if (has_fosas)
344
+ ",\n COALESCE(CAST(f.fosas_total AS INTEGER), 0) AS fosas_total,\n COALESCE(CAST(f.cuerpos_total AS INTEGER), 0) AS cuerpos_total"
345
+ else
346
+ ",\n 0 AS fosas_total, 0 AS cuerpos_total"
347
+ fosas_join <- if (has_fosas)
348
+ "\n LEFT JOIN dim_fosas_mun f ON g.CVEGEO = f.CVEGEO"
349
+ else ""
350
+ # Subqueries ensure exactly one row per CVEGEO; direct joins on multi-row
351
+ # tables (dim_desap_mun ~58k rows, dim_pam_mun ~7.5k rows) explode memory.
352
+ desap_sub <- "(SELECT CVEGEO, AVG(final_desap_nl_per_100k) AS desap_rate FROM dim_desap_mun GROUP BY CVEGEO)"
353
+ pam_sub <- "(SELECT CVEGEO, SUM(pam_n) AS pam_total FROM dim_pam_mun GROUP BY CVEGEO)"
354
+ glue::glue(
355
+ "WITH pts_src AS (
356
+ SELECT {col_n} AS codigo,
357
+ SUM(CASE WHEN src = 'migrants' THEN 1 ELSE 0 END) AS migrants_n,
358
+ SUM(CASE WHEN src = 'graves' THEN 1 ELSE 0 END) AS graves_n,
359
+ SUM(CASE WHEN src = 'ocved' THEN 1 ELSE 0 END) AS ocved_n,
360
+ SUM(CASE WHEN src = 'ged' THEN 1 ELSE 0 END) AS ged_n,
361
+ SUM(CASE WHEN src = 'infra' THEN 1 ELSE 0 END) AS infra_n
362
+ FROM (
363
+ SELECT cw.{col_n}, 'migrants' AS src
364
+ FROM grid_crosswalk cw
365
+ INNER JOIN migrants m ON cw.lon = m.lon AND cw.lat = m.lat
366
+ WHERE cw.{col_n} IS NOT NULL
367
+ UNION ALL
368
+ SELECT cw.{col_n}, 'graves'
369
+ FROM grid_crosswalk cw
370
+ INNER JOIN graves gv ON cw.lon = gv.lon AND cw.lat = gv.lat
371
+ WHERE cw.{col_n} IS NOT NULL
372
+ UNION ALL
373
+ SELECT cw.{col_n}, 'ocved'
374
+ FROM grid_crosswalk cw
375
+ INNER JOIN ocved_events oe ON cw.lon = oe.lon AND cw.lat = oe.lat
376
+ WHERE cw.{col_n} IS NOT NULL
377
+ UNION ALL
378
+ SELECT cw.{col_n}, 'ged'
379
+ FROM grid_crosswalk cw
380
+ INNER JOIN ged_events ge ON cw.lon = ge.lon AND cw.lat = ge.lat
381
+ WHERE cw.{col_n} IS NOT NULL
382
+ UNION ALL
383
+ SELECT cw.{col_n}, 'infra'
384
+ FROM grid_crosswalk cw
385
+ INNER JOIN support_infrastructure si ON cw.lon = si.lon AND cw.lat = si.lat
386
+ WHERE cw.{col_n} IS NOT NULL
387
+ ) all_pts
388
+ GROUP BY {col_n}
389
+ )
390
+ SELECT g.codigo, g.CVEGEO, g.wkt,
391
+ CAST(c.homicidio AS DOUBLE) AS homicidio,
392
+ CAST(c.robo AS DOUBLE) AS robo,
393
+ CAST(c.secuestro AS DOUBLE) AS secuestro,
394
+ CAST(c.trafico_menores AS DOUBLE) AS trafico_menores,
395
+ CAST(c.trata_personas AS DOUBLE) AS trata_personas,
396
+ COALESCE(CAST(d.desap_rate AS DOUBLE), 0) AS desap_per_100k{fosas_cols},
397
+ COALESCE(r.giim_mun, 'No aplica') AS giim_mun,
398
+ COALESCE(CAST(p.pam_total AS INTEGER), 0) AS pam_n,
399
+ COALESCE(pts.migrants_n, 0) AS migrants_n,
400
+ COALESCE(pts.graves_n, 0) AS graves_n,
401
+ COALESCE(pts.ocved_n, 0) AS ocved_n,
402
+ COALESCE(pts.ged_n, 0) AS ged_n,
403
+ COALESCE(pts.infra_n, 0) AS infra_n
404
+ FROM grid_nivel{niv} g
405
+ LEFT JOIN crime_mun c ON g.CVEGEO = c.CVEGEO
406
+ LEFT JOIN {desap_sub} d ON g.CVEGEO = d.CVEGEO{fosas_join}
407
+ LEFT JOIN dim_remesas_mun r ON g.CVEGEO = r.CVEGEO
408
+ LEFT JOIN {pam_sub} p ON g.CVEGEO = p.CVEGEO
409
+ LEFT JOIN pts_src pts ON g.codigo = pts.codigo{limit_clause}"
410
+ )
411
+ }
412
+
413
+ #######################################################################################
414
+ # WEB_SPEC -- declarative layer / accordion / panel / palette config.
415
+ # Single source consumed by the Shiny UI AND the docs converter so the two
416
+ # cannot drift. References the palette/break constants above (one definition).
417
+ #######################################################################################
418
+
419
+ WEB_SPEC <- list(
420
+
421
+ niveles = c("4", "5", "6", "7"), # N7 enabled (full-load, like N6); N8 = soon
422
+ niveles_soon = c("8"),
423
+
424
+ palettes = list(
425
+ crime = CRIME_STEP_PAL, desap = DESAP_PAL, pam = PAM_PAL,
426
+ envipe = ENVIPE_PAL, fosas = FOSAS_PAL,
427
+ migrants = MIGRANTS_PAL, graves = GRAVES_PAL, infra = INFRA_PAL,
428
+ ocved = OCVED_PAL, ged = GED_PAL,
429
+ giim = GIIM_COLORS, bar_ramp = BAR_RAMP
430
+ ),
431
+
432
+ crime_cols = CRIME_COLS,
433
+ fosas_cols = FOSAS_COLS,
434
+ envipe_cols = ENVIPE_COLS,
435
+ map_styles = MAP_STYLES,
436
+ grid_layer_cfg = GRID_LAYER_CFG,
437
+ non_grid_layers = NON_GRID_LAYERS,
438
+
439
+ # Count-layer breaks (point density on grid cells + grid_color_expr)
440
+ count_breaks = list(
441
+ migrants = c(1, 5, 20, 50, 100),
442
+ graves = c(1, 2, 5, 15, 30),
443
+ infra = c(1, 3, 8, 20, 50),
444
+ ocved = c(1, 5, 20, 60, 200),
445
+ ged = c(1, 3, 10, 30, 80)
446
+ ),
447
+
448
+ # Map layer descriptors (converter -> docs/js/map.js)
449
+ layers = list(
450
+ list(id = "crime-choro", type = "fill", src = "src_crime",
451
+ geom = "polygon", color = "crime_qstep", value = "homicidio",
452
+ opacity = 0.6, visible = TRUE),
453
+ list(id = "desap-choro", type = "fill", src = "src_desap",
454
+ geom = "polygon", color = "desap_qstep", value = "desap_per_100k",
455
+ opacity = 0.6, visible = FALSE),
456
+ list(id = "remesas-choro", type = "fill", src = "src_remesas",
457
+ geom = "polygon", color = "giim_match", value = "giim_mun",
458
+ opacity = 0.75, visible = FALSE),
459
+ list(id = "pam-choro", type = "fill", src = "src_pam",
460
+ geom = "polygon", color = "pam_qstep", value = "pam_n",
461
+ opacity = 0.7, visible = FALSE),
462
+ list(id = "fosas-choro", type = "fill", src = "src_fosas",
463
+ geom = "polygon", color = "fosas_step", value = "fosas_total",
464
+ opacity = 0.65, visible = FALSE, requires = "dim_fosas_mun"),
465
+ list(id = "envipe-state", type = "fill", src = "src_state",
466
+ geom = "polygon", color = "envipe_step", value = "insecurity_perc",
467
+ opacity = 0.6, visible = FALSE),
468
+ list(id = "routes-lines", type = "line", src = "src_routes",
469
+ geom = "line", color = COL_AMBER, opacity = 0.1, visible = FALSE),
470
+ list(id = "viaferrea-lines", type = "line", src = "src_via",
471
+ geom = "line", color = "#990f0f", opacity = 0.9, visible = FALSE,
472
+ dash = c(4, 2)),
473
+ list(id = "migrants-pts", type = "circle", src = "migrants",
474
+ geom = "point", color = COL_YEL, radius = 2, visible = FALSE, limit = 8000),
475
+ list(id = "graves-pts", type = "circle", src = "graves",
476
+ geom = "point", color = COL_RED, radius = 2, visible = FALSE, limit = 5000),
477
+ list(id = "infra-pts", type = "circle", src = "support_infrastructure",
478
+ geom = "point", color = COL_GREEN, radius = 2, visible = FALSE, limit = 5000),
479
+ list(id = "ocved-pts", type = "circle", src = "ocved_events",
480
+ geom = "point", color = COL_AMBER, radius = 1, visible = FALSE, limit = 80000),
481
+ list(id = "ged-pts", type = "circle", src = "ged_events",
482
+ geom = "point", color = COL_LIGHT_BLUE, radius = 1, visible = FALSE, limit = 80000)
483
+ ),
484
+
485
+ # Accordion layer panel (converter -> docs/js/panels.js; Shiny UI renders this too)
486
+ accordion = list(
487
+ list(id = "risk", label = "Risk", open = FALSE, items = list(
488
+ list(kind = "soon", label = "Transit migration risk index")
489
+ )),
490
+ list(id = "hazards", label = "Hazards", open = TRUE, items = list(
491
+ list(kind = "layer", value = "crime-choro",
492
+ label = "Criminal incidence by municipality",
493
+ tooltip = "Rate of crimes per 100,000 people in each municipality. Select crime type using the buttons below.",
494
+ legend = list(type = "ramp", pal = "crime", lo = "0 /100k", hi = "150+ /100k"),
495
+ checked = TRUE),
496
+ list(kind = "radios", group = "crime"),
497
+ list(kind = "layer", value = "migrants-pts",
498
+ label = "Missing migrant incidents by IOM",
499
+ tooltip = "Each dot marks a location where migrants went missing or died, recorded by the International Organization for Migration.",
500
+ legend = list(type = "dot", color = COL_YEL, label = "Incident location")),
501
+ list(kind = "layer", value = "desap-choro",
502
+ label = "Enforced disappearances per 100k (RNPDNO)",
503
+ tooltip = "People officially reported as forcibly disappeared in each municipality, shown per 100,000 residents.",
504
+ legend = list(type = "ramp", pal = "desap", lo = "0 /100k", hi = "150+ /100k")),
505
+ list(kind = "layer", value = "fosas-choro",
506
+ label = "Clandestine burial pits by municipality",
507
+ tooltip = "Number of clandestine burial pits (fosas) and bodies found per municipality. Source: RNPED.",
508
+ legend = list(type = "ramp", pal = "fosas", lo = "0", hi = "High")),
509
+ list(kind = "radios", group = "fosas"),
510
+ list(kind = "layer", value = "graves-pts",
511
+ label = "Clandestine burial sites",
512
+ tooltip = "Locations where clandestine grave sites have been discovered across Mexico.",
513
+ legend = list(type = "dot", color = COL_RED, label = "Burial site")),
514
+ list(kind = "layer", value = "ocved-pts",
515
+ label = "Organised criminal violence events (OCVED 2.0)",
516
+ tooltip = "Incidents of organised crime violence such as confrontations, attacks and criminal activities.",
517
+ legend = list(type = "dot", color = COL_AMBER, label = "Violence event")),
518
+ list(kind = "layer", value = "ged-pts",
519
+ label = "Armed conflict events (UCDP-GED)",
520
+ tooltip = "Armed conflict events recorded by Uppsala University's global conflict research dataset.",
521
+ legend = list(type = "dot", color = COL_LIGHT_BLUE, label = "Conflict event")),
522
+ list(kind = "layer", value = "envipe-state",
523
+ label = "Crime victimisation survey by state (ENVIPE)",
524
+ tooltip = "State-level survey on how safe people feel and how many crimes go unreported. Select metric below.",
525
+ legend = list(type = "ramp", pal = "envipe", lo = "Low %", hi = "High %")),
526
+ list(kind = "radios", group = "envipe"),
527
+ list(kind = "soon", label = "Heat stress index")
528
+ )),
529
+ list(id = "exposure", label = "Exposure", open = FALSE, items = list(
530
+ list(kind = "layer", value = "pam-choro",
531
+ label = "Irregular migration events by municipality (PAM)",
532
+ tooltip = "Number of people who arrived at migrant assistance points or shelters in each municipality.",
533
+ legend = list(type = "ramp", pal = "pam", lo = "0 events", hi = "1,000+ events")),
534
+ list(kind = "layer", value = "remesas-choro",
535
+ label = "Migration intensity index MEX-USA (GIIM)",
536
+ tooltip = "Index measuring the intensity of migration from Mexico to the United States, from none to very high.",
537
+ legend = list(type = "cats", pal = "giim")),
538
+ list(kind = "layer", value = "routes-lines",
539
+ label = "Main migration routes through Mexico",
540
+ tooltip = "Key routes migrants use to travel through Mexico towards the northern border.",
541
+ legend = list(type = "line", color = COL_AMBER, label = "Route")),
542
+ list(kind = "layer", value = "viaferrea-lines",
543
+ label = "Railway network (La Bestia routes)",
544
+ tooltip = "Railway lines used by migrants, including freight routes known as 'La Bestia'.",
545
+ legend = list(type = "line", color = "#94a3b8", label = "Railway", dashed = TRUE)),
546
+ list(kind = "soon", label = "Foreign population")
547
+ )),
548
+ list(id = "vulnerability", label = "Vulnerability", open = FALSE, items = list(
549
+ list(kind = "layer", value = "infra-pts",
550
+ label = "Migrant shelters and support centres",
551
+ tooltip = "Shelters, health posts, and other services available to migrants in transit.",
552
+ legend = list(type = "dot", color = COL_GREEN, label = "Support facility"))
553
+ ))
554
+ ),
555
+
556
+ radios = list(
557
+ crime = list(
558
+ list(label = "Homicides", value = "Homicide rate"),
559
+ list(label = "Robberies", value = "Robbery rate"),
560
+ list(label = "Kidnapping", value = "Kidnapping rate"),
561
+ list(label = "Child trafficking", value = "Child trafficking"),
562
+ list(label = "Human trafficking", value = "Human trafficking")
563
+ ),
564
+ envipe = list(
565
+ list(label = "Insecurity perception", value = "insecurity"),
566
+ list(label = "Unreported crimes", value = "dark_figures")
567
+ ),
568
+ fosas = list(
569
+ list(label = "Clandestine graves", value = "fosas"),
570
+ list(label = "Bodies (cuerpos)", value = "cuerpos")
571
+ )
572
+ ),
573
+
574
+ # Analysis panel (converter -> docs/js/panels.js; Shiny renders this too)
575
+ panel = list(
576
+ title = "Transit Migration Risk Index",
577
+ subtitle = "Multi-variable analysis of components of vulnerability for migrants across Mexico.",
578
+ groups = list(
579
+ list(id = "hazards", label = "Hazards", open = TRUE, cards = list(
580
+ list(title = "Missing Migrants Incidents (IOM)", kind = "bigstat", chart = "temporal_plot",
581
+ stats = list(
582
+ list(id = "n_incidents", label = "INCIDENTS", sub = "sub_incidents", color = COL_RISK),
583
+ list(id = "n_fatalities", label = "CONFIRMED DEAD", sub = "sub_fatalities", color = COL_AMBER)
584
+ )),
585
+ list(title = "Criminal Incidence by Municipality", kind = "munlist"),
586
+ list(title = "Enforced Disappearances (RNPDNO)", kind = "stat",
587
+ stats = list(list(id = "n_desap", label = "Registered disappearances", color = COL_RISK))),
588
+ list(title = "Burial Data", kind = "stat",
589
+ stats = list(list(id = "n_graves", label = "Clandestine burial sites", color = COL_RED))),
590
+ list(title = "Organised Crime Violence (OCVED 2.0)", kind = "stat", chart = "temporal_ocved",
591
+ stats = list(list(id = "ocved_n", label = "Recorded violence events", color = COL_COUNT))),
592
+ list(title = "Armed Conflict Events (UCDP-GED)", kind = "stat", chart = "temporal_ged",
593
+ stats = list(list(id = "n_ged", label = "Conflict events", color = COL_LIGHT_BLUE)))
594
+ )),
595
+ list(id = "exposure", label = "Exposure", open = FALSE, cards = list(
596
+ list(title = "Irregular Migration Events (PAM)", kind = "stat",
597
+ stats = list(list(id = "n_pam", label = "Recorded irregular migration events", color = COL_COUNT)))
598
+ )),
599
+ list(id = "vulnerability", label = "Vulnerability", open = FALSE, cards = list(
600
+ list(title = "Migrant Support Facilities", kind = "stat",
601
+ stats = list(list(id = "n_facilities", label = "Active support facilities", color = COL_GREEN)))
602
+ ))
603
+ )
604
+ )
605
+ )
606
+
607
+ # --- Lucide icons + JS helpers --------------------------------------------------
608
+ LUCIDE_JS <- tags$head(
609
+ tags$script(src = "https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"),
610
+ tags$script(HTML("
611
+ var gridOn = false;
612
+
613
+ document.addEventListener('DOMContentLoaded', function() {
614
+ setTimeout(function() {
615
+ if (window.lucide) lucide.createIcons();
616
+ updateActiveLayers(); // init legend visibility
617
+ setTimeout(initViewport, 800);
618
+ }, 400);
619
+
620
+ // Tooltip: positioned via JS to escape overflow:hidden containers
621
+ document.addEventListener('mouseover', function(e) {
622
+ var el = e.target.closest('[data-tooltip]');
623
+ if (!el) { hideTooltip(); return; }
624
+ var tip = document.getElementById('depth-tooltip');
625
+ if (!tip) {
626
+ tip = document.createElement('div');
627
+ tip.id = 'depth-tooltip';
628
+ tip.className = 'depth-tooltip';
629
+ document.body.appendChild(tip);
630
+ }
631
+ tip.textContent = el.dataset.tooltip;
632
+ var r = el.getBoundingClientRect();
633
+ tip.style.left = (r.right + 8) + 'px';
634
+ tip.style.top = (r.top + r.height / 2) + 'px';
635
+ tip.style.display = 'block';
636
+ });
637
+ document.addEventListener('mouseout', function(e) {
638
+ if (e.target.closest('[data-tooltip]')) hideTooltip();
639
+ });
640
+
641
+ // Fire initial values once Shiny is connected
642
+ $(document).on('shiny:connected', function() {
643
+ setTimeout(function() {
644
+ updateActiveLayers();
645
+ Shiny.setInputValue('crime_type', 'Homicide rate', { priority: 'event' });
646
+ Shiny.setInputValue('envipe_type', 'insecurity', { priority: 'event' });
647
+ Shiny.setInputValue('fosas_type', 'fosas', { priority: 'event' });
648
+ Shiny.setInputValue('grid_nivel', '6', { priority: 'event' });
649
+ }, 300);
650
+
651
+ // mapgl injects rect/radius into the first ctrl-group it finds.
652
+ // Identify draw ctrl-group by the trash button and move them there.
653
+ var fixTries = 0;
654
+ var fixTimer = setInterval(function() {
655
+ fixTries++;
656
+ var mapEl = document.querySelector('.maplibregl-map');
657
+ if (!mapEl) { if (fixTries >= 40) clearInterval(fixTimer); return; }
658
+ var drawGroup = null;
659
+ mapEl.querySelectorAll('.maplibregl-ctrl-group').forEach(function(g) {
660
+ if (g.querySelector('.mapbox-gl-draw_trash')) drawGroup = g;
661
+ });
662
+ if (!drawGroup) { if (fixTries >= 40) clearInterval(fixTimer); return; }
663
+ var trashBtn = drawGroup.querySelector('.mapbox-gl-draw_trash');
664
+ var moved = false;
665
+ mapEl.querySelectorAll('.maplibregl-ctrl-group').forEach(function(g) {
666
+ if (g === drawGroup) return;
667
+ ['.mapbox-gl-draw_rectangle', '.mapbox-gl-draw_radius'].forEach(function(sel) {
668
+ var btn = g.querySelector(sel);
669
+ if (!btn) return;
670
+ if (trashBtn) drawGroup.insertBefore(btn, trashBtn);
671
+ else drawGroup.appendChild(btn);
672
+ moved = true;
673
+ });
674
+ });
675
+ if (moved || fixTries >= 40) clearInterval(fixTimer);
676
+ }, 100);
677
+ });
678
+
679
+ // Close style picker / grid panel / state panel when clicking outside
680
+ document.addEventListener('click', function(e) {
681
+ var styleWrap = document.querySelector('.style-btn-wrap');
682
+ if (styleWrap && !styleWrap.contains(e.target)) {
683
+ var picker = document.getElementById('style-picker');
684
+ if (picker) picker.style.display = 'none';
685
+ }
686
+ var gridWrap = document.querySelector('.grid-btn-wrap');
687
+ if (gridWrap && !gridWrap.contains(e.target) && gridOn) {
688
+ gridOn = false;
689
+ closeGridPanel();
690
+ var gridBtn = document.getElementById('grid-icon-btn');
691
+ if (gridBtn) gridBtn.classList.remove('active');
692
+ }
693
+ var statePanel = document.getElementById('state-picker-panel');
694
+ var stateBtn = document.getElementById('btn_state_filter');
695
+ if (statePanel && statePanel.style.display !== 'none') {
696
+ if (!statePanel.contains(e.target) && e.target !== stateBtn &&
697
+ !(stateBtn && stateBtn.contains(e.target))) {
698
+ statePanel.style.display = 'none';
699
+ }
700
+ }
701
+ });
702
+
703
+ // Hide state panel on Shiny message
704
+ Shiny.addCustomMessageHandler('hideStatePanel', function(msg) {
705
+ var p = document.getElementById('state-picker-panel');
706
+ if (p) p.style.display = 'none';
707
+ });
708
+ });
709
+
710
+ function hideTooltip() {
711
+ var tip = document.getElementById('depth-tooltip');
712
+ if (tip) tip.style.display = 'none';
713
+ }
714
+
715
+ function clearDrawing() {
716
+ var btn = document.getElementById('clear_btn');
717
+ if (btn) btn.click();
718
+ }
719
+
720
+ function setMapMode(mode) {
721
+ if (typeof Shiny !== 'undefined')
722
+ Shiny.setInputValue('select_mode', mode, { priority: 'event' });
723
+ ['draw', 'click'].forEach(function(m) {
724
+ var btn = document.getElementById('btn_mode_' + m);
725
+ if (btn) btn.classList.toggle('active', m === mode);
726
+ });
727
+ }
728
+
729
+ function toggleLayerPanel() {
730
+ var body = document.getElementById('layer-ctrl-body');
731
+ var icon = document.getElementById('layer-ctrl-icon');
732
+ var hidden = body.style.display === 'none';
733
+ body.style.display = hidden ? '' : 'none';
734
+ icon.innerHTML = hidden ? '&#9650;' : '&#9660;';
735
+ }
736
+
737
+ function toggleGroup(id) {
738
+ var body = document.getElementById('group-' + id);
739
+ var icon = document.getElementById('icon-' + id);
740
+ if (!body) return;
741
+ var hidden = body.style.display === 'none';
742
+ body.style.display = hidden ? '' : 'none';
743
+ icon.innerHTML = hidden ? '&#9650;' : '&#9660;';
744
+ }
745
+
746
+ function updateActiveLayers() {
747
+ var checked = [];
748
+ document.querySelectorAll('.layer-check:checked').forEach(function(cb) {
749
+ checked.push(cb.value);
750
+ });
751
+ // Sync legend visibility: use class so CSS controls display type per legend shape
752
+ document.querySelectorAll('.acc-item-wrap').forEach(function(wrap) {
753
+ var input = wrap.querySelector('.acc-toggle-input');
754
+ var legend = wrap.querySelector('.acc-legend');
755
+ if (legend) legend.classList.toggle('legend-visible', !!(input && input.checked));
756
+ });
757
+ if (typeof Shiny !== 'undefined')
758
+ Shiny.setInputValue('active_layers', checked, { priority: 'event' });
759
+ }
760
+
761
+ function setCrimeType(type) {
762
+ if (typeof Shiny !== 'undefined')
763
+ Shiny.setInputValue('crime_type', type, { priority: 'event' });
764
+ document.querySelectorAll('.crime-radio').forEach(function(r) {
765
+ r.classList.toggle('active', r.dataset.value === type);
766
+ });
767
+ }
768
+
769
+ function setEnvipeType(type) {
770
+ if (typeof Shiny !== 'undefined')
771
+ Shiny.setInputValue('envipe_type', type, { priority: 'event' });
772
+ document.querySelectorAll('.envipe-radio').forEach(function(r) {
773
+ r.classList.toggle('active', r.dataset.value === type);
774
+ });
775
+ }
776
+
777
+ function setFosasType(type) {
778
+ if (typeof Shiny !== 'undefined')
779
+ Shiny.setInputValue('fosas_type', type, { priority: 'event' });
780
+ document.querySelectorAll('.fosas-radio').forEach(function(r) {
781
+ r.classList.toggle('active', r.dataset.value === type);
782
+ });
783
+ }
784
+
785
+ function toggleStylePicker() {
786
+ var picker = document.getElementById('style-picker');
787
+ if (!picker) return;
788
+ picker.style.display = picker.style.display === 'none' ? 'block' : 'none';
789
+ }
790
+
791
+ function setMapStyle(style) {
792
+ if (typeof Shiny !== 'undefined')
793
+ Shiny.setInputValue('map_style', style, { priority: 'event' });
794
+ document.querySelectorAll('.style-option').forEach(function(btn) {
795
+ btn.classList.toggle('active', btn.dataset.style === style);
796
+ });
797
+ var picker = document.getElementById('style-picker');
798
+ if (picker) picker.style.display = 'none';
799
+ }
800
+
801
+ function toggleStatePanel() {
802
+ var p = document.getElementById('state-picker-panel');
803
+ if (!p) return;
804
+ p.style.display = p.style.display === 'none' ? 'flex' : 'none';
805
+ }
806
+
807
+ function closeGridPanel() {
808
+ var panel = document.getElementById('grid-panel');
809
+ if (!panel) return;
810
+ panel.classList.remove('open');
811
+ setTimeout(function() { if (!panel.classList.contains('open')) panel.style.display = 'none'; }, 160);
812
+ }
813
+
814
+ function toggleGridPanel() {
815
+ var panel = document.getElementById('grid-panel');
816
+ if (!panel) return;
817
+ gridOn = !gridOn;
818
+ if (gridOn) {
819
+ panel.style.display = 'flex';
820
+ requestAnimationFrame(function() { panel.classList.add('open'); });
821
+ } else {
822
+ closeGridPanel();
823
+ }
824
+ var btn = document.getElementById('grid-icon-btn');
825
+ if (btn) btn.classList.toggle('active', gridOn);
826
+ if (typeof Shiny !== 'undefined')
827
+ Shiny.setInputValue('grid_icon_click', Date.now(), { priority: 'event' });
828
+ }
829
+
830
+ function setGridNivel(nivel) {
831
+ if (typeof Shiny !== 'undefined')
832
+ Shiny.setInputValue('grid_nivel', nivel, { priority: 'event' });
833
+ document.querySelectorAll('.grid-nivel-btn').forEach(function(btn) {
834
+ btn.classList.toggle('active', btn.dataset.nivel === nivel);
835
+ });
836
+ // Auto-collapse grid panel after selection
837
+ closeGridPanel();
838
+ var iconBtn = document.getElementById('grid-icon-btn');
839
+ if (iconBtn) iconBtn.classList.remove('active');
840
+ gridOn = false;
841
+ }
842
+
843
+ function initViewport() {
844
+ var widget = window.HTMLWidgets && HTMLWidgets.find('#map');
845
+ if (!widget) { setTimeout(initViewport, 500); return; }
846
+ var m = widget.map || (widget.getMap && widget.getMap());
847
+ if (!m || typeof m.getCenter !== 'function') { setTimeout(initViewport, 500); return; }
848
+ ['moveend', 'zoomend', 'rotateend', 'pitchend'].forEach(function(ev) {
849
+ m.on(ev, function() { renderViewport(m); });
850
+ });
851
+ renderViewport(m);
852
+ }
853
+
854
+ function renderViewport(m) {
855
+ try {
856
+ var c = m.getCenter();
857
+ var set = function(id, v) { var e = document.getElementById(id); if (e) e.textContent = v; };
858
+ set('vp-lon', c.lng.toFixed(3));
859
+ set('vp-lat', c.lat.toFixed(3));
860
+ set('vp-zoom', m.getZoom().toFixed(1));
861
+ set('vp-brng', m.getBearing().toFixed(0) + 'Β°');
862
+ set('vp-ptch', m.getPitch().toFixed(0) + 'Β°');
863
+ } catch(e) {}
864
+ }
865
+
866
+ function toggleDpGroup(id) {
867
+ var body = document.getElementById('dp-group-body-' + id);
868
+ var icon = document.getElementById('dp-group-icon-' + id);
869
+ if (!body) return;
870
+ var hidden = body.style.display === 'none';
871
+ body.style.display = hidden ? '' : 'none';
872
+ if (icon) icon.innerHTML = hidden ? '&#9650;' : '&#9660;';
873
+ }
874
+
875
+ function toggleDpSubgroup(id) {
876
+ var body = document.getElementById('dp-sub-body-' + id);
877
+ var icon = document.getElementById('dp-sub-icon-' + id);
878
+ if (!body) return;
879
+ var hidden = body.style.display === 'none';
880
+ body.style.display = hidden ? '' : 'none';
881
+ if (icon) icon.innerHTML = hidden ? '&#9650;' : '&#9660;';
882
+ }
883
+ "))
884
+ )
885
+
886
+ # --- UI helpers -----------------------------------------------------------------
887
+
888
+ big_stat <- function(val_id, label, sub_id = NULL, color = "#1e293b") {
889
+ div(
890
+ div(class = "stat-val", style = glue("color:{color}"),
891
+ textOutput(val_id, inline = TRUE)),
892
+ div(class = "stat-lbl", label),
893
+ if (!is.null(sub_id))
894
+ div(class = "stat-sub", textOutput(sub_id, inline = TRUE))
895
+ )
896
+ }
897
+
898
+ mini_stat <- function(val_id, label, color = "#1e293b") {
899
+ div(
900
+ div(class = "mini-val", style = glue("color:{color}"),
901
+ textOutput(val_id, inline = TRUE)),
902
+ div(class = "mini-lbl", label)
903
+ )
904
+ }
905
+
906
+ rank_row <- function(i, name, state, rate_str, pct, color) {
907
+ tags$li(class = "rank-item",
908
+ div(class = "rank-num", sprintf("%02d", i)),
909
+ div(class = "rank-body",
910
+ div(class = "rank-name", name),
911
+ div(class = "rank-meta", state),
912
+ div(class = "rank-track",
913
+ div(class = "rank-fill",
914
+ style = glue("width:{pct}%;background:{color}")))),
915
+ div(class = "rank-rate", rate_str)
916
+ )
917
+ }
918
+
919
+ # --- Accordion layer panel helpers ----------------------------------------------
920
+
921
+ acc_group <- function(id, label, items_list, open = FALSE) {
922
+ div(class = "acc-group",
923
+ div(class = "acc-header",
924
+ onclick = paste0("toggleGroup('", id, "')"),
925
+ span(class = "acc-label", label),
926
+ span(id = paste0("icon-", id), class = "acc-icon",
927
+ HTML(if (open) "&#9650;" else "&#9660;"))
928
+ ),
929
+ div(id = paste0("group-", id),
930
+ class = "acc-body",
931
+ style = if (!open) "display:none;" else NULL,
932
+ items_list
933
+ )
934
+ )
935
+ }
936
+
937
+ acc_layer <- function(value, label, tooltip = NULL, legend = NULL, checked = FALSE) {
938
+ div(class = "acc-item-wrap",
939
+ div(class = "acc-item",
940
+ tags$label(class = "acc-toggle-label",
941
+ tags$input(
942
+ type = "checkbox",
943
+ class = "layer-check acc-toggle-input",
944
+ value = value,
945
+ onchange = "updateActiveLayers()",
946
+ checked = if (checked) "checked" else NULL
947
+ ),
948
+ tags$span(class = "acc-toggle-track",
949
+ tags$span(class = "acc-toggle-thumb")
950
+ ),
951
+ span(class = "acc-toggle-text", label)
952
+ ),
953
+ if (!is.null(tooltip))
954
+ tags$span(class = "acc-info-wrap", `data-tooltip` = tooltip,
955
+ HTML('<i data-lucide="info" style="width:11px;height:11px;stroke-width:2.5;color:var(--c-text-dim)"></i>')
956
+ )
957
+ ),
958
+ if (!is.null(legend)) legend
959
+ )
960
+ }
961
+
962
+ acc_legend_ramp <- function(pal, lo = "Low", hi = "High") {
963
+ stops <- paste(pal, collapse = ", ")
964
+ HTML(paste0(
965
+ '<div class="acc-legend acc-legend-ramp">',
966
+ '<div class="acc-legend-bar" style="background:linear-gradient(to right,', stops, ')"></div>',
967
+ '<div class="acc-legend-labels">',
968
+ '<span><span class="acc-legend-bound">min</span>', lo, '</span>',
969
+ '<span>', hi, '<span class="acc-legend-bound bound-max">max</span></span>',
970
+ '</div>',
971
+ '</div>'
972
+ ))
973
+ }
974
+
975
+ acc_legend_cats <- function(cats) {
976
+ items <- paste(sapply(seq_along(cats), function(i) {
977
+ paste0(
978
+ '<div class="acc-legend-cat">',
979
+ '<span class="acc-legend-swatch" style="background:', unname(cats[i]), '"></span>',
980
+ '<span>', names(cats)[i], '</span>',
981
+ '</div>'
982
+ )
983
+ }), collapse = "")
984
+ HTML(paste0('<div class="acc-legend acc-legend-cats">', items, '</div>'))
985
+ }
986
+
987
+ acc_legend_dot <- function(color, label = "Event / point") {
988
+ HTML(paste0(
989
+ '<div class="acc-legend acc-legend-dot">',
990
+ '<span class="acc-legend-circle" style="background:', color, '"></span>',
991
+ '<span class="acc-legend-dot-lbl">', label, '</span>',
992
+ '</div>'
993
+ ))
994
+ }
995
+
996
+ acc_legend_line <- function(color, label = "Route", dashed = FALSE) {
997
+ bstyle <- if (dashed) "dashed" else "solid"
998
+ HTML(paste0(
999
+ '<div class="acc-legend acc-legend-line-wrap">',
1000
+ '<span class="acc-legend-line-seg" style="border-top:2px ', bstyle, ' ', color, '"></span>',
1001
+ '<span class="acc-legend-line-lbl">', label, '</span>',
1002
+ '</div>'
1003
+ ))
1004
+ }
1005
+
1006
+ acc_crime_radios <- function() {
1007
+ crimes <- list(
1008
+ list(label = "Homicides", value = "Homicide rate"),
1009
+ list(label = "Robberies", value = "Robbery rate"),
1010
+ list(label = "Kidnapping", value = "Kidnapping rate"),
1011
+ list(label = "Child trafficking", value = "Child trafficking"),
1012
+ list(label = "Human trafficking", value = "Human trafficking")
1013
+ )
1014
+ div(class = "acc-radios",
1015
+ lapply(crimes, function(cr) {
1016
+ tags$button(
1017
+ class = paste0("acc-radio crime-radio", if (cr$value == "Homicide rate") " active" else ""),
1018
+ `data-value` = cr$value,
1019
+ onclick = paste0("setCrimeType('", cr$value, "')"),
1020
+ cr$label
1021
+ )
1022
+ })
1023
+ )
1024
+ }
1025
+
1026
+ acc_envipe_radios <- function() {
1027
+ types <- list(
1028
+ list(label = "Insecurity perception", value = "insecurity"),
1029
+ list(label = "Unreported crimes", value = "dark_figures")
1030
+ )
1031
+ div(class = "acc-radios",
1032
+ lapply(types, function(tp) {
1033
+ tags$button(
1034
+ class = paste0("acc-radio envipe-radio", if (tp$value == "insecurity") " active" else ""),
1035
+ `data-value` = tp$value,
1036
+ onclick = paste0("setEnvipeType('", tp$value, "')"),
1037
+ tp$label
1038
+ )
1039
+ })
1040
+ )
1041
+ }
1042
+
1043
+ acc_fosas_radios <- function() {
1044
+ types <- list(
1045
+ list(label = "Clandestine graves", value = "fosas"),
1046
+ list(label = "Bodies (cuerpos)", value = "cuerpos")
1047
+ )
1048
+ div(class = "acc-radios",
1049
+ lapply(types, function(tp) {
1050
+ tags$button(
1051
+ class = paste0("acc-radio fosas-radio", if (tp$value == "fosas") " active" else ""),
1052
+ `data-value` = tp$value,
1053
+ onclick = paste0("setFosasType('", tp$value, "')"),
1054
+ tp$label
1055
+ )
1056
+ })
1057
+ )
1058
+ }
1059
+
1060
+ # --- Analysis panel collapsible group helpers ----------------------------------
1061
+
1062
+ dp_group <- function(id, label, items_list, open = TRUE) {
1063
+ div(class = "dp-group",
1064
+ div(class = "dp-group-hdr",
1065
+ onclick = paste0("toggleDpGroup('", id, "')"),
1066
+ span(class = "dp-group-label", label),
1067
+ span(id = paste0("dp-group-icon-", id), class = "dp-group-icon",
1068
+ HTML(if (open) "&#9650;" else "&#9660;"))
1069
+ ),
1070
+ div(id = paste0("dp-group-body-", id),
1071
+ class = "dp-group-body",
1072
+ style = if (!open) "display:none;" else NULL,
1073
+ items_list
1074
+ )
1075
+ )
1076
+ }
1077
+
1078
+ dp_subgroup <- function(id, label, items_list, open = TRUE) {
1079
+ div(class = "dp-subgroup",
1080
+ div(class = "dp-subgroup-hdr",
1081
+ onclick = paste0("toggleDpSubgroup('", id, "')"),
1082
+ span(class = "dp-subgroup-label", label),
1083
+ span(id = paste0("dp-sub-icon-", id), class = "dp-sub-icon",
1084
+ HTML(if (open) "&#9650;" else "&#9660;"))
1085
+ ),
1086
+ div(id = paste0("dp-sub-body-", id),
1087
+ class = "dp-sub-body",
1088
+ style = if (!open) "display:none;" else NULL,
1089
+ items_list
1090
+ )
1091
+ )
1092
+ }
1093
+
1094
+ dp_stat <- function(val_id, label, color = "#1e293b") {
1095
+ div(class = "dp-stat",
1096
+ span(class = "dp-stat-lbl", label),
1097
+ span(class = "dp-stat-val", style = paste0("color:", color),
1098
+ textOutput(val_id, inline = TRUE))
1099
+ )
1100
+ }
1101
+
1102
+ ind_card <- function(title, body, chart_id = NULL, note = NULL, soon = FALSE) {
1103
+ div(class = "ind-card",
1104
+ div(class = "ind-card-hdr",
1105
+ span(class = "ind-card-title", title),
1106
+ if (soon) tags$span(class = "acc-coming-tag", "Soon")
1107
+ ),
1108
+ div(class = "ind-card-body", body),
1109
+ if (!is.null(note))
1110
+ div(class = "ind-card-note", note),
1111
+ if (!is.null(chart_id))
1112
+ div(class = "ind-card-chart",
1113
+ plotOutput(chart_id, width = "100%", height = "55px"))
1114
+ )
1115
+ }
1116
+
1117
+ # --- WEB_SPEC-driven UI builders (Shiny UI + docs converter share WEB_SPEC) -----
1118
+
1119
+ acc_soon_item <- function(label) {
1120
+ div(class = "acc-item acc-disabled",
1121
+ tags$span(class = "acc-toggle-label",
1122
+ tags$span(class = "acc-toggle-track", tags$span(class = "acc-toggle-thumb")),
1123
+ tags$span(class = "acc-toggle-text", label)),
1124
+ tags$span(class = "acc-coming-tag", "Soon")
1125
+ )
1126
+ }
1127
+
1128
+ acc_legend_from_spec <- function(lg) {
1129
+ if (is.null(lg)) return(NULL)
1130
+ pal <- if (!is.null(lg$pal)) WEB_SPEC$palettes[[lg$pal]] else NULL
1131
+ switch(lg$type,
1132
+ ramp = acc_legend_ramp(pal, lo = lg$lo %||% "Low", hi = lg$hi %||% "High"),
1133
+ cats = acc_legend_cats(pal),
1134
+ dot = acc_legend_dot(lg$color, lg$label %||% "Event / point"),
1135
+ line = acc_legend_line(lg$color, lg$label %||% "Route",
1136
+ dashed = isTRUE(lg$dashed)),
1137
+ NULL
1138
+ )
1139
+ }
1140
+
1141
+ acc_radios_from_spec <- function(group) {
1142
+ opts <- WEB_SPEC$radios[[group]]
1143
+ cls <- paste0(group, "-radio")
1144
+ setter <- switch(group, crime = "setCrimeType",
1145
+ envipe = "setEnvipeType", fosas = "setFosasType")
1146
+ default <- opts[[1]]$value
1147
+ div(class = "acc-radios",
1148
+ lapply(opts, function(op) {
1149
+ tags$button(
1150
+ class = paste0("acc-radio ", cls,
1151
+ if (op$value == default) " active" else ""),
1152
+ `data-value` = op$value,
1153
+ onclick = paste0(setter, "('", op$value, "')"),
1154
+ op$label
1155
+ )
1156
+ })
1157
+ )
1158
+ }
1159
+
1160
+ render_accordion <- function() {
1161
+ lapply(WEB_SPEC$accordion, function(grp) {
1162
+ items <- lapply(grp$items, function(it) {
1163
+ switch(it$kind,
1164
+ soon = acc_soon_item(it$label),
1165
+ radios = acc_radios_from_spec(it$group),
1166
+ layer = acc_layer(it$value, it$label,
1167
+ tooltip = it$tooltip,
1168
+ legend = acc_legend_from_spec(it$legend),
1169
+ checked = isTRUE(it$checked))
1170
+ )
1171
+ })
1172
+ acc_group(grp$id, grp$label, tagList(items), open = isTRUE(grp$open))
1173
+ })
1174
+ }
1175
+
1176
+ render_panel_card <- function(card) {
1177
+ body <- switch(card$kind,
1178
+ bigstat = div(class = "stat-grid",
1179
+ lapply(card$stats, function(s)
1180
+ big_stat(s$id, s$label, s$sub, s$color %||% "#1e293b"))),
1181
+ munlist = tagList(
1182
+ div(class = "dp-label", textOutput("mun_section_lbl", inline = TRUE)),
1183
+ uiOutput("mun_list")),
1184
+ stat = tagList(
1185
+ lapply(card$stats, function(s)
1186
+ dp_stat(s$id, s$label, s$color %||% "#1e293b")))
1187
+ )
1188
+ ind_card(card$title, body, chart_id = card$chart)
1189
+ }
1190
+
1191
+ render_panel_groups <- function() {
1192
+ lapply(WEB_SPEC$panel$groups, function(grp) {
1193
+ dp_group(grp$id, grp$label, open = isTRUE(grp$open),
1194
+ tagList(lapply(grp$cards, render_panel_card)))
1195
+ })
1196
+ }
1197
+
1198
+ # === UI =========================================================================
1199
+
1200
+ ui <- page_fillable(
1201
+ theme = bs_theme(version = 5, bg = "#0f172a", fg = "#f8fafc",
1202
+ primary = COL_BLUE, base_font = font_google("Inter")),
1203
+ padding = 0,
1204
+ LUCIDE_JS,
1205
+ includeCSS("10.08_styles.css"),
1206
+
1207
+ div(class = "depth-shell",
1208
+
1209
+ # === Map area (flex: 1) =====================================================
1210
+ div(class = "depth-map",
1211
+
1212
+ maplibreOutput("map", width = "100%", height = "100%"),
1213
+
1214
+ # --- Viewport info bar (bottom-center) ---------------------------------
1215
+ div(id = "viewport-bar", class = "viewport-bar",
1216
+ span(class = "vp-item",
1217
+ span(class = "vp-key", "Lon"),
1218
+ span(class = "vp-val", id = "vp-lon", "--")),
1219
+ span(class = "vp-sep"),
1220
+ span(class = "vp-item",
1221
+ span(class = "vp-key", "Lat"),
1222
+ span(class = "vp-val", id = "vp-lat", "--")),
1223
+ span(class = "vp-sep"),
1224
+ span(class = "vp-item",
1225
+ span(class = "vp-key", "Zoom"),
1226
+ span(class = "vp-val", id = "vp-zoom", "--")),
1227
+ span(class = "vp-sep"),
1228
+ span(class = "vp-item",
1229
+ span(class = "vp-key", "Bearing"),
1230
+ span(class = "vp-val", id = "vp-brng", "--")),
1231
+ span(class = "vp-sep"),
1232
+ span(class = "vp-item",
1233
+ span(class = "vp-key", "Pitch"),
1234
+ span(class = "vp-val", id = "vp-ptch", "--"))
1235
+ ),
1236
+
1237
+ # --- Extra draw controls (between draw toolbar and grid buttons) ----------
1238
+ tags$div(class = "draw-extra-ctrl",
1239
+ tags$button(id = "btn_mode_click", class = "draw-extra-btn",
1240
+ title = "Click municipality to analyse", onclick = "setMapMode('click')",
1241
+ HTML('<i data-lucide="mouse-pointer-click" style="width:14px;height:14px;stroke-width:2.5"></i>')),
1242
+ tags$button(id = "btn_state_filter", class = "draw-extra-btn",
1243
+ title = "Filter by state", onclick = "toggleStatePanel()",
1244
+ HTML('<i data-lucide="text-cursor" style="width:14px;height:14px;stroke-width:2.5"></i>')),
1245
+ tags$button(id = "btn_brush_clear", class = "draw-extra-btn",
1246
+ title = "Clear selection", onclick = "clearDrawing()",
1247
+ HTML('<i data-lucide="brush-cleaning" style="width:14px;height:14px;stroke-width:2.5"></i>'))
1248
+ ),
1249
+
1250
+ # --- Right column: home + grid + style + 3D (below draw-extra-ctrl) -----
1251
+ div(class = "btn-controls",
1252
+ actionButton("home_btn",
1253
+ HTML('<i data-lucide="house" style="width:14px;height:14px;display:inline-block;vertical-align:middle"></i>'),
1254
+ class = "btn btn-ctrl btn-ctrl-sq",
1255
+ title = "Reset to full Mexico overview"),
1256
+ # Grid toggle with expandable nivel panel + info icon
1257
+ div(class = "grid-btn-wrap",
1258
+ tags$span(
1259
+ class = "acc-info-wrap grid-info-btn",
1260
+ `data-tooltip` = "Overlay a spatial grid on the map. Select resolution: N4 (~500 km2, national overview) to N6 (~20 km2, municipality level). Grid colours follow the active layer.",
1261
+ HTML('<i data-lucide="info" style="width:11px;height:11px;stroke-width:2.5;color:var(--c-text-mid)"></i>')
1262
+ ),
1263
+ tags$button(
1264
+ id = "grid-icon-btn",
1265
+ class = "btn btn-ctrl btn-ctrl-sq",
1266
+ onclick = "toggleGridPanel()",
1267
+ title = "Toggle spatial grid",
1268
+ HTML('<i data-lucide="grid-3x3" style="width:14px;height:14px;display:inline-block;vertical-align:middle"></i>')
1269
+ ),
1270
+ div(id = "grid-panel", class = "grid-panel", style = "display:none;",
1271
+ lapply(WEB_SPEC$niveles, function(nv) {
1272
+ tags$button(
1273
+ class = paste0("grid-nivel-btn", if (nv == "6") " active" else ""),
1274
+ `data-nivel` = nv,
1275
+ onclick = paste0("setGridNivel('", nv, "')"),
1276
+ paste0("N", nv)
1277
+ )
1278
+ }),
1279
+ lapply(WEB_SPEC$niveles_soon, function(nv)
1280
+ tags$button(class = "grid-nivel-btn soon",
1281
+ title = "Coming soon", paste0("N", nv)))
1282
+ )
1283
+ ),
1284
+ div(class = "style-btn-wrap",
1285
+ tags$button(
1286
+ class = "btn btn-ctrl btn-ctrl-sq",
1287
+ onclick = "toggleStylePicker()",
1288
+ title = "Map style",
1289
+ HTML('<i data-lucide="map" style="width:14px;height:14px;display:inline-block;vertical-align:middle"></i>')
1290
+ ),
1291
+ div(id = "style-picker", class = "style-picker",
1292
+ style = "display:none;",
1293
+ tags$button(class = "style-option active",
1294
+ `data-style` = "dark-matter",
1295
+ onclick = "setMapStyle('dark-matter')",
1296
+ "Dark Matter"),
1297
+ tags$button(class = "style-option",
1298
+ `data-style` = "positron",
1299
+ onclick = "setMapStyle('positron')",
1300
+ "Positron"),
1301
+ tags$button(class = "style-option",
1302
+ `data-style` = "voyager",
1303
+ onclick = "setMapStyle('voyager')",
1304
+ "Voyager")
1305
+ )
1306
+ ),
1307
+ actionButton("toggle_3d",
1308
+ HTML('<i data-lucide="box" style="width:14px;height:14px;display:inline-block;vertical-align:middle"></i>'),
1309
+ class = "btn btn-ctrl btn-ctrl-sq",
1310
+ title = "Toggle 2D / 3D")),
1311
+
1312
+ # --- Accordion layer panel (bottom-left) --------------------------------
1313
+ div(class = "layer-ctrl-panel",
1314
+ div(class = "layer-ctrl-header",
1315
+ onclick = "toggleLayerPanel()",
1316
+ HTML('<i data-lucide="layers-plus" style="width:13px;height:13px;flex-shrink:0"></i> Data layers available'),
1317
+ span(id = "layer-ctrl-icon", class = "layer-ctrl-icon",
1318
+ HTML("&#9660;"))),
1319
+ div(id = "layer-ctrl-body", class = "layer-ctrl-body", style = "display:none;",
1320
+ render_accordion()
1321
+ )), # end layer-ctrl-panel
1322
+
1323
+ # --- State filter picker panel (top-right, near draw controls) ----------
1324
+ div(id = "state-picker-panel", class = "state-picker-panel", style = "display:none;",
1325
+ div(class = "state-picker-header",
1326
+ span("Filter by state"),
1327
+ tags$button(class = "state-picker-close", type = "button",
1328
+ onclick = "toggleStatePanel()", HTML("&times;"))
1329
+ ),
1330
+ selectInput("state_select", NULL,
1331
+ choices = list(), selectize = FALSE, width = "100%"),
1332
+ div(class = "state-picker-actions",
1333
+ actionButton("state_filter_clear", "Clear",
1334
+ class = "btn btn-clear", style = "flex:1; font-size:10px;")
1335
+ )
1336
+ ),
1337
+
1338
+ ), # end .depth-map
1339
+
1340
+ # === Analysis panel β€” right column =========================================
1341
+ div(class = "depth-panel",
1342
+ tags$div(style = "display:none;", actionButton("clear_btn", "")),
1343
+ div(class = "dp-header",
1344
+ tags$h1(class = "dp-title", WEB_SPEC$panel$title),
1345
+ tags$p(class = "dp-sub", WEB_SPEC$panel$subtitle),
1346
+ div(class = "dp-selection-badge",
1347
+ HTML('<i data-lucide="map-pin" style="width:9px;height:9px;stroke-width:2.5;flex-shrink:0"></i>'),
1348
+ textOutput("panel_subtitle", inline = TRUE))
1349
+ ),
1350
+
1351
+ div(class = "dp-scroll",
1352
+ render_panel_groups()
1353
+ ),
1354
+
1355
+ div(class = "dp-footer",
1356
+ downloadButton("dl_xlsx", "Download selection (.xlsx)"))
1357
+ )
1358
+
1359
+ )
1360
+ )
1361
+
1362
+ # === Server =====================================================================
1363
+
1364
+ server <- function(input, output, session) {
1365
+
1366
+ con <- tryCatch(
1367
+ # Deployment edit (v10_HF): read-only -- DB lives on a read-only image
1368
+ # layer and is shared across concurrent Shiny sessions.
1369
+ ddb_connect(DB_PATH, read_only = TRUE),
1370
+ error = function(e) {
1371
+ showNotification(glue("Cannot open DuckDB: {conditionMessage(e)}"),
1372
+ type = "error", duration = NULL)
1373
+ NULL
1374
+ }
1375
+ )
1376
+ session$onSessionEnded(function() {
1377
+ if (!is.null(con)) DBI::dbDisconnect(con, shutdown = TRUE)
1378
+ })
1379
+
1380
+ # Check fosas table once at session start β€” gates all fosas SQL and JS calls
1381
+ has_fosas_tbl <- if (!is.null(con)) {
1382
+ tryCatch(DBI::dbExistsTable(con, "dim_fosas_mun"), error = function(e) FALSE)
1383
+ } else FALSE
1384
+
1385
+ # Tracks whether grid N7 layers were successfully added to the map
1386
+ is_3d <- reactiveVal(FALSE)
1387
+ is_grid_on <- reactiveVal(FALSE)
1388
+
1389
+ rv <- reactiveValues(
1390
+ n_incidents = "---", n_fatalities = "---",
1391
+ n_graves = "---", n_facilities = "---",
1392
+ n_events = "---", n_mun = "---",
1393
+ n_desap = "---", n_pam = "---",
1394
+ ocved_n = "---", n_ged = "---",
1395
+ sub_incidents = " ", sub_fatalities = " ",
1396
+ mun_df = NULL,
1397
+ dl_list = NULL,
1398
+ is_national = TRUE,
1399
+ temporal_filter_wkt = NULL
1400
+ )
1401
+
1402
+ # Mutable quantile-break cache; populated once in renderMaplibre()
1403
+ qb <- list()
1404
+
1405
+ # Compute n_colors-1 quantile thresholds from a DuckDB table column
1406
+ compute_qbreaks <- function(tbl, col, n_colors, filter_zero = TRUE) {
1407
+ n_breaks <- n_colors - 1L
1408
+ q <- tryCatch(
1409
+ DBI::dbGetQuery(con, web_sql_qbreaks(tbl, col, n_colors, filter_zero)),
1410
+ error = function(e) { message("qbreaks[", col, "]: ", e$message); NULL }
1411
+ )
1412
+ if (is.null(q) || nrow(q) == 0 || ncol(q) == 0)
1413
+ return(seq_len(n_breaks))
1414
+ as.numeric(q[1, ])
1415
+ }
1416
+
1417
+ # --- Expression helpers -------------------------------------------------------
1418
+
1419
+ step_expr <- function(col, breaks, pal) {
1420
+ parts <- list("step", list("get", col), pal[[1]])
1421
+ for (i in seq_along(breaks))
1422
+ parts <- c(parts, list(breaks[[i]], pal[[i + 1L]]))
1423
+ parts
1424
+ }
1425
+
1426
+ crime_step_expr <- function(col, breaks) {
1427
+ inner <- step_expr(col, breaks, CRIME_STEP_PAL)
1428
+ list("case", list("==", list("get", col), 0), "rgba(0,0,0,0)", inner)
1429
+ }
1430
+
1431
+ count_step_expr <- function(col, breaks, pal) {
1432
+ inner <- step_expr(col, breaks, pal)
1433
+ list("case", list("==", list("get", col), 0), "rgba(0,0,0,0)", inner)
1434
+ }
1435
+
1436
+ giim_match_expr <- function() {
1437
+ pairs <- unlist(
1438
+ lapply(names(GIIM_COLORS), function(nm) list(nm, unname(GIIM_COLORS[[nm]]))),
1439
+ recursive = FALSE
1440
+ )
1441
+ c(list("match", list("get", "giim_mun")), pairs, list("#cccccc"))
1442
+ }
1443
+
1444
+ fosas_step_expr <- function(fosas_type = "fosas") {
1445
+ info <- FOSAS_COLS[[fosas_type]] %||% FOSAS_COLS[["fosas"]]
1446
+ inner <- step_expr(info$col, info$breaks, FOSAS_PAL)
1447
+ list("case", list("==", list("get", info$col), 0), "rgba(0,0,0,0)", inner)
1448
+ }
1449
+
1450
+ # Quantile-aware step expression builders (use qb cache if populated)
1451
+ crime_qstep_expr <- function(crime_type) {
1452
+ info <- CRIME_COLS[[crime_type]]
1453
+ breaks <- qb$crime[[crime_type]] %||% info$breaks
1454
+ crime_step_expr(info$col, breaks)
1455
+ }
1456
+
1457
+ desap_qstep_expr <- function() {
1458
+ breaks <- qb$desap %||% c(5, 15, 40, 80, 150)
1459
+ step_expr("desap_per_100k", breaks, DESAP_PAL)
1460
+ }
1461
+
1462
+ pam_qstep_expr <- function() {
1463
+ breaks <- qb$pam %||% c(10, 50, 200, 500, 1000)
1464
+ count_step_expr("pam_n", breaks, PAM_PAL)
1465
+ }
1466
+
1467
+ # Return the fill-color expression for a given original layer ID
1468
+ grid_color_expr <- function(orig_lid, crime_type, fosas_type = "fosas") {
1469
+ switch(orig_lid,
1470
+ "crime-choro" = crime_qstep_expr(crime_type),
1471
+ "desap-choro" = desap_qstep_expr(),
1472
+ "fosas-choro" = fosas_step_expr(fosas_type),
1473
+ "remesas-choro" = giim_match_expr(),
1474
+ "pam-choro" = pam_qstep_expr(),
1475
+ "migrants-pts" = count_step_expr("migrants_n", c(1, 5, 20, 50, 100), MIGRANTS_PAL),
1476
+ "graves-pts" = count_step_expr("graves_n", c(1, 2, 5, 15, 30), GRAVES_PAL),
1477
+ "infra-pts" = count_step_expr("infra_n", c(1, 3, 8, 20, 50), INFRA_PAL),
1478
+ "ocved-pts" = count_step_expr("ocved_n", c(1, 5, 20, 60, 200), OCVED_PAL),
1479
+ "ged-pts" = count_step_expr("ged_n", c(1, 3, 10, 30, 80), GED_PAL),
1480
+ NULL
1481
+ )
1482
+ }
1483
+
1484
+ # Central visibility updater
1485
+ apply_visibility <- function(active, grid_on, crime_type, nivel, envipe_type, fosas_type = "fosas") {
1486
+ prx <- maplibre_proxy("map")
1487
+
1488
+ for (orig_lid in names(GRID_LAYER_CFG)) {
1489
+ if (orig_lid == "fosas-choro" && !has_fosas_tbl) next
1490
+ vis <- if (!grid_on && orig_lid %in% active) "visible" else "none"
1491
+ prx <- prx |> set_layout_property(orig_lid, "visibility", vis)
1492
+ }
1493
+ prx <- prx |> set_layout_property(
1494
+ "crime-border", "visibility",
1495
+ if (!grid_on && "crime-choro" %in% active) "visible" else "none"
1496
+ )
1497
+
1498
+ # Sync crime fill color to selected type when grid is OFF
1499
+ if (!grid_on && "crime-choro" %in% active) {
1500
+ prx <- prx |> set_paint_property(
1501
+ "crime-choro", "fill-color",
1502
+ crime_qstep_expr(crime_type)
1503
+ )
1504
+ }
1505
+
1506
+ # Sync fosas fill color to selected sub-type when grid is OFF
1507
+ if (has_fosas_tbl && !grid_on && "fosas-choro" %in% active) {
1508
+ prx <- prx |> set_paint_property(
1509
+ "fosas-choro", "fill-color",
1510
+ fosas_step_expr(fosas_type)
1511
+ )
1512
+ }
1513
+
1514
+ for (lid in NON_GRID_LAYERS) {
1515
+ prx <- prx |> set_layout_property(
1516
+ lid, "visibility",
1517
+ if (lid %in% active) "visible" else "none"
1518
+ )
1519
+ }
1520
+
1521
+ # Sync ENVIPE fill color to selected sub-type when layer is active
1522
+ if ("envipe-state" %in% active) {
1523
+ env_info <- ENVIPE_COLS[[envipe_type]] %||% ENVIPE_COLS[["insecurity"]]
1524
+ prx <- prx |> set_paint_property(
1525
+ "envipe-state", "fill-color",
1526
+ step_expr(env_info$col, env_info$breaks, ENVIPE_PAL)
1527
+ )
1528
+ }
1529
+
1530
+ # Grid fill layers (opacity-based to avoid always-visible init bug)
1531
+ niv_seq <- if (grid7_loaded()) WEB_SPEC$niveles else c("4", "5", "6")
1532
+ for (niv in niv_seq) {
1533
+ for (orig_lid in names(GRID_LAYER_CFG)) {
1534
+ if (orig_lid == "fosas-choro" && !has_fosas_tbl) next
1535
+ key <- GRID_LAYER_CFG[[orig_lid]]
1536
+ grid_id <- paste0("grid-", key, "-n", niv)
1537
+ show <- grid_on && orig_lid %in% active && niv == nivel
1538
+ prx <- prx |> set_paint_property(grid_id, "fill-opacity", if (show) 0.65 else 0)
1539
+ if (show) {
1540
+ expr <- grid_color_expr(orig_lid, crime_type, fosas_type)
1541
+ if (!is.null(expr))
1542
+ prx <- prx |> set_paint_property(grid_id, "fill-color", expr)
1543
+ }
1544
+ }
1545
+ }
1546
+ }
1547
+
1548
+ # --- National totals ----------------------------------------------------------
1549
+
1550
+ load_national_totals <- function() {
1551
+ if (is.null(con)) return()
1552
+ rv$is_national <- TRUE
1553
+ rv$dl_list <- NULL
1554
+ rv$temporal_filter_wkt <- NULL
1555
+
1556
+ migrants <- tryCatch(DBI::dbGetQuery(con, web_sql("nat_migrants")),
1557
+ error = function(e) data.frame(n = 0, n_dead = 0, n_miss = 0, n_total = 0))
1558
+ rv$n_incidents <- fmt_n(migrants$n)
1559
+ rv$n_fatalities <- fmt_n(migrants$n_dead)
1560
+ rv$sub_incidents <- if (migrants$n_total > 0) glue("{fmt_n(migrants$n_total)} people recorded") else " "
1561
+ rv$sub_fatalities <- if (migrants$n_miss > 0) glue("{fmt_n(migrants$n_miss)} missing") else " "
1562
+
1563
+ graves <- tryCatch(DBI::dbGetQuery(con, web_sql("nat_count", tbl = "graves")),
1564
+ error = function(e) data.frame(n = 0))
1565
+ rv$n_graves <- fmt_n(graves$n)
1566
+
1567
+ infra <- tryCatch(DBI::dbGetQuery(con, web_sql("nat_count", tbl = "support_infrastructure")),
1568
+ error = function(e) data.frame(n = 0))
1569
+ rv$n_facilities <- fmt_n(infra$n)
1570
+
1571
+ ocved <- tryCatch(DBI::dbGetQuery(con, web_sql("nat_count", tbl = "ocved_events")),
1572
+ error = function(e) data.frame(n = 0))
1573
+ ged <- tryCatch(DBI::dbGetQuery(con, web_sql("nat_count", tbl = "ged_events")),
1574
+ error = function(e) data.frame(n = 0))
1575
+ rv$n_events <- fmt_n(ocved$n + ged$n)
1576
+ rv$ocved_n <- fmt_n(ocved$n)
1577
+ rv$n_ged <- fmt_n(ged$n)
1578
+
1579
+ mun_agg <- tryCatch(DBI::dbGetQuery(con, web_sql("nat_mun_agg")),
1580
+ error = function(e) data.frame(n_mun = 0, desap_n = 0, pam_n = 0))
1581
+ rv$n_mun <- fmt_n(mun_agg$n_mun)
1582
+ rv$n_desap <- fmt_n(mun_agg$desap_n)
1583
+ rv$n_pam <- fmt_n(mun_agg$pam_n)
1584
+
1585
+ rv$mun_df <- tryCatch(DBI::dbGetQuery(con, web_sql("mun_table")),
1586
+ error = function(e) data.frame())
1587
+ }
1588
+
1589
+ load_pts <- function(tbl, limit = 80000) {
1590
+ tryCatch({
1591
+ DBI::dbGetQuery(con, web_sql("load_pts", tbl = tbl, limit = limit)) |>
1592
+ sf::st_as_sf(coords = c("lon", "lat"), crs = 4326)
1593
+ }, error = function(e) { message(" [pts] ", tbl, ": ", e$message); NULL })
1594
+ }
1595
+
1596
+ # --- Query helpers ------------------------------------------------------------
1597
+
1598
+ pts_in_poly <- function(tbl, poly_wkt, bb) {
1599
+ DBI::dbGetQuery(con, web_sql("pts_in_poly", tbl = tbl,
1600
+ xmin = bb["xmin"], xmax = bb["xmax"],
1601
+ ymin = bb["ymin"], ymax = bb["ymax"], poly_wkt = poly_wkt))
1602
+ }
1603
+
1604
+ mun_in_poly <- function(poly_wkt, bb) {
1605
+ DBI::dbGetQuery(con, web_sql("mun_in_poly",
1606
+ xmin = bb["xmin"], xmax = bb["xmax"],
1607
+ ymin = bb["ymin"], ymax = bb["ymax"], poly_wkt = poly_wkt))
1608
+ }
1609
+
1610
+ run_analysis <- function(poly_wkt, bb) {
1611
+ rv$is_national <- FALSE
1612
+ rv$temporal_filter_wkt <- poly_wkt
1613
+ migrants_df <- tryCatch(pts_in_poly("migrants", poly_wkt, bb),
1614
+ error = function(e) data.frame())
1615
+ rv$n_incidents <- fmt_n(nrow(migrants_df))
1616
+ n_dead <- sum(as.numeric(migrants_df$n_dead), na.rm = TRUE)
1617
+ n_miss <- sum(as.numeric(migrants_df$n_missing), na.rm = TRUE)
1618
+ n_total <- sum(as.numeric(migrants_df$n_total), na.rm = TRUE)
1619
+ rv$n_fatalities <- fmt_n(n_dead)
1620
+ rv$sub_incidents <- if (n_total > 0) glue("{fmt_n(n_total)} people recorded") else " "
1621
+ rv$sub_fatalities <- if (n_miss > 0) glue("{fmt_n(n_miss)} missing") else " "
1622
+
1623
+ graves_df <- tryCatch(pts_in_poly("graves", poly_wkt, bb),
1624
+ error = function(e) data.frame())
1625
+ rv$n_graves <- fmt_n(nrow(graves_df))
1626
+
1627
+ infra_df <- tryCatch(pts_in_poly("support_infrastructure", poly_wkt, bb),
1628
+ error = function(e) data.frame())
1629
+ rv$n_facilities <- fmt_n(nrow(infra_df))
1630
+
1631
+ ocved_df <- tryCatch(pts_in_poly("ocved_events", poly_wkt, bb),
1632
+ error = function(e) data.frame())
1633
+ ged_df <- tryCatch(pts_in_poly("ged_events", poly_wkt, bb),
1634
+ error = function(e) data.frame())
1635
+ rv$n_events <- fmt_n(nrow(ocved_df) + nrow(ged_df))
1636
+ rv$ocved_n <- fmt_n(nrow(ocved_df))
1637
+ rv$n_ged <- fmt_n(nrow(ged_df))
1638
+
1639
+ mun_df <- tryCatch(mun_in_poly(poly_wkt, bb),
1640
+ error = function(e) { message("mun: ", e$message); data.frame() })
1641
+ rv$n_mun <- fmt_n(nrow(mun_df))
1642
+ rv$n_desap <- fmt_n(sum(mun_df$desap_n, na.rm = TRUE))
1643
+ rv$n_pam <- fmt_n(sum(mun_df$pam_n, na.rm = TRUE))
1644
+ rv$mun_df <- mun_df
1645
+
1646
+ rv$dl_list <- list(
1647
+ migrants = migrants_df, graves = graves_df,
1648
+ infra = infra_df, ocved = ocved_df,
1649
+ ged = ged_df, mun = mun_df
1650
+ )
1651
+ }
1652
+
1653
+ # Satellite fade-in expression helper
1654
+ zoom_fade_in <- function(from_zoom, to_zoom) {
1655
+ list("interpolate", list("linear"), list("zoom"), from_zoom, 0, to_zoom, 1)
1656
+ }
1657
+
1658
+ # N7 is lazy-loaded on first selection (113k rows would block renderMaplibre).
1659
+ grid7_sf <- NULL
1660
+ grid7_loaded <- reactiveVal(FALSE)
1661
+
1662
+ # Grid tooltip and layer builder live in server scope so the lazy N7
1663
+ # observer can add N7 layers via maplibre_proxy after renderMaplibre completes.
1664
+ grid_tooltip <- list("concat",
1665
+ "<b>Cell: </b>", list("get", "codigo"),
1666
+ "<br>Homicide: <b>", list("to-string", list("get", "homicidio")), "</b> /100k",
1667
+ "<br>Disappear./100k: <b>", list("to-string", list("get", "desap_per_100k")), "</b>",
1668
+ "<br>Burial pits: ", list("to-string", list("get", "fosas_total")),
1669
+ "<br>Irreg. mig. events: ", list("to-string", list("get", "pam_n")),
1670
+ "<br>IOM incidents: ", list("to-string", list("get", "migrants_n")),
1671
+ "<br>Grave sites: ", list("to-string", list("get", "graves_n")),
1672
+ "<br>Violence (OCVED): ", list("to-string", list("get", "ocved_n")),
1673
+ "<br>Conflict (GED): ", list("to-string", list("get", "ged_n")),
1674
+ "<br>Facilities: ", list("to-string", list("get", "infra_n")))
1675
+
1676
+ add_typed_grid_layers <- function(m, grid_sf, niv) {
1677
+ if (is.null(grid_sf)) return(m)
1678
+ src <- paste0("grid-crime-n", niv)
1679
+ m <- m |>
1680
+ add_fill_layer(
1681
+ id = src,
1682
+ source = grid_sf,
1683
+ fill_color = crime_qstep_expr("Homicide rate"),
1684
+ fill_opacity = 0,
1685
+ tooltip = grid_tooltip
1686
+ )
1687
+ m <- m |>
1688
+ add_fill_layer(id = paste0("grid-desap-n", niv), source = src,
1689
+ fill_color = desap_qstep_expr(),
1690
+ fill_opacity = 0, tooltip = grid_tooltip)
1691
+ if (has_fosas_tbl) {
1692
+ m <- m |> add_fill_layer(id = paste0("grid-fosas-n", niv), source = src,
1693
+ fill_color = fosas_step_expr("fosas"),
1694
+ fill_opacity = 0, tooltip = grid_tooltip)
1695
+ }
1696
+ m <- m |>
1697
+ add_fill_layer(id = paste0("grid-remesas-n", niv), source = src,
1698
+ fill_color = giim_match_expr(),
1699
+ fill_opacity = 0, tooltip = grid_tooltip) |>
1700
+ add_fill_layer(id = paste0("grid-pam-n", niv), source = src,
1701
+ fill_color = pam_qstep_expr(),
1702
+ fill_opacity = 0, tooltip = grid_tooltip) |>
1703
+ add_fill_layer(id = paste0("grid-migrants-n", niv), source = src,
1704
+ fill_color = count_step_expr("migrants_n", c(1, 5, 20, 50, 100), MIGRANTS_PAL),
1705
+ fill_opacity = 0, tooltip = grid_tooltip) |>
1706
+ add_fill_layer(id = paste0("grid-graves-n", niv), source = src,
1707
+ fill_color = count_step_expr("graves_n", c(1, 2, 5, 15, 30), GRAVES_PAL),
1708
+ fill_opacity = 0, tooltip = grid_tooltip) |>
1709
+ add_fill_layer(id = paste0("grid-infra-n", niv), source = src,
1710
+ fill_color = count_step_expr("infra_n", c(1, 3, 8, 20, 50), INFRA_PAL),
1711
+ fill_opacity = 0, tooltip = grid_tooltip) |>
1712
+ add_fill_layer(id = paste0("grid-ocved-n", niv), source = src,
1713
+ fill_color = count_step_expr("ocved_n", c(1, 5, 20, 60, 200), OCVED_PAL),
1714
+ fill_opacity = 0, tooltip = grid_tooltip) |>
1715
+ add_fill_layer(id = paste0("grid-ged-n", niv), source = src,
1716
+ fill_color = count_step_expr("ged_n", c(1, 3, 10, 30, 80), GED_PAL),
1717
+ fill_opacity = 0, tooltip = grid_tooltip)
1718
+ m
1719
+ }
1720
+
1721
+ # --- Render map ---------------------------------------------------------------
1722
+ output$map <- renderMaplibre({
1723
+ req(con)
1724
+
1725
+ # Compute quantile breaks from actual data (populate qb cache)
1726
+ qb$crime <- lapply(CRIME_COLS, function(info) {
1727
+ compute_qbreaks("crime_mun", info$col, length(CRIME_STEP_PAL))
1728
+ })
1729
+ qb$desap <- compute_qbreaks("dim_desap_mun", "final_desap_nl_per_100k",
1730
+ length(DESAP_PAL))
1731
+ qb$pam <- compute_qbreaks("dim_pam_mun", "pam_n", length(PAM_PAL))
1732
+
1733
+ g <- function(col) list("get", col)
1734
+ ts <- function(col) list("to-string", list("get", col))
1735
+
1736
+ crime_sf <- tryCatch({
1737
+ DBI::dbGetQuery(con, web_sql("src_crime")) |>
1738
+ sf::st_as_sf(wkt = "wkt", crs = 4326) |>
1739
+ dplyr::select(-wkt)
1740
+ }, error = function(e) { message("crime_mun: ", e$message); NULL })
1741
+
1742
+ load_grid_unified <- function(niv, limit = NULL) {
1743
+ tryCatch({
1744
+ DBI::dbGetQuery(con,
1745
+ web_sql_grid_unified(niv, has_fosas_tbl, limit)) |>
1746
+ sf::st_as_sf(wkt = "wkt", crs = 4326) |>
1747
+ dplyr::select(-wkt)
1748
+ }, error = function(e) { message("grid_nivel", niv, ": ", e$message); NULL })
1749
+ }
1750
+
1751
+ # N7 (113k rows) is lazy-loaded; only N4-6 pre-loaded so the map renders fast.
1752
+ RENDER_NIVELES <- c("4", "5", "6")
1753
+ grid_sf_list <- setNames(
1754
+ lapply(RENDER_NIVELES, \(n) load_grid_unified(as.integer(n))),
1755
+ RENDER_NIVELES
1756
+ )
1757
+ grid4_sf <- grid_sf_list[["4"]]
1758
+ grid5_sf <- grid_sf_list[["5"]]
1759
+ grid6_sf <- grid_sf_list[["6"]]
1760
+ # grid7_sf stays NULL until user first selects N7 (see lazy observer below)
1761
+ desap_sf <- tryCatch({
1762
+ DBI::dbGetQuery(con, web_sql("src_desap")) |>
1763
+ sf::st_as_sf(wkt = "wkt", crs = 4326) |>
1764
+ dplyr::select(-wkt)
1765
+ }, error = function(e) { message("desap: ", e$message); NULL })
1766
+
1767
+ remesas_sf <- tryCatch({
1768
+ DBI::dbGetQuery(con, web_sql("src_remesas")) |>
1769
+ sf::st_as_sf(wkt = "wkt", crs = 4326) |>
1770
+ dplyr::select(-wkt)
1771
+ }, error = function(e) { message("remesas: ", e$message); NULL })
1772
+
1773
+ pam_sf <- tryCatch({
1774
+ DBI::dbGetQuery(con, web_sql("src_pam")) |>
1775
+ sf::st_as_sf(wkt = "wkt", crs = 4326) |>
1776
+ dplyr::select(-wkt)
1777
+ }, error = function(e) { message("pam: ", e$message); NULL })
1778
+
1779
+ fosas_sf <- tryCatch({
1780
+ DBI::dbGetQuery(con, web_sql("src_fosas")) |>
1781
+ sf::st_as_sf(wkt = "wkt", crs = 4326) |>
1782
+ dplyr::select(-wkt)
1783
+ }, error = function(e) { message("dim_fosas_mun: ", e$message); NULL })
1784
+
1785
+ state_sf <- tryCatch({
1786
+ DBI::dbGetQuery(con, web_sql("src_state")) |>
1787
+ sf::st_as_sf(wkt = "wkt", crs = 4326) |>
1788
+ dplyr::select(-wkt)
1789
+ }, error = function(e) { message("state_polygons: ", e$message); NULL })
1790
+
1791
+ routes_sf <- tryCatch({
1792
+ DBI::dbGetQuery(con, web_sql("src_routes")) |>
1793
+ sf::st_as_sf(wkt = "wkt", crs = 4326) |>
1794
+ dplyr::select(-wkt)
1795
+ }, error = function(e) { message("routes: ", e$message); NULL })
1796
+
1797
+ via_sf <- tryCatch({
1798
+ DBI::dbGetQuery(con, web_sql("src_via")) |>
1799
+ sf::st_as_sf(wkt = "wkt", crs = 4326) |>
1800
+ dplyr::select(-wkt)
1801
+ }, error = function(e) { message("viaferrea: ", e$message); NULL })
1802
+
1803
+ migrants_pts <- load_pts("migrants", 8000)
1804
+ graves_pts <- load_pts("graves", 5000)
1805
+ infra_pts <- load_pts("support_infrastructure", 5000)
1806
+ ocved_pts <- load_pts("ocved_events", 80000)
1807
+ ged_pts <- load_pts("ged_events", 80000)
1808
+
1809
+ m <- maplibre(
1810
+ style = carto_style("positron"),
1811
+ center = c(-102.5, 23.6), zoom = 4.5
1812
+ ) |>
1813
+ add_geocoder_control(position = "top-left", placeholder = "Search location...") |>
1814
+ add_draw_control(
1815
+ position = "top-right",
1816
+ freehand = TRUE,
1817
+ simplify_freehand = TRUE,
1818
+ rectangle = TRUE,
1819
+ radius = TRUE,
1820
+ point_color = COL_BLUE,
1821
+ line_color = COL_BLUE,
1822
+ fill_color = COL_BLUE,
1823
+ fill_opacity = 0.12,
1824
+ active_color = "#93c5fd",
1825
+ line_width = 2,
1826
+ show_measurements = TRUE
1827
+ )
1828
+
1829
+ # --- Satellite imagery (fades in at zoom > 14) ----------------------------
1830
+ m <- m |>
1831
+ add_raster_source(
1832
+ id = "satellite-source",
1833
+ tiles = list("https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"),
1834
+ tileSize = 256,
1835
+ maxzoom = 19
1836
+ ) |>
1837
+ add_raster_layer(
1838
+ id = "satellite-basemap",
1839
+ source = "satellite-source",
1840
+ raster_opacity = zoom_fade_in(14, 16),
1841
+ raster_fade_duration = 300
1842
+ )
1843
+
1844
+ # --- Municipality / state fill layers (crime-choro visible by default) -----
1845
+ if (!is.null(crime_sf)) {
1846
+ m <- m |>
1847
+ add_fill_layer(
1848
+ id = "crime-choro",
1849
+ source = crime_sf,
1850
+ fill_color = crime_qstep_expr("Homicide rate"),
1851
+ fill_opacity = 0.6,
1852
+ tooltip = list("concat",
1853
+ "<b>", g("municipio"), "</b>, ", g("entidad"),
1854
+ "<br>Homicide: <b>", ts("homicidio"), "</b> /100k",
1855
+ "<br>Robbery: <b>", ts("robo"), "</b> /100k",
1856
+ "<br>Kidnapping: <b>", ts("secuestro"), "</b> /100k",
1857
+ "<br>Trafficking: <b>", ts("trata_personas"), "</b> /100k",
1858
+ "<br>Child trafficking: <b>", ts("trafico_menores"), "</b> /100k")
1859
+ ) |>
1860
+ add_line_layer(
1861
+ id = "crime-border",
1862
+ source = "crime-choro",
1863
+ line_color = "rgba(255,255,255,0.07)",
1864
+ line_width = 0.2
1865
+ )
1866
+ }
1867
+
1868
+ if (!is.null(desap_sf)) {
1869
+ m <- m |>
1870
+ add_fill_layer(
1871
+ id = "desap-choro",
1872
+ source = desap_sf,
1873
+ fill_color = desap_qstep_expr(),
1874
+ fill_opacity = 0.6, visibility = "none",
1875
+ tooltip = list("concat", "<b>", g("municipio"), "</b>",
1876
+ "<br>Disappearances: ", ts("desap_per_100k"), " /100k")
1877
+ )
1878
+ }
1879
+
1880
+ if (!is.null(remesas_sf)) {
1881
+ m <- m |>
1882
+ add_fill_layer(
1883
+ id = "remesas-choro",
1884
+ source = remesas_sf,
1885
+ fill_color = giim_match_expr(),
1886
+ fill_opacity = 0.75, visibility = "none",
1887
+ tooltip = list("concat", "<b>", g("municipio"), "</b>",
1888
+ "<br>Migration intensity: <b>", g("giim_mun"), "</b>")
1889
+ )
1890
+ }
1891
+
1892
+ if (!is.null(pam_sf)) {
1893
+ m <- m |>
1894
+ add_fill_layer(
1895
+ id = "pam-choro",
1896
+ source = pam_sf,
1897
+ fill_color = pam_qstep_expr(),
1898
+ fill_opacity = 0.7, visibility = "none",
1899
+ tooltip = list("concat", "<b>", g("municipio"), "</b>",
1900
+ "<br>Irregular migration events: ", ts("pam_n"))
1901
+ )
1902
+ }
1903
+
1904
+ if (!is.null(fosas_sf)) {
1905
+ m <- m |>
1906
+ add_fill_layer(
1907
+ id = "fosas-choro",
1908
+ source = fosas_sf,
1909
+ fill_color = fosas_step_expr("fosas"),
1910
+ fill_opacity = 0.65, visibility = "none",
1911
+ tooltip = list("concat", "<b>", g("municipio"), "</b>",
1912
+ "<br>Burial pits (fosas): ", ts("fosas_total"),
1913
+ "<br>Bodies (cuerpos): ", ts("cuerpos_total"))
1914
+ )
1915
+ }
1916
+
1917
+ if (!is.null(state_sf)) {
1918
+ m <- m |>
1919
+ add_fill_layer(
1920
+ id = "envipe-state",
1921
+ source = state_sf,
1922
+ fill_color = step_expr("insecurity_perc", c(40, 55, 65, 75, 85), ENVIPE_PAL),
1923
+ fill_opacity = 0.6, visibility = "none",
1924
+ tooltip = list("concat", "<b>", g("desc_entidad"), "</b>",
1925
+ "<br>Insecurity: ", ts("insecurity_perc"), "%",
1926
+ "<br>Dark figures: ", ts("dark_figures_perc"), "%")
1927
+ )
1928
+ }
1929
+
1930
+ # --- Grid layers: N4-6 pre-loaded; N7 added lazily by observer below -------
1931
+ for (nv in RENDER_NIVELES)
1932
+ m <- add_typed_grid_layers(m, grid_sf_list[[nv]], nv)
1933
+
1934
+ # --- Line layers ------------------------------------------------------------
1935
+ if (!is.null(routes_sf)) {
1936
+ m <- m |>
1937
+ add_line_layer(
1938
+ id = "routes-lines",
1939
+ source = routes_sf,
1940
+ line_color = COL_AMBER,
1941
+ line_width = list("interpolate", list("linear"), list("zoom"),
1942
+ 5, 0.5, 10, 2.0),
1943
+ line_opacity = 0.1, visibility = "none"
1944
+ )
1945
+ }
1946
+
1947
+ if (!is.null(via_sf)) {
1948
+ m <- m |>
1949
+ add_line_layer(
1950
+ id = "viaferrea-lines",
1951
+ source = via_sf,
1952
+ line_color = "#990f0f",
1953
+ line_width = 1.5,
1954
+ line_opacity = 0.9,
1955
+ line_dasharray = c(4, 2),
1956
+ visibility = "none"
1957
+ )
1958
+ }
1959
+
1960
+ # --- Point layers -----------------------------------------------------------
1961
+ add_pts_layer <- function(m, pts, id, color, radius = 3) {
1962
+ if (is.null(pts)) return(m)
1963
+ m |> add_circle_layer(
1964
+ id = id,
1965
+ source = pts,
1966
+ circle_color = color,
1967
+ circle_radius = radius,
1968
+ circle_opacity = 0.75,
1969
+ circle_stroke_color = "rgba(0,0,0,0.4)",
1970
+ circle_stroke_width = 0.7,
1971
+ visibility = "none"
1972
+ )
1973
+ }
1974
+
1975
+ m <- m |>
1976
+ add_pts_layer(migrants_pts, "migrants-pts", COL_YEL, 2) |>
1977
+ add_pts_layer(graves_pts, "graves-pts", COL_RED, 2) |>
1978
+ add_pts_layer(infra_pts, "infra-pts", COL_GREEN, 2) |>
1979
+ add_pts_layer(ocved_pts, "ocved-pts", COL_AMBER, 1) |>
1980
+ add_pts_layer(ged_pts, "ged-pts", COL_LIGHT_BLUE, 1)
1981
+
1982
+ m
1983
+ })
1984
+
1985
+ # --- Observers ----------------------------------------------------------------
1986
+
1987
+ # Lazy N7: load grid_nivel7 and add its layers to the map the first time
1988
+ # the user selects N7, avoiding a 5+ minute block in renderMaplibre.
1989
+ observeEvent(input$grid_nivel, {
1990
+ req(input$grid_nivel == "7", !grid7_loaded(), !is.null(con))
1991
+ grid7_sf <<- tryCatch({
1992
+ DBI::dbGetQuery(con, web_sql_grid_unified(7, has_fosas_tbl)) |>
1993
+ sf::st_as_sf(wkt = "wkt", crs = 4326) |>
1994
+ dplyr::select(-wkt)
1995
+ }, error = function(e) { message("N7 lazy load: ", e$message); NULL })
1996
+ if (!is.null(grid7_sf)) {
1997
+ maplibre_proxy("map") |> add_typed_grid_layers(grid7_sf, "7")
1998
+ grid7_loaded(TRUE)
1999
+ }
2000
+ }, ignoreNULL = TRUE, ignoreInit = TRUE)
2001
+
2002
+ observeEvent(input$clear_btn, {
2003
+ clear_drawn_features(maplibre_proxy("map"))
2004
+ load_national_totals()
2005
+ })
2006
+
2007
+ if (!is.null(con)) {
2008
+ load_national_totals()
2009
+ # Populate state filter at startup β€” avoids renderUI lifecycle issues
2010
+ tbl_cols <- tryCatch(
2011
+ DBI::dbGetQuery(con, "DESCRIBE state_polygons")$column_name,
2012
+ error = function(e) character(0)
2013
+ )
2014
+ label_col <- if ("desc_entidad" %in% tbl_cols) "desc_entidad"
2015
+ else if ("entidad" %in% tbl_cols) "entidad"
2016
+ else if ("nombre" %in% tbl_cols) "nombre"
2017
+ else "CVE_ENT"
2018
+ state_ch <- tryCatch(
2019
+ DBI::dbGetQuery(con, web_sql("state_choices", label_col = label_col)) |>
2020
+ (\(d) setNames(d$CVE_ENT, d$label))(),
2021
+ error = function(e) { message("state_polygons: ", e$message); character(0) }
2022
+ )
2023
+ updateSelectInput(session, "state_select",
2024
+ choices = c("Choose a state..." = "", state_ch))
2025
+ }
2026
+
2027
+ observeEvent(input$grid_icon_click, {
2028
+ new_on <- !is_grid_on()
2029
+ is_grid_on(new_on)
2030
+ }, ignoreNULL = TRUE, ignoreInit = TRUE)
2031
+
2032
+ observeEvent(
2033
+ eventExpr = list(
2034
+ input$active_layers, is_grid_on(),
2035
+ input$crime_type, input$grid_nivel, input$envipe_type, input$fosas_type
2036
+ ),
2037
+ handlerExpr = {
2038
+ apply_visibility(
2039
+ active = input$active_layers %||% character(0),
2040
+ grid_on = is_grid_on(),
2041
+ crime_type = input$crime_type %||% "Homicide rate",
2042
+ nivel = input$grid_nivel %||% "6",
2043
+ envipe_type = input$envipe_type %||% "insecurity",
2044
+ fosas_type = input$fosas_type %||% "fosas"
2045
+ )
2046
+ },
2047
+ ignoreInit = TRUE
2048
+ )
2049
+
2050
+ observeEvent(input$map_style, {
2051
+ req(input$map_style)
2052
+ style <- switch(input$map_style,
2053
+ "dark-matter" = carto_style("dark-matter"),
2054
+ "positron" = carto_style("positron"),
2055
+ "voyager" = carto_style("voyager"),
2056
+ "osm" = "https://tiles.openfreemap.org/styles/liberty"
2057
+ )
2058
+ if (!is.null(style))
2059
+ maplibre_proxy("map") |> set_style(style, preserve_layers = TRUE)
2060
+ }, ignoreNULL = TRUE, ignoreInit = TRUE)
2061
+
2062
+ observeEvent(input$home_btn, {
2063
+ is_3d(FALSE)
2064
+ maplibre_proxy("map") |>
2065
+ ease_to(c(-102.5, 23.6), zoom = 4.5, pitch = 0, bearing = 0, duration = 1200)
2066
+ })
2067
+
2068
+ observeEvent(input$toggle_3d, {
2069
+ new_3d <- !is_3d()
2070
+ is_3d(new_3d)
2071
+ pitch <- if (new_3d) 60 else 0
2072
+ bearing <- if (new_3d) -15 else 0
2073
+ maplibre_proxy("map") |>
2074
+ ease_to(c(-102.5, 23.6), pitch = pitch, bearing = bearing, duration = 1500)
2075
+ })
2076
+
2077
+ observeEvent(input$map_drawn_features, {
2078
+ feats <- input$map_drawn_features[["features"]]
2079
+ if (is.null(feats) || length(feats) == 0) { load_national_totals(); return() }
2080
+ drawn <- tryCatch({
2081
+ gj <- jsonlite::toJSON(input$map_drawn_features, auto_unbox = TRUE)
2082
+ tmp <- tempfile(fileext = ".geojson")
2083
+ writeLines(as.character(gj), tmp)
2084
+ result <- sf::read_sf(tmp, quiet = TRUE)
2085
+ unlink(tmp)
2086
+ result
2087
+ }, error = function(e) { message("GeoJSON parse: ", e$message); NULL })
2088
+ if (is.null(drawn) || nrow(drawn) == 0) return()
2089
+ poly <- sf::st_union(drawn)
2090
+ poly <- tryCatch(sf::st_make_valid(poly), error = function(e) poly)
2091
+ geom_type <- as.character(sf::st_geometry_type(poly, by_geometry = FALSE))
2092
+ if (!grepl("POLYGON", toupper(geom_type))) {
2093
+ poly <- sf::st_convex_hull(sf::st_buffer(poly, dist = 0.1))
2094
+ }
2095
+ poly_wkt <- sf::st_as_text(poly)
2096
+ bb <- sf::st_bbox(poly)
2097
+ run_analysis(poly_wkt, bb)
2098
+ }, ignoreNULL = FALSE)
2099
+
2100
+ observeEvent(input$map_click, {
2101
+ req(isTRUE(input$select_mode == "click"), con)
2102
+ click <- input$map_click
2103
+ req(!is.null(click$lng), !is.null(click$lat))
2104
+ lon <- click$lng; lat <- click$lat
2105
+ mun_click <- tryCatch(
2106
+ DBI::dbGetQuery(con, web_sql("click_mun",
2107
+ lon_lo = lon - 2, lon_hi = lon + 2,
2108
+ lat_lo = lat - 2, lat_hi = lat + 2, lon = lon, lat = lat)),
2109
+ error = function(e) data.frame()
2110
+ )
2111
+ if (nrow(mun_click) == 0) return()
2112
+ poly_wkt <- mun_click$wkt[1]
2113
+ sf_mun <- sf::st_as_sf(data.frame(wkt = poly_wkt), wkt = "wkt", crs = 4326)
2114
+ bb <- sf::st_bbox(sf_mun)
2115
+ run_analysis(poly_wkt, bb)
2116
+ })
2117
+
2118
+ # --- State filter ------------------------------------------------------------
2119
+
2120
+ observeEvent(input$state_select, {
2121
+ req(con, input$state_select, nchar(input$state_select) > 0)
2122
+ state_row <- tryCatch(
2123
+ DBI::dbGetQuery(con, web_sql("state_wkt", cve_ent = input$state_select)),
2124
+ error = function(e) data.frame()
2125
+ )
2126
+ req(nrow(state_row) > 0)
2127
+ poly_wkt <- state_row$wkt[1]
2128
+ sf_state <- sf::st_as_sf(data.frame(wkt = poly_wkt), wkt = "wkt", crs = 4326)
2129
+ bb <- sf::st_bbox(sf_state)
2130
+ center <- as.numeric(c((bb["xmin"] + bb["xmax"]) / 2,
2131
+ (bb["ymin"] + bb["ymax"]) / 2))
2132
+ run_analysis(poly_wkt, bb)
2133
+ maplibre_proxy("map") |> fly_to(center = center, zoom = 7, duration = 1200)
2134
+ session$sendCustomMessage("hideStatePanel", list())
2135
+ }, ignoreNULL = TRUE, ignoreInit = TRUE)
2136
+
2137
+ observeEvent(input$state_filter_clear, {
2138
+ clear_drawn_features(maplibre_proxy("map"))
2139
+ load_national_totals()
2140
+ session$sendCustomMessage("hideStatePanel", list())
2141
+ }, ignoreNULL = TRUE, ignoreInit = TRUE)
2142
+
2143
+ # --- Outputs ------------------------------------------------------------------
2144
+
2145
+ output$panel_subtitle <- renderText({
2146
+ if (isTRUE(rv$is_national)) "All Mexico" else "Selection active"
2147
+ })
2148
+
2149
+ output$n_incidents <- renderText(rv$n_incidents)
2150
+ output$n_fatalities <- renderText(rv$n_fatalities)
2151
+ output$n_graves <- renderText(rv$n_graves)
2152
+ output$n_facilities <- renderText(rv$n_facilities)
2153
+ output$n_events <- renderText(rv$n_events)
2154
+ output$ocved_n <- renderText(rv$ocved_n)
2155
+ output$n_ged <- renderText(rv$n_ged)
2156
+ output$n_mun <- renderText(rv$n_mun)
2157
+ output$n_desap <- renderText(rv$n_desap)
2158
+ output$n_pam <- renderText(rv$n_pam)
2159
+ output$sub_incidents <- renderText(rv$sub_incidents)
2160
+ output$sub_fatalities <- renderText(rv$sub_fatalities)
2161
+
2162
+ output$mun_section_lbl <- renderText({
2163
+ lbl <- tolower(input$crime_type %||% "homicide rate")
2164
+ if (isTRUE(rv$is_national)) {
2165
+ glue("Top 5 municipalities -- {lbl} (all Mexico)")
2166
+ } else {
2167
+ n <- if (!is.null(rv$mun_df)) nrow(rv$mun_df) else 0
2168
+ glue("Top 5 municipalities -- {lbl} ({n} in selection)")
2169
+ }
2170
+ })
2171
+
2172
+ output$mun_list <- renderUI({
2173
+ df <- rv$mun_df
2174
+ if (is.null(df) || nrow(df) == 0)
2175
+ return(div(class = "dp-empty", "Draw a polygon or click a municipality to filter."))
2176
+ col_name <- CRIME_COLS[[input$crime_type]]$col
2177
+ if (!col_name %in% names(df)) col_name <- "homicidio"
2178
+ df_sorted <- df[order(df[[col_name]], decreasing = TRUE, na.last = TRUE), ]
2179
+ top <- head(df_sorted, 5L)
2180
+ mx <- max(top[[col_name]], na.rm = TRUE)
2181
+ if (is.na(mx) || mx <= 0) mx <- 1
2182
+ items <- lapply(seq_len(nrow(top)), function(i) {
2183
+ r <- top[i, ]
2184
+ rate <- round(r[[col_name]], 1L)
2185
+ pct <- max(4L, round(100 * rate / mx))
2186
+ color <- BAR_RAMP[min(i, length(BAR_RAMP))]
2187
+ rank_row(i, r$municipio, r$entidad, glue("{rate} /100k"), pct, color)
2188
+ })
2189
+ extras <- nrow(df) - 5L
2190
+ tagList(
2191
+ tags$ul(class = "rank-list", items),
2192
+ if (extras > 0)
2193
+ div(class = "dp-more", glue("+ {extras} more municipalities in selection"))
2194
+ )
2195
+ })
2196
+
2197
+ output$temporal_plot <- renderPlot({
2198
+ req(con)
2199
+ # Try incident_date_ts (DATE, from parquet) then fall back to incident_date
2200
+ yr_expr_ts <- "CAST(date_part('year', incident_date_ts) AS INTEGER)"
2201
+ yr_expr_raw <- "CAST(date_part('year', CAST(incident_date AS TIMESTAMP)) AS INTEGER)"
2202
+ df_all <- tryCatch(
2203
+ DBI::dbGetQuery(con, web_sql("temporal_mig_all", yr_expr = yr_expr_ts)),
2204
+ error = function(e) tryCatch(
2205
+ DBI::dbGetQuery(con, web_sql("temporal_mig_all", yr_expr = yr_expr_raw)),
2206
+ error = function(e2) { message("temporal: ", e2$message); NULL }
2207
+ )
2208
+ )
2209
+ if (is.null(df_all) || nrow(df_all) == 0) return(NULL)
2210
+
2211
+ filter_wkt <- rv$temporal_filter_wkt
2212
+ df_sel <- if (!is.null(filter_wkt)) {
2213
+ tryCatch(DBI::dbGetQuery(con, web_sql("temporal_mig_sel",
2214
+ yr_expr = yr_expr_ts, poly_wkt = filter_wkt)), error = function(e) NULL)
2215
+ } else NULL
2216
+
2217
+ COL_LINE <- "#b45309"
2218
+ p <- ggplot(df_all, aes(x = yr, y = n)) +
2219
+ geom_area(fill = COL_LINE, alpha = 0.08, color = NA) +
2220
+ geom_line(color = COL_LINE, linewidth = 0.7, alpha = 0.45)
2221
+ if (!is.null(df_sel) && nrow(df_sel) > 0) {
2222
+ p <- p +
2223
+ geom_line(data = df_sel, aes(x = yr, y = n),
2224
+ color = COL_LINE, linewidth = 1.1) +
2225
+ geom_point(data = df_sel, aes(x = yr, y = n),
2226
+ color = COL_LINE, size = 1.6, shape = 21,
2227
+ fill = "white", stroke = 0.7)
2228
+ }
2229
+ p + theme_minimal(base_size = 8) +
2230
+ theme(
2231
+ plot.background = element_rect(fill = "transparent", color = NA),
2232
+ panel.background = element_rect(fill = "transparent", color = NA),
2233
+ panel.grid = element_blank(),
2234
+ panel.grid.major.y = element_line(color = "#e2e8f0", linewidth = 0.3),
2235
+ axis.title = element_blank(),
2236
+ axis.text.x = element_text(size = 7, color = "#64748b"),
2237
+ axis.text.y = element_blank(),
2238
+ axis.ticks = element_blank(),
2239
+ plot.margin = margin(1, 4, 1, 4)
2240
+ )
2241
+ }, bg = "transparent", res = 96)
2242
+
2243
+ # Shared helper: temporal bar chart by year for point event tables
2244
+ make_temporal_yr <- function(tbl, yr_col, line_color) {
2245
+ renderPlot({
2246
+ req(con)
2247
+ yr_expr <- glue("CAST({yr_col} AS INTEGER)")
2248
+ df_all <- tryCatch(DBI::dbGetQuery(con, web_sql("temporal_yr_all",
2249
+ yr_expr = yr_expr, tbl = tbl, yr_col = yr_col)),
2250
+ error = function(e) NULL)
2251
+ if (is.null(df_all) || nrow(df_all) == 0) return(NULL)
2252
+ fw <- rv$temporal_filter_wkt
2253
+ df_sel <- if (!is.null(fw)) tryCatch(DBI::dbGetQuery(con, web_sql("temporal_yr_sel",
2254
+ yr_expr = yr_expr, tbl = tbl, yr_col = yr_col, poly_wkt = fw)),
2255
+ error = function(e) NULL) else NULL
2256
+ p <- ggplot(df_all, aes(x = yr, y = n)) +
2257
+ geom_area(fill = line_color, alpha = 0.08, color = NA) +
2258
+ geom_line(color = line_color, linewidth = 0.7, alpha = 0.45)
2259
+ if (!is.null(df_sel) && nrow(df_sel) > 0)
2260
+ p <- p +
2261
+ geom_line(data = df_sel, aes(x = yr, y = n),
2262
+ color = line_color, linewidth = 1.1) +
2263
+ geom_point(data = df_sel, aes(x = yr, y = n),
2264
+ color = line_color, size = 1.6, shape = 21,
2265
+ fill = "white", stroke = 0.7)
2266
+ p + theme_minimal(base_size = 8) +
2267
+ theme(
2268
+ plot.background = element_rect(fill = "transparent", color = NA),
2269
+ panel.background = element_rect(fill = "transparent", color = NA),
2270
+ panel.grid = element_blank(),
2271
+ panel.grid.major.y = element_line(color = "#e2e8f0", linewidth = 0.3),
2272
+ axis.title = element_blank(),
2273
+ axis.text.x = element_text(size = 7, color = "#64748b"),
2274
+ axis.text.y = element_blank(),
2275
+ axis.ticks = element_blank(),
2276
+ plot.margin = margin(1, 4, 1, 4)
2277
+ )
2278
+ }, bg = "transparent", res = 96)
2279
+ }
2280
+
2281
+ output$temporal_ocved <- make_temporal_yr("ocved_events", "year", "#d97706")
2282
+ output$temporal_ged <- make_temporal_yr("ged_events", "year", "#2563eb")
2283
+
2284
+ output$dl_xlsx <- downloadHandler(
2285
+ filename = function()
2286
+ glue("depth_selection_{format(Sys.time(), '%Y%m%d_%H%M%S')}.xlsx"),
2287
+ content = function(file) {
2288
+ if (is.null(rv$dl_list)) {
2289
+ writexl::write_xlsx(list(note = data.frame(note = "No selection made.")),
2290
+ path = file)
2291
+ return()
2292
+ }
2293
+ dl <- rv$dl_list
2294
+ keep <- list(
2295
+ migrants = dl$migrants,
2296
+ graves = dl$graves,
2297
+ support_infra = dl$infra,
2298
+ violence_ocved = dl$ocved,
2299
+ conflict_ged = dl$ged,
2300
+ municipalities_crime = dl$mun
2301
+ )
2302
+ sheets <- Filter(function(x) !is.null(x) && nrow(x) > 0, keep)
2303
+ if (length(sheets) == 0)
2304
+ sheets <- list(note = data.frame(note = "No features in selection."))
2305
+ writexl::write_xlsx(sheets, path = file)
2306
+ }
2307
+ )
2308
+ }
2309
+
2310
+ # Extract mode: source() defines ui/server/WEB_SQL/WEB_SPEC without launching.
2311
+ # The docs converter (10.13_build_docs.R) sets this option, reads the objects.
2312
+ # Deployment edit (v10_HF): bind 0.0.0.0:$PORT so the HF Docker Space can
2313
+ # reach the app (HF expects the container to listen on app_port, default 7860).
2314
+ if (!isTRUE(getOption("depth.extract_mode", FALSE))) {
2315
+ shiny::runApp(
2316
+ shinyApp(ui, server),
2317
+ host = "0.0.0.0",
2318
+ port = as.integer(Sys.getenv("PORT", "7860"))
2319
+ )
2320
+ }
Dockerfile CHANGED
@@ -1,19 +1,32 @@
1
- FROM rocker/shiny:latest
 
 
2
 
3
- WORKDIR /code
 
 
 
 
4
 
5
- RUN apt-get update && apt-get install -y \
6
- build-essential \
7
- libcurl4-openssl-dev \
8
- libssl-dev \
9
- libxml2-dev
10
 
11
- RUN install2.r --error \
12
- dplyr \
13
- ggplot2 \
14
- readr \
15
- ggExtra
16
 
17
- COPY . .
 
 
 
 
18
 
19
- CMD ["R", "--quiet", "-e", "shiny::runApp('/code', host='0.0.0.0', port=7860)"]
 
 
 
 
 
 
 
 
1
+ # DEPTH Risk Map -- Hugging Face Docker Space
2
+ # Base ships R + sf/GDAL/GEOS/PROJ (the slowest deps to build).
3
+ FROM rocker/geospatial:4.4
4
 
5
+ # DuckDB lives on the edgarodriguez/depth_alpha dataset; baked in at build.
6
+ # Override with: docker build --build-arg DDB_URL=... -t depth-hf .
7
+ ARG DDB_URL=https://huggingface.co/datasets/edgarodriguez/depth_alpha/resolve/main/depth_mexico.duckdb
8
+ # For a PRIVATE dataset, also pass --build-arg HF_TOKEN=hf_xxx
9
+ ARG HF_TOKEN=
10
 
11
+ RUN install2.r --error --skipinstalled \
12
+ shiny bslib DBI duckdb dplyr jsonlite htmltools glue writexl ggplot2 stringr \
13
+ && R -e "install.packages('mapgl', repos = c('https://walkerke.r-universe.dev', 'https://cloud.r-project.org'))" \
14
+ && R -e "library(mapgl); library(duckdb); library(sf); library(shiny)"
 
15
 
16
+ WORKDIR /app
17
+ COPY 10.11_draw_query_indicators_v10_HF.R helpers.R 10.08_styles.css ./
 
 
 
18
 
19
+ # Bake the DuckDB into the image (build-time download).
20
+ RUN if [ -n "$HF_TOKEN" ]; then AUTH="Authorization: Bearer $HF_TOKEN"; else AUTH=""; fi \
21
+ && curl -fL ${AUTH:+-H "$AUTH"} "${DDB_URL}" -o /app/depth_mexico.duckdb \
22
+ && test -s /app/depth_mexico.duckdb \
23
+ && ls -lh /app/depth_mexico.duckdb
24
 
25
+ # HF Spaces runs the container as a non-root user -- make /app world-readable.
26
+ RUN chmod -R a+rX /app
27
+
28
+ ENV DEPTH_DDB_PATH=/app/depth_mexico.duckdb \
29
+ PORT=7860
30
+
31
+ EXPOSE 7860
32
+ CMD ["Rscript", "10.11_draw_query_indicators_v10_HF.R"]
README.md CHANGED
@@ -1,14 +1,40 @@
1
  ---
2
- title: Depth Map App
3
- emoji: 🌍
4
- colorFrom: gray
5
- colorTo: blue
6
  sdk: docker
 
7
  pinned: false
8
- license: cc-by-nc-4.0
9
- short_description: prototype for map
10
  ---
11
 
12
- EARLY-ALPHA-ALPHA VERSION FOR TRANSIT MIGRATION RISK ASSESSMENT
13
 
14
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: DEPTH Risk Map
3
+ emoji: πŸ—ΊοΈ
4
+ colorFrom: blue
5
+ colorTo: indigo
6
  sdk: docker
7
+ app_port: 7860
8
  pinned: false
 
 
9
  ---
10
 
11
+ # DEPTH Risk Map
12
 
13
+ Interactive migration-risk map for Mexico, developed by the University of
14
+ Nottingham's Rights Lab. Humanitarian, violence and infrastructure indicators
15
+ overlaid on a rHEALPix hierarchical spatial grid.
16
+
17
+ ## How this Space works
18
+
19
+ - `Dockerfile` builds an R image (`rocker/geospatial`), installs the app
20
+ packages, and downloads `depth_mexico.duckdb` from the
21
+ [`edgarodriguez/depth_alpha`](https://huggingface.co/datasets/edgarodriguez/depth_alpha)
22
+ dataset at build time (baked into the image).
23
+ - The Shiny app (`10.11_draw_query_indicators_v10_HF.R`) opens that DuckDB
24
+ read-only and serves on `0.0.0.0:7860`.
25
+
26
+ ## Files
27
+
28
+ | File | Role |
29
+ |---|---|
30
+ | `10.11_draw_query_indicators_v10_HF.R` | Shiny app (HF build of v9) |
31
+ | `helpers.R` | DuckDB connection + formatting helpers |
32
+ | `10.08_styles.css` | App stylesheet |
33
+ | `Dockerfile` | Image build + DuckDB bake-in |
34
+
35
+ ## Updating
36
+
37
+ The app database is not stored in this Space. To refresh data, update
38
+ `depth_mexico.duckdb` in the `edgarodriguez/depth_alpha` dataset and rebuild
39
+ (Factory rebuild). App-logic changes are made upstream in `v9`, then this file
40
+ is regenerated from it.
helpers.R ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ── Shared helpers ─────────────────────────────────────────────────────────────
2
+
3
+ library(sf)
4
+ library(dplyr)
5
+ library(stringr)
6
+
7
+ # Hierarchy codes from nivel8 via str_sub truncation (one char per level up)
8
+ add_nivel_codes <- function(sf_obj) {
9
+ st_join(sf_obj, nivel8_all |> select(codigo_n4:codigo_n8), join = st_within)
10
+ }
11
+
12
+ # Safe column name from category string
13
+ safe_cat <- function(x) {
14
+ x |>
15
+ tolower() |>
16
+ str_replace_all("[^a-z0-9]+", "_") |>
17
+ str_replace_all("^_|_$", "")
18
+ }
19
+
20
+ # Formatted number for UI display
21
+ fmt_n <- function(x, digits = 0) {
22
+ if (all(is.na(x))) return("β€”")
23
+ format(round(sum(x, na.rm = TRUE), digits), big.mark = ",", scientific = FALSE)
24
+ }
25
+
26
+ fmt_avg <- function(x, digits = 1) {
27
+ if (all(is.na(x))) return("β€”")
28
+ format(round(mean(x, na.rm = TRUE), digits), big.mark = ",", scientific = FALSE)
29
+ }
30
+
31
+ # DuckDB connection helper with spatial + httpfs extensions loaded.
32
+ # Uses duckdb(dbdir = path) so the driver object is created with the correct path
33
+ # upfront (avoids a class of GC-related "Invalid connection" errors).
34
+ # Tries LOAD first (no network); falls back to INSTALL + LOAD on version mismatch.
35
+ ddb_connect <- function(path = ":memory:", remote = FALSE, read_only = FALSE) {
36
+ drv <- duckdb::duckdb(dbdir = path, read_only = read_only)
37
+ # Keep driver in globalenv so R's GC never collects it before the connection
38
+ # is closed. parent.frame() is unreliable when called through source()/eval()
39
+ # chains (returns a temporary env that is GC'd immediately).
40
+ assign(".ddb_drv", drv, envir = globalenv())
41
+ con <- DBI::dbConnect(drv)
42
+ tryCatch(
43
+ DBI::dbExecute(con, "LOAD spatial"),
44
+ error = function(e) {
45
+ DBI::dbExecute(con, "INSTALL spatial")
46
+ DBI::dbExecute(con, "LOAD spatial")
47
+ }
48
+ )
49
+ if (remote) {
50
+ tryCatch(
51
+ DBI::dbExecute(con, "LOAD httpfs"),
52
+ error = function(e) {
53
+ DBI::dbExecute(con, "INSTALL httpfs")
54
+ DBI::dbExecute(con, "LOAD httpfs")
55
+ }
56
+ )
57
+ }
58
+ con
59
+ }
60
+
61
+ # Convert sf geometry column to WKB blob for DuckDB insert
62
+ sf_to_wkb_df <- function(sf_obj) {
63
+ wkt <- sf::st_as_text(sf::st_geometry(sf_obj))
64
+ df <- sf::st_drop_geometry(sf_obj)
65
+ df$geom_wkt <- wkt
66
+ df
67
+ }
68
+
69
+ # Read WKB blob column back to sf
70
+ # DuckDB returns ST_AsWKB as a list of raw vectors β€” st_as_sfc accepts it directly
71
+ wkb_to_sf <- function(df, geom_col = "geom", crs = 4326) {
72
+ geoms <- sf::st_as_sfc(df[[geom_col]], crs = crs)
73
+ df[[geom_col]] <- NULL
74
+ sf::st_as_sf(df, geometry = geoms)
75
+ }
76
+
77
+ # Stat display helper for Shiny summary panel
78
+ stat_row <- function(label, value) {
79
+ htmltools::tags$div(
80
+ class = "d-flex justify-content-between small py-1 border-bottom border-secondary",
81
+ htmltools::tags$span(class = "text-muted", label),
82
+ htmltools::tags$span(class = "fw-semibold", value)
83
+ )
84
+ }