-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathxsql-deforest-divisions.py
More file actions
2580 lines (2367 loc) · 111 KB
/
Copy pathxsql-deforest-divisions.py
File metadata and controls
2580 lines (2367 loc) · 111 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# /// script
# requires-python = ">=3.13"
# dependencies = [
# "marimo",
# "datafusion>=54.0.0",
# "xarray-sql>=0.3.2",
# "xarray",
# "h3ronpy>=0.22.0",
# "pyarrow>=25.0.0",
# "arro3-core",
# "geoarrow-rust-core",
# "obstore>=0.9.2",
# "async-geotiff>=0.4",
# "lonboard[geotiff]>=0.16.0",
# "anywidget>=0.9",
# "numpy==2.5.1",
# "duckdb>=1.5.5",
# "matplotlib==3.11.1",
# ]
# ///
"""Global deforestation 2002-2022, folded to H3 and joined onto Overture divisions.
Vizzuality's `deforest_100m_cog.tif` is one 5.7 GB COG covering the planet at 100 m. Its
value is the PORTION OF EACH CELL deforested between 2002 and 2022: an intensive 0-1
quantity. That single fact decides most of this notebook. A portion can be averaged at any
scale, so `mean()` is valid at every H3 resolution and the COG's averaged overview pyramid
is legitimate rather than a lie. No majority vote, no mode, no class fold.
WHAT EACH ENGINE DOES:
obstore streams the COG and the Overture divisions PMTiles, unsigned. Nothing is
cached to disk; a viewport reads what it needs and keeps it in memory.
DataFusion the fold (pixels -> H3 cells) AND the join (cells -> divisions). The join
is an integer equi-join on a UBIGINT cell id plus a group-by, which is what
a query engine is for.
DuckDB the polyfill (division polygon -> the cells covering it) and the tile-seam
dissolve (clipped pieces of one division -> one MultiPolygon). The two
geometry steps neither DataFusion nor plain SQL can do.
lonboard the render.
WHY PMTILES AND NOT THE GEOPARQUET. Overture lays the division_area files out with no
spatial order, so the geometry (99.0% of the bytes) cannot be pruned: a Rondonia-sized
viewport decodes ~190 MB per file to keep 6,337 rows, and no query makes that smaller.
The same release's divisions.pmtiles is the vector twin of the COG's overview pyramid:
one 19.5 GB object, addressed by ranged GET, Hilbert-ordered tiles, z0-12. The same
viewport reads ~0.8 MB. Tiles are gzipped MVT, decoded here with a hand-rolled protobuf
walk (verified ring-exact against mapbox-vector-tile) because the whole reader is fewer
lines than the dependency. Tile geometry is quantized to ~2.4 m at z12 and clipped to
tile edges; the polyfill is 'center'-ruled at res 4-8 (cells 460 m and up), so the cells
land identically, and the dissolve below removes the clip edges before anything is drawn.
WHY H3 IS NOT JUST A DEMO STEP. The COG is EPSG:4326, so its pixels are not equal area: a
100 m pixel at the equator covers about twice the ground of one at 60 degrees. Averaging
pixels directly over a country spanning many latitudes overweights its poleward end. H3
cells are near-equal-area, so folding to H3 and then averaging CELLS equally is an
area-weighted mean almost for free. Pixel count weights WITHIN a cell (a coastal cell is
mostly NaN ocean and should not count as a full one); cells are equal-weighted within a
division. Two weightings, each correcting a different bias.
THE COG IS SPARSE AND async-geotiff DOES NOT KNOW IT. 73.6% of full-resolution tiles have
offset 0 and length 0, because ocean is not stored, and a read touching one issues a byte
range 0..0 and raises `Invalid range requested, start: 0 end: 0`. Reading on the COG's own
512 px tile grid and consulting `ifd.tile_byte_counts` first turns that from a crash into
a speedup: an empty tile is NaN with no request at all.
COLOUR. 69.6% of res-4 cells are exactly zero and the nonzero values span nine orders of
magnitude (p1 7.3e-8, p50 2.1e-3, p99.9 0.45), so a linear 0-1 ramp paints a blank world.
Zero takes its own dark swatch and the rest is log10 over 1e-4 to 0.5, which is p25 to
p99.9. See the ramp cell for why zero is separated by luminance and not by hue.
THE PAINT IS THE RASTER ITSELF (2026-08-14, not yet flown interactively). A lonboard
RasterLayer serves the COG's own pyramid as PNG tiles coloured by the same ramp, so the
map shows pixels rather than cell means; the H3 hexagon layer is commented out in the map
cell, not deleted. The fold is untouched, because the divisions join and the ranking eat
its cells. Zero and NaN pixels are both transparent, matching the fold's
`HAVING avg(v) > 0`. The layer is built directly rather than via
RasterLayer.from_geotiff, for two reasons recorded at the construction site: from_geotiff's
fetch is not sparse-aware, and its zoom clamp ships commented out.
PRESS THE BUTTON AND THE JOIN BECOMES A NUMBER. "rank what's in view", in the controls
under the map, ranks every division in the current view by its mean share deforested. It
reads one H3 resolution finer than the screen does, sizes that resolution from the view
box rather than the current zoom, and falls back county -> region -> country, because
Overture has counties for only 171 of 219 countries. This is the one output here that is
a figure rather than a colour. It replaced lonboard's draw-box tool, which asked the
user to describe a region twice (camera, then rectangle); the toolbar for that tool is
hidden from the Controls widget, since lonboard 0.16 has no Python-side switch for it.
THE CAMERA ANSWERS FROM MEMORY FIRST. `view_state` fires on every frame of a drag, and any
frame that can be served from what is already folded (a pan inside the current box, a zoom
back to a resolution already visited) is answered synchronously in the comm handler. Only a
view that genuinely needs bytes goes through the debounce. See `_instant`.
Data: Vizzuality / LandGriffon, CC-BY 4.0, on source.coop. Boundaries: Overture Maps.
Run: uv run marimo edit xsql-deforest-divisions.py --sandbox
"""
import marimo
__generated_with = "0.24.0"
app = marimo.App(width="full")
@app.cell
def _():
import asyncio
import gzip
import io
import math
import struct
import anywidget
import traitlets
import marimo as mo
import matplotlib
matplotlib.use("Agg") # no GUI backend in a kernel
import duckdb
import numpy as np
import obstore
import pyarrow as pa
import xarray as xr
from arro3.core import Array as ArroArray, Table as ArroTable
from async_geotiff import GeoTIFF, Window
from async_geotiff.tms import generate_tms
from datafusion import udf
from geoarrow.rust.core import from_wkb, multipolygon
from h3ronpy.vector import coordinates_to_cells
from obstore.store import S3Store
from xarray_sql import XarrayContext
from matplotlib import image as mpl_image
from lonboard import Map, H3HexagonLayer, PolygonLayer, BitmapTileLayer
from lonboard import RasterLayer
from lonboard.raster import EncodedImage
from lonboard.basemap import CartoBasemap, MaplibreBasemap
from lonboard._geoarrow.ops import Bbox
from lonboard._serialization import infer_rows_per_chunk
return (
ArroArray,
ArroTable,
Bbox,
BitmapTileLayer,
CartoBasemap,
EncodedImage,
GeoTIFF,
H3HexagonLayer,
Map,
MaplibreBasemap,
PolygonLayer,
RasterLayer,
S3Store,
Window,
XarrayContext,
anywidget,
asyncio,
coordinates_to_cells,
duckdb,
from_wkb,
generate_tms,
gzip,
infer_rows_per_chunk,
io,
math,
matplotlib,
mo,
mpl_image,
multipolygon,
np,
obstore,
pa,
struct,
traitlets,
udf,
xr,
)
@app.cell
def _(duckdb):
# ONE JOB: polygon -> H3 cells. Everything else that could plausibly live here does not.
#
# The fold and the join are DataFusion's. The fold because it is a whole-column
# operation, where h3ronpy converts a column at once and DuckDB would call a UDF per
# row: 70 ms against 462 ms on 1.58M rows, measured in xsql-duckdb-nlcd-h3.py. The join
# because it is an ordinary equi-join on an integer, with no geometry in sight.
#
# The polyfill is the opposite regime, which is why it is here. There are 219 countries
# or a few thousand counties, so per-row call overhead is irrelevant and the work is all
# inside the H3 library: the regime where Uber's C won the dissolve comparison, 75 ms
# against h3ronpy's 2,784 ms.
#
# Extensions download once into ~/.duckdb and are cached after that.
con = duckdb.connect()
con.execute("INSTALL h3 FROM community; LOAD h3; INSTALL spatial; LOAD spatial;")
return (con,)
@app.cell
def _(anywidget, traitlets):
class Status(anywidget.AnyWidget):
"""A one-line status readout the camera can write to, and the viewport ruler.
A widget rather than `mo.md`, because the only way to update marimo output is to
re-run the cell that produced it, and the cell holding the map is downstream of any
state the camera could write: re-running it rebuilds the Map and throws the view
away. A widget trait syncs straight to the browser instead.
THE RULER, AND WHY IT LIVES HERE. lonboard's view_state carries longitude,
latitude and zoom but NOT the canvas size, so the kernel cannot know how much
world the screen shows: VIEW_W/VIEW_H were assumed, and going fullscreen made
that assumption visibly wrong (cells folded for a 620 px band inside a 1400 px
screen). This widget is always mounted just below the map, and every widget
shares the page document, so it finds the deck canvas (the largest canvas on the
page), measures its CSS size, and syncs it up as `view_wh`. Remeasured on window
resize, on fullscreenchange (fullscreening an ELEMENT resizes no window, so a
resize listener alone misses it), and via a ResizeObserver on the canvas itself
for layout changes that are neither. Ported from the HFP notebook, where the
fullscreen defect was found and the trait-type and shadow-DOM lessons were paid
for.
"""
_esm = """
function render({ model, el }) {
const line = document.createElement("div");
line.style.cssText =
"font:12.5px ui-monospace,SFMono-Regular,Menlo,monospace;" +
"opacity:.85;padding:.15rem 0;min-height:1.2em";
// The browser's OWN reading, drawn from JS with no kernel round trip. When
// the ruler works, this matches the px readout in the kernel's line above it;
// when it does not, whichever half is missing names the broken leg. An error
// in the measuring code lands here too instead of vanishing.
const probe = document.createElement("div");
probe.style.cssText =
"font:10px ui-monospace,SFMono-Regular,Menlo,monospace;opacity:.4";
const draw = () => { line.innerHTML = model.get("value"); };
draw();
model.on("change:value", draw);
el.appendChild(line);
// The diagnostic line, off by default. Everything still measures and syncs;
// this only decides whether the browser-side reading is SHOWN.
el.appendChild(probe);
let watched = null;
const ro = new ResizeObserver(() => kick());
// marimo puts cell output inside shadow DOM, and document.querySelectorAll
// does not pierce shadow roots: the deck canvas is on screen and invisible to
// a plain query (measured: "no canvas found" while the map was clearly
// there). So the search walks INTO every shadowRoot it passes.
const collect = (root, out) => {
root.querySelectorAll("canvas").forEach((c) => out.push(c));
root.querySelectorAll("*").forEach((n) => {
if (n.shadowRoot) collect(n.shadowRoot, out);
});
};
const send = () => {
try {
let best = null, area = 0;
const found = [];
collect(document, found);
found.forEach((c) => {
const a = c.clientWidth * c.clientHeight;
if (a > area) { area = a; best = c; }
});
let w, h, tag;
if (best) {
w = best.clientWidth; h = best.clientHeight; tag = "ruler ";
if (best !== watched) {
if (watched) ro.unobserve(watched);
ro.observe(best);
watched = best;
}
} else {
// No canvas even through the shadow roots: fall back to the window,
// which OVERSTATES the map and costs a larger read, the cheap
// direction to be wrong in. A band on screen is the expensive one.
w = window.innerWidth; h = window.innerHeight; tag = "ruler window ";
}
if (w > 0 && h > 0) {
probe.textContent = tag + w + "x" + h;
// A string, not a number list: the only trait types this notebook has
// PROVEN to cross marimo's anywidget bridge are Unicode (value, down)
// and Bool (the Controls, up). The first ruler used List(Float) and
// the kernel never heard a word.
model.set("view_wh", w + "x" + h);
model.save_changes();
}
} catch (err) {
probe.textContent = "ruler error: " + err;
}
};
let t = null;
const kick = () => { clearTimeout(t); t = setTimeout(send, 250); };
window.addEventListener("resize", kick);
document.addEventListener("fullscreenchange", kick);
setTimeout(send, 500);
}
export default { render };
"""
value = traitlets.Unicode("").tag(sync=True)
view_wh = traitlets.Unicode("").tag(sync=True)
class Controls(anywidget.AnyWidget):
"""Layer switches, under the map next to the legend.
Same constraint as Status: an `mo.ui.checkbox` would make the map cell depend on it,
so every click would rebuild the Map and reset the camera. A widget trait syncs to
the kernel, a Python observer assigns onto the deck layers, and nothing re-runs.
"""
_esm = """
function render({ model, el }) {
const box = document.createElement("div");
box.style.cssText =
"display:flex;flex-wrap:wrap;align-items:center;gap:.9rem;" +
"font:12px ui-sans-serif,system-ui,sans-serif;padding:.2rem 0 0;" +
"user-select:none";
const check = (key, label) => {
const l = document.createElement("label");
l.style.cssText =
"display:inline-flex;align-items:center;gap:.35rem;cursor:pointer";
const c = document.createElement("input");
c.type = "checkbox";
c.checked = model.get(key);
c.onchange = () => { model.set(key, c.checked); model.save_changes(); };
model.on("change:" + key, () => { c.checked = model.get(key); });
l.appendChild(c);
l.appendChild(document.createTextNode(label));
box.appendChild(l);
};
check("show_cells", "deforestation");
check("show_divisions", "boundaries");
check("division_fill", "boundary fill");
// The ranking trigger, HERE rather than lonboard's draw-box tool. A Bool
// toggle, not a counter: Bool is a trait type proven to cross marimo's
// anywidget bridge browser -> kernel, and the kernel observer fires on any
// change, so flipping the value is a click.
const btn = document.createElement("button");
btn.textContent = "rank what's in view";
btn.style.cssText =
"font:12px ui-sans-serif,system-ui,sans-serif;cursor:pointer;" +
"padding:.15rem .6rem;border-radius:4px;border:1px solid " +
"rgba(127,127,127,.45);background:transparent;color:inherit";
btn.onclick = () => {
model.set("rank_view", !model.get("rank_view"));
model.save_changes();
};
box.appendChild(btn);
// BOUNDARY FILL OPACITY. A stepped slider (0.1-1.0 by 0.1) plus a free
// number box (any 0-1 float); both write the same Unicode trait, because
// Unicode is proven to cross marimo's bridge browser -> kernel (the Status
// ruler's "WxH" string). Commit on 'change', not 'input': every commit
// re-tints and re-pushes the divisions table, and Safari/Firefox fire
// 'change' DURING a drag anyway (terrain notebook lesson), so the 0.1
// steps are the real rate limiter.
const ow = document.createElement("span");
ow.style.cssText =
"display:inline-flex;align-items:center;gap:.35rem;opacity:.9";
ow.appendChild(document.createTextNode("fill opacity"));
const sl = document.createElement("input");
sl.type = "range";
sl.min = "0.1"; sl.max = "1"; sl.step = "0.1";
sl.style.width = "6rem";
const nb = document.createElement("input");
nb.type = "number";
// step 0.1 so the spinner arrows move in tenths (Stephen's spec); typed
// values are still any float, the step only drives the up/down buttons.
nb.min = "0"; nb.max = "1"; nb.step = "0.1";
nb.style.cssText =
"width:3.6rem;font:inherit;background:transparent;color:inherit;" +
"border:1px solid rgba(127,127,127,.45);border-radius:4px;" +
"padding:0 .2rem";
const seed0 = parseFloat(model.get("fill_alpha"));
sl.value = nb.value = String(Number.isNaN(seed0) ? 0.65 : seed0);
const commit = (v) => {
v = Math.min(1, Math.max(0, v));
sl.value = String(v);
nb.value = String(v);
model.set("fill_alpha", String(v));
model.save_changes();
};
sl.onchange = () => commit(parseFloat(sl.value));
nb.onchange = () => {
const v = parseFloat(nb.value);
if (!Number.isNaN(v)) commit(v);
};
ow.appendChild(sl);
ow.appendChild(nb);
box.appendChild(ow);
el.appendChild(box);
// HIDE LONBOARD'S DRAW-BOX TOOL. Its toolbar is rendered unconditionally in
// the bundled JS (lonboard 0.16): the Map's `controls` trait governs only
// fullscreen/navigation/scale, so there is no Python-side switch. The button
// lives in lonboard's shadow root, hence the same recurse-into-shadowRoots
// walk the Status ruler uses; an interval rather than a one-shot because the
// map mounts after this widget and can be rebuilt by a cell re-run.
const hideBbox = (root) => {
let hid = false;
root.querySelectorAll("button[aria-label]").forEach((b) => {
const a = b.getAttribute("aria-label");
if (a === "Select BBox" || a === "Cancel drawing" ||
a === "Clear bounding box") {
const holder = b.closest("div[style*='absolute']") || b;
holder.style.display = "none";
hid = true;
}
});
root.querySelectorAll("*").forEach((n) => {
if (n.shadowRoot) hid = hideBbox(n.shadowRoot) || hid;
});
return hid;
};
setInterval(() => hideBbox(document), 1000);
}
export default { render };
"""
show_cells = traitlets.Bool(True).tag(sync=True)
show_divisions = traitlets.Bool(True).tag(sync=True)
# ON BY DEFAULT. The join onto Overture is the whole point of this notebook and the
# choropleth is what it produces, so shipping it behind an unticked box meant the
# result was invisible unless you went looking for it.
division_fill = traitlets.Bool(True).tag(sync=True)
# Boundary fill opacity as a 0-1 float IN A STRING, per the proven-trait-types
# rule (Unicode crosses the bridge both ways; numeric traits never made it).
# "0.65" matches HOLD["fill_alpha"]'s 165 seed.
fill_alpha = traitlets.Unicode("0.65").tag(sync=True)
# The ranking trigger. Value is meaningless; a CHANGE is a click.
rank_view = traitlets.Bool(False).tag(sync=True)
class Panel(anywidget.AnyWidget):
"""A block of HTML the kernel can rewrite, for the drawn-box ranking.
Status is a one-line strip and this is a table, but the reason for both is the same:
marimo output only updates by re-running the cell that made it, and the cell that
made this one owns the Map.
"""
_esm = """
function render({ model, el }) {
const box = document.createElement("div");
const draw = () => { box.innerHTML = model.get("value"); };
draw();
model.on("change:value", draw);
el.appendChild(box);
}
export default { render };
"""
value = traitlets.Unicode("").tag(sync=True)
return Controls, Panel, Status
@app.cell
def _(math):
# ------------------------------------------------------------------ the raster
SOURCE_BUCKET = "us-west-2.opendata.source.coop"
COG = "vizzuality/lg-land-carbon-data/deforest_100m_cog.tif"
# The COG's own tile size, at every level. Reading on this grid is what makes a read
# shareable between viewports AND what lets the sparse-tile check work, since a tile is
# the unit that is either present or absent.
TILE = 512
FETCH_AT_ONCE = 32 # tiles are only faster than one ranged read if they fly together
TILE_BUDGET = 256 * 1024 * 1024 # float32, so ~1,000 tiles resident
# WHICH OVERVIEW EACH H3 RESOLUTION READS. The pyramid is 100 m native and doubles ten
# times: L0 100 m, L1 200, L2 400, L3 800, L4 1.6 km, L5 3.2, L6 6.4, L7 12.8, L8 25.6,
# L9 51, L10 102.
#
# Chosen so 20-80 pixels sit under every cell: enough for a mean to mean something,
# without reading pixels the cell will only average away.
# res 4 (1,770 km2) / L6 (40.7 km2) = 43 px
# res 5 ( 253 km2) / L5 (10.2 km2) = 25 px
# res 6 ( 36.1 km2) / L3 (0.64 km2) = 56 px
# res 7 ( 5.16 km2) / L2 (0.16 km2) = 32 px
# res 8 (0.737 km2) / L1 (0.04 km2) = 18 px
#
# Reading an overview is only equivalent to reading pixels if the pyramid AVERAGES, and
# that was verified rather than assumed: over one 1-degree box the mean survives a 64x
# downsample (0.2260 -> 0.2342) while the max collapses (1.0 -> 0.65) and the
# exact-zero fraction goes 62% -> 0%. That is the signature of average resampling.
LEVEL_FOR_RES = {4: 6, 5: 5, 6: 3, 7: 2, 8: 1}
# ------------------------------------------------------------------ the zoom ladder
# One H3 resolution per 1.4 zoom levels, because each H3 step is 2.65x linear and
# log2(2.65) = 1.4. That keeps a hexagon a constant size ON SCREEN.
#
# math.floor, NOT int(): int truncates toward zero, so every zoom below ZOOM0 would
# collapse onto BASE_RES instead of continuing down to MIN_RES.
ZOOM0, PER_RES, BASE_RES = 4.0, 1.4, 4
MIN_RES, MAX_RES = 4, 8
def res_for_zoom(z):
return max(MIN_RES, min(MAX_RES, BASE_RES + math.floor((z - ZOOM0) / PER_RES)))
# RES 4 COMFORTABLY DRAWS THE WHOLE PLANET: H3 res 4 is 288,122 cells globally, of
# which ~224k carry data. Measured world fold at res 4: 15.7M pixels read in 821 ms
# (68 tiles fetched, 10 skipped as sparse), folded in 282 ms.
# WHICH DIVISION LEVEL IS DRAWN AT WHICH ZOOM, AND WHY THERE IS NONE AT THE TOP.
#
# Below DIV_ZOOM there are no boundaries at all: the hexagons carry the map alone.
# Under GeoParquet that was forced (a world view of countries meant reading most of
# 5.5 GB to find 219 rows); under PMTiles a world of countries is 16 tiles at z2 and
# the constraint is gone. The band is kept as a design choice: at the opening zoom
# the map is about where deforestation IS, and country outlines over it answer a
# question nobody has asked yet. Lower DIV_ZOOM if that reading changes.
#
# Overture has counties for 171 of 219 countries, so the county band is genuinely empty
# in places rather than merely sparse.
DIV_ZOOM = 4.5
# TODO: a fourth band, `locality`, above roughly zoom 9.5. The tileset carries
# localities from z10, so under PMTiles the cost question is already answered; what
# remains is the meaning question. A locality boundary is a settlement, so most of a
# drawn box would fall outside every polygon and the ranking would describe the towns
# rather than the ground. Decide that before adding the band.
def division_for_zoom(z):
if z < DIV_ZOOM:
return None
if z < 7.0:
return "region"
return "county"
DIVISION_LABEL = {"country": "countries", "region": "regions", "county": "counties"}
# ------------------------------------------------------------------ boundaries
# Overture's own PMTiles build of the same release the GeoParquet path used to read.
# One object, anonymous ranged GETs, MVT tiles z0-12.
OVERTURE_RELEASE = "2026-07-22.0"
PM_BUCKET = "overturemaps-extras-us-west-2"
PM_PATH = f"tiles/{OVERTURE_RELEASE}/divisions.pmtiles"
# The tile zoom at which each subtype FIRST appears in this tileset. Measured off the
# tiles themselves (probe: Rondonia, Iowa, Congo, z2-z10), not documented anywhere:
# Planetiler's minzoom rules are baked into the build. Every subtype persists from its
# floor up to z12, so these are floors for the zoom picker, not bands.
SUB_MINZOOM = {"country": 2, "region": 4, "county": 8}
# ------------------------------------------------------------------ view
# VIEW_W/VIEW_H and HOME moved INTO the map cell (2026-08-14, the cell split): a
# constants edit must never re-run the map cell, because destroying the Map kills
# deck's earcut pool. See the map cell.
PAD = 1.25
# SETTLE ONLY GUARDS A READ. Every camera event that can be answered from memory (a pan
# inside the box already folded, a zoom back to a resolution already visited) is now
# answered synchronously in the comm handler, so this delay is never spent on a view the
# notebook already knows the answer to. It exists purely so a two-second drag issues one
# object-store read at the end instead of a hundred along the way.
SETTLE = 0.15
# The fill alpha moved to HOLD["fill_alpha"] (2026-08-14): the Controls slider writes
# it, and it must live somewhere no cell re-run can reset, which is HOLD's whole job.
# The stroke alpha. Higher than the fill so the boundary still reads when the fill is
# toggled off; the RGB underneath is the same ramp either way.
LINE_ALPHA = 205
return (
COG,
DIVISION_LABEL,
FETCH_AT_ONCE,
LEVEL_FOR_RES,
LINE_ALPHA,
MAX_RES,
PAD,
PM_BUCKET,
PM_PATH,
SETTLE,
SOURCE_BUCKET,
SUB_MINZOOM,
TILE,
TILE_BUDGET,
division_for_zoom,
res_for_zoom,
)
@app.cell
def _(matplotlib, np):
# THE LOG RAMP.
#
# 69.6% of res-4 cells are exactly zero and the nonzero part spans nine orders of
# magnitude, so a linear 0-1 ramp is a blank map. LO..HI is p25..p99.9 of the nonzero
# values, which spends the whole ramp on the part of the data that varies.
LO, HI = 1e-4, 0.5
# THE ZERO SWATCH IS SEPARATED BY LUMINANCE, NOT HUE, AND THAT IS FORCED.
#
# The obvious flat neutral grey (78, 80, 84) lands at luminance 0.313, and the 0.1%
# stop of full-range cividis lands at 0.318. Measured, not guessed: "none" and "0.1%"
# came out the same colour, which is the worst thing this legend could do given zero is
# the majority case. Hue cannot fix it, because the entire point of cividis is that hue
# carries no information. So the ramp's floor is LIFTED off the bottom of cividis and
# zero takes the dark end alone.
#
# FLOOR = 0.25 truncates cividis to its upper 75%, putting the ramp's darkest colour at
# luminance 0.305 against the zero swatch's 0.156. Nothing is lost: the ramp still
# spans 0.305 -> 0.874, more luminance range than most sequential maps get.
#
# cividis rather than viridis: both are colourblind-safe, but cividis is built for it.
# It is strictly two-hue (blue -> yellow) and monotonic in luminance, and a deuteranope
# simulation of these exact stops is monotonic too, so the ORDER survives, which is the
# only thing a sequential ramp has to promise.
FLOOR = 0.25
ZERO_RGB = (38, 40, 44)
_CIVIDIS = matplotlib.colormaps["cividis"]
def ramp(v):
"""portion -> uint8 RGB, with exact zero (and NaN) taking the dark swatch."""
v = np.asarray(v, dtype="float64")
live = np.isfinite(v) & (v > 0)
t = np.zeros(v.shape)
if live.any():
t[live] = (np.log10(np.clip(v[live], LO, HI)) - np.log10(LO)) / (
np.log10(HI) - np.log10(LO)
)
out = (_CIVIDIS(FLOOR + t * (1 - FLOOR))[..., :3] * 255).astype(np.uint8)
out[~live] = ZERO_RGB
return out
def ramp_rgba(v, alpha):
"""`ramp` with a constant alpha appended, as uint8 RGBA.
The division fill needs four channels and the hexagons need three, and they must
agree colour for colour: a division and the cells inside it are the same number
drawn twice, so any drift between the two ramps would read as a disagreement in the
data.
"""
rgb = ramp(v)
out = np.empty(rgb.shape[:-1] + (4,), dtype=np.uint8)
out[..., :3] = rgb
out[..., 3] = alpha
return out
# 1e-4 is the ramp floor, so anything under it is "below 0.01%", not zero.
STOPS = [
(0.0, "none"),
(1e-4, "0.01%"),
(1e-3, "0.1%"),
(1e-2, "1%"),
(5e-2, "5%"),
(1e-1, "10%"),
(2.5e-1, "25%"),
(5e-1, "50%+"),
]
return STOPS, ramp, ramp_rgba
@app.cell
def _():
# Callback memory. NOT mo.state: writing mo.state from a camera observer re-runs every
# downstream cell, including the one that owns the Map, so the Map would be rebuilt with
# its opening view_state and the camera would snap home on every pan. A plain dict is
# invisible to the dataflow graph.
HOLD = {
"wh": (1400.0, 620.0), # the real canvas size, measured by Status; this is the seed
# Boundary fill alpha, 0-255. The Controls slider/number box writes it (as a 0-1
# float, converted in _on_controls); divisions_to_layer reads it for every new
# pair. 165 is the old FILL_ALPHA constant: not 255, so the deforestation paint
# stays legible underneath and the fill reads as a wash rather than a lid.
"fill_alpha": 165,
"fold": None, # the SQL fold, set by the read cell
"zonal": None, # cells -> division means, set by the read cell
"rank": None, # view box -> divisions ranked, set by the read cell
"res": None, # H3 resolution currently on screen
"box": None, # padded degree box the current cells cover
"div": None, # division subtype currently on screen
# The box the DIVISIONS cover, tracked apart from the cells' box because they are
# fetched second and a camera move can land between the two. Without it, a pan that
# interrupts a fold leaves `div` set while the boundaries on screen belong to the
# previous place, and the instant path then matches and never refetches them.
"divbox": None,
"cache": {}, # res -> [box, layer table, raw fold]
"divpair": None, # (fill-on table, fill-off table) currently on the division layer
# The status line in two halves, so a camera move that reads nothing can still
# refresh the zoom readout without throwing away what the last read said. Zooming IN
# always lands inside the box the last read covered, so without this the numbers
# would freeze exactly when the map feels least responsive.
"head": "", # what the cells are
"tail": "", # what the divisions are
"vs": None, # the last camera acted on, for the echo check
"busy": False,
"pending": None,
"loop": None,
"task": None,
"seltask": None, # the drawn-box ranking, which runs on its own
}
return (HOLD,)
@app.cell
def _(
ArroArray,
ArroTable,
HOLD,
LINE_ALPHA,
coordinates_to_cells,
np,
pa,
ramp,
ramp_rgba,
):
def cells_to_layer(tbl):
"""Folded cells -> the arro3 table the H3HexagonLayer draws.
combine_chunks because DataFusion returns many chunks while the numpy-derived
colour column is one, and lonboard rejects a table whose columns disagree about
chunking. ArroTable rather than pyarrow because the layer's `table` trait coerces
in __init__ but its validate() is a strict isinstance check, so assigning
afterwards needs the real type.
"""
tbl = tbl.combine_chunks()
portion = np.asarray(tbl["portion"])
return ArroTable.from_arrow(
pa.table(
{
"hex": tbl["hex"],
"color": pa.FixedSizeListArray.from_arrays(
pa.array(ramp(portion).ravel()), 3
),
# Percent, because "0.043 of a cell" is not how anyone reads this, and
# the tooltip is the one place the number is stated outright.
"deforested %": pa.array(np.round(portion * 100, 4)),
"pixels": tbl["px_total"],
}
)
)
def divisions_to_layer(tbl, from_wkb, multipolygon):
"""Division zonal means -> the TWO tables the PolygonLayer swaps between.
Two tables, identical except for the alpha in `color`, and that is the fix for the
dead "boundary fill" checkbox. `filled` is left permanently True (flipping it does
not reliably build the fill sublayer, per CLAUDE.md), but the previous attempt then
swapped `get_fill_color` between a TABLE COLUMN and the constant `[0, 0, 0, 0]`, and
that swap is what never took: deck was being handed two different KINDS of accessor
for one prop, and the layer only ever picked up whichever it saw first. Here both
states are the same column of the same schema, and the toggle re-pushes the table,
which is the one update path this layer has always honoured.
Geometry comes straight off the Arrow column via from_wkb: to_pylist() here would
materialise every polygon as a Python bytes object on the way past, and it is built
once and shared by both tables.
"""
tbl = tbl.combine_chunks()
portion = np.asarray(tbl["portion"], dtype="float64")
geom = ArroArray.from_arrow(
from_wkb(
tbl["wkb"].combine_chunks(), to_type=multipolygon("xy", crs="EPSG:4326")
)
)
rest = [
ArroArray.from_arrow(tbl["name"].combine_chunks()),
ArroArray.from_arrow(tbl["region"].combine_chunks()),
ArroArray.from_arrow(tbl["country"].combine_chunks()),
ArroArray.from_arrow(pa.array(np.round(portion * 100, 4))),
ArroArray.from_arrow(tbl["n_cells"].combine_chunks()),
]
names = [
"geometry",
"color",
"line",
"name",
"region",
"country",
"deforested %",
"cells",
]
# The stroke takes the same ramp as the fill, but from its OWN column: the fill
# toggle works by swapping to a table whose `color` alpha is zero, and a line fed
# from that column would vanish with it. One line column, shared by both variants,
# at the stroke's own alpha.
line = ArroArray.from_arrow(
pa.FixedSizeListArray.from_arrays(
pa.array(ramp_rgba(portion, LINE_ALPHA).ravel()), 4
)
)
def build(alpha):
col = ArroArray.from_arrow(
pa.FixedSizeListArray.from_arrays(
pa.array(ramp_rgba(portion, alpha).ravel()), 4
)
)
return ArroTable.from_arrays([geom, col, line, *rest], names=names)
# HOLD["fill_alpha"], read at build time: a pair built after the slider moved
# carries the new alpha without any extra machinery. Pairs built BEFORE the move
# are re-tinted in place by _refill in the map cell.
return build(HOLD["fill_alpha"]), build(0)
def seed_cells():
"""One hexagon at null island so the Map has a valid table at build time.
This is what lets the Map cell depend on nothing, and therefore never wait for the
raster read. The first camera event replaces it.
"""
hexes = coordinates_to_cells(np.array([0.0]), np.array([0.0]), 4)
return ArroTable.from_arrow(
pa.table(
{
"hex": pa.array(hexes),
"color": pa.FixedSizeListArray.from_arrays(
pa.array(np.array([13, 17, 23], dtype=np.uint8)), 3
),
"deforested %": pa.array([0.0]),
"pixels": pa.array([0], type=pa.int64()),
}
)
)
def seed_divisions(from_wkb, multipolygon):
"""A one-row polygon table, so the PolygonLayer can be built before any join runs.
lonboard will not take `table=None`: the layer imports the Arrow C stream in
__init__ and raises "Expected object with __arrow_c_array__ ..." on anything else.
So the layer needs real geometry from the start, and it begins invisible until a
join produces some. The WKB is hand-built rather than borrowed from a geometry
library, because this notebook has no shapely and one degenerate square at null
island is not worth adding one for.
"""
import struct
# A REAL POLYGON, NOT A DEGENERATE ONE. The first version of this used a 1e-6
# degree square, and deck's earcut tessellator failed on it, which took down the
# ENTIRE update pass: deck initialises all layers in one batch, so one throw
# produced a cascade of "deck.gl: assertion failed" naming BitmapLayer and
# GeoArrowPolygonLayer, neither of which was at fault, followed by "Cannot schedule
# pool tasks after terminate()". An assertion naming a layer is weak evidence that
# the layer is the problem.
#
# 0.01 degrees, counter-clockwise, at null island: big enough to tessellate, far
# enough from anywhere this map opens to be invisible.
d = 0.01
ring = [(0.0, 0.0), (d, 0.0), (d, d), (0.0, d), (0.0, 0.0)]
wkb = struct.pack("<BII", 1, 6, 1) # little endian, MultiPolygon, 1 polygon
wkb += struct.pack("<BIII", 1, 3, 1, len(ring)) # Polygon, 1 ring, n points
for x, y in ring:
wkb += struct.pack("<dd", x, y)
geom = from_wkb(
pa.array([wkb], pa.binary()), to_type=multipolygon("xy", crs="EPSG:4326")
)
return ArroTable.from_arrays(
[
ArroArray.from_arrow(geom),
# Four channels, matching what divisions_to_layer produces. A seed whose
# colour column is three wide would make the first real push a change of
# accessor WIDTH as well as of data, which is the class of swap that left
# the fill unpainted before.
ArroArray.from_arrow(
pa.FixedSizeListArray.from_arrays(
pa.array(np.array([0, 0, 0, 0], dtype=np.uint8)), 4
)
),
ArroArray.from_arrow(
pa.FixedSizeListArray.from_arrays(
pa.array(np.array([0, 0, 0, 0], dtype=np.uint8)), 4
)
),
ArroArray.from_arrow(pa.array([""])),
ArroArray.from_arrow(pa.array([""])),
ArroArray.from_arrow(pa.array([""])),
ArroArray.from_arrow(pa.array([0.0])),
ArroArray.from_arrow(pa.array([0], type=pa.int64())),
],
names=[
"geometry",
"color",
"line",
"name",
"region",
"country",
"deforested %",
"cells",
],
)
return cells_to_layer, divisions_to_layer, seed_cells, seed_divisions
@app.cell
async def _(
PM_BUCKET,
PM_PATH,
S3Store,
SUB_MINZOOM,
asyncio,
con,
gzip,
math,
np,
obstore,
pa,
struct,
):
# DIVISIONS COME OUT OF ONE PMTILES OBJECT, BY RANGED GET. The GeoParquet path this
# replaces was measured to the floor first (see the notes doc): geometry is 99.0% of a
# row group's bytes, `subtype` statistics prune nothing, and client concurrency was
# not the bottleneck, so a Rondonia-sized viewport cost ~190 MB of decode per file and
# no query could make it smaller. The tileset is the same release with the layout
# problem solved upstream: Hilbert-ordered MVT tiles in one archive, so a viewport is
# a handful of contiguous ranges. The same viewport reads ~0.8 MB.
#
# The reader is the one from xsql-duckdb-terrain-h3.py (Mapterhorn), ported. That
# notebook is parked on looks; its PMTiles v3 client is the good part. Opening costs
# two reads (127-byte header, root directory), then one leaf directory per region
# touched, parsed once and cached.
_pm_store = S3Store(PM_BUCKET, region="us-west-2", skip_signature=True)
async def _pm_range(a, b):
"""Inclusive byte range [a, b]. obstore's `end` is exclusive."""
return bytes(
memoryview(
await obstore.get_range_async(_pm_store, PM_PATH, start=a, end=b + 1)
)
)
def _varint(buf, i):
r = s = 0
while True:
c = buf[i]
i += 1
r |= (c & 0x7F) << s
if not c & 0x80:
return r, i
s += 7
def _parse_dir(buf):
"""A PMTiles v3 directory: four varint columns, tile ids delta-encoded.
Entries are (tile_id, offset, length, run_length). run_length 0 marks a pointer
to a LEAF directory rather than to a tile. A zero OFFSET means "immediately after
the previous entry", so offsets are reconstructed in order, not read.
"""
n, i = _varint(buf, 0)
ids, last = [0] * n, 0
for k in range(n):
v, i = _varint(buf, i)
last += v
ids[k] = last
runs = [0] * n
for k in range(n):
runs[k], i = _varint(buf, i)
lens = [0] * n
for k in range(n):
lens[k], i = _varint(buf, i)
offs = [0] * n
for k in range(n):
v, i = _varint(buf, i)
offs[k] = (offs[k - 1] + lens[k - 1]) if v == 0 and k > 0 else v - 1
return list(zip(ids, offs, lens, runs))
def _tile_id(z, x, y):
"""z/x/y -> PMTiles v3 tile id: Hilbert order within a level, levels stacked.
Hilbert rather than row-major so tiles near each other on the GROUND are near
each other in the FILE, which is what makes a viewport a few contiguous ranges.
"""
acc = sum((1 << t) * (1 << t) for t in range(z))
n = 1 << z
d, s = 0, n >> 1
while s > 0:
rx = 1 if x & s else 0
ry = 1 if y & s else 0
d += s * s * ((3 * rx) ^ ry)
if ry == 0:
if rx == 1:
x, y = s - 1 - x, s - 1 - y
x, y = y, x
s >>= 1
return acc + d
def _find(entries, tid):
"""Binary search, falling back to the run that COVERS tid.
The fallback is not an optimisation: directories are run-length encoded, so a
tile usually has no entry of its own and is covered by an earlier one.
"""
lo, hi = 0, len(entries) - 1
while lo <= hi:
m = (lo + hi) // 2
if tid < entries[m][0]:
hi = m - 1
elif tid > entries[m][0]:
lo = m + 1
else:
return entries[m]
if hi >= 0 and (entries[hi][3] == 0 or tid - entries[hi][0] < entries[hi][3]):
return entries[hi]
return None
_hdr = await _pm_range(0, 126)
assert _hdr[:7] == b"PMTiles" and _hdr[7] == 3, "not a PMTiles v3 archive"