cover: absorb in one direction, and add a too-thin test #42

Merged
art merged 1 commit from issue-36-width-measure into main 2026-08-03 08:34:30 +00:00
Collaborator

Closes #36 and #13.

What the issue asked, and what the measurements said

4·area/perimeter is a sound width measure. Validated against
ST_MaximumInscribedCircle over all 33 062 of Sweden's z9 water polygons: r = 0.971,
median ratio 1.047, and 45× cheaper (0.06 s vs 2.7 s). The fractal-perimeter objection
does not bite, for a structural reason: both callers measure it on geometry already
simplified to that level's tolerance, so detail finer than half a pixel is gone before P is
taken.

But on cover the measure was never the constraint. A 1 px width test adds 2 072
candidates to Poland's 612 643 at z11, and one in 18 158 at z12. Sweden already had
23 885 of 24 601 z5 polygons under a pixel wide. Whatever was wrong, it was not which
polygons the threshold picked.

What was wrong: absorption was not absorbing. Every candidate is evaluated against one
snapshot and takes its largest neighbour's class, with no rule about which of the two is
larger — so two adjacent candidates take each other's class. Both relabelled, neither
merges, count does not move. On Sweden's z6→z5 step, 17 560 of 24 797 absorptions (71%)
were one half of such a pair. At coarse zoom a threshold in pixels makes nearly the whole
table a candidate, so this is the normal case, not a corner.

Absorption is now directed: a candidate absorbs only from something strictly larger
((n._a, n._id) > (c._a, c._id), the first ORDER BY term). A local maximum keeps its
class and is what its neighbours absorb into — which is also why found still means
"touches anything at all", since the no-neighbour drop must not delete a local maximum
with neighbours. Same step, one extra sort term: 24 602 → 12 132 features, area
2 570 263 → 2 570 284 km² (8 ppm).

min_width_pixels

New per-layer knob. On cover it widens the candidate set (area <= minArea OR width < minWidth), so it recolours and can never open a hole; it also enables absorption on its
own. On simplify it is an outright drop ANDed with the area test, which is only safe
where something else still draws the feature. Measured at z9→z8 as orphaned area — dropped
polygon area with no waterway within a pixel:

dropped orphaned
Poland 445 / 1 713 km² 15 / 30 km² (1.8%)
Iceland 258 / 2 377 km² 77 / 408 km² (17%)
Sweden 1 491 / 4 433 km² 682 / 1 896 km² (43%)

So Poland's water_bodies gets it and Sweden's and Iceland's do not; the config comments
name the measurement to re-run before that changes, and #40 is what would change it.
Rejected before any DB work on simplify_line/filter_only, where it would be silently
ignored.

Full cascades, all three countries

z5 z6 z7 z8
Iceland features 6 700 → 35 6 700 → 132 6 700 → 527 6 700 → 2 132
tile cover B 214 237 → 11 293 143 749 → 20 208 26 070 → 10 470 15 766 → 9 009
Poland features 2 381 → 622 4 260 → 1 498 9 439 → 4 410 21 887 → 15 769
tile cover B 68 067 → 46 088 72 116 → 45 472 98 514 → 58 077 91 075 → 66 055
Sweden features 24 601 → 1 212 25 731 → 1 927 28 347 → 4 823 38 903 → 16 105

Iceland is the clearest reading, because it had no absorption at all before — nothing in
its cascade could remove a feature, so the table sat at a flat 6 700 from z9 to z5. Its z5
tile went 254 kB → 51 kB and renders essentially unchanged. Poland's water_bodies: z9
5 675 → 4 281 features, 186 252 → 68 824 points.

Verification

  • Area preserved to 0.02% (IS) / 0.06% (PL) / 0.009% (SE); marine_water z12→z10 moves
    0.00% / +0.04% / +0.34%, so the coastline did not move (#26's property, re-checked
    because a thinness test makes narrow inlets candidates and Sweden's archipelago is the
    hard case).
  • ST_IsValid clean at z5/z8/z11 for all three; every partition present at every zoom; no
    leftover _new tables.
  • Rendered over CDP in real time (page reports map.loaded() + areTilesLoaded(), never a
    virtual clock) at z5–z11 for all three countries.
  • go test green, gl-style-validate clean.
  • Cost: PL z5–11 90 min, SE 66 min, IS z5–12 83 s — the same distribution as before; the
    width test does not change it.

Also in here

#13, folded in because it says to do it the next time cover is rebuilt for another
reason and that was now: urban_vegetation rolls up to artificial at z9 (Iceland was
carrying a z12-granularity class to z5), and glacier surviving every rollup is written
down as the deliberate decision it is.

Two spun out rather than folded in: #40 (bridge centrelines through water polygons that
have none — the answer to the "fake centrelines" question on #36, and what would let Sweden
and Iceland use the new knob) and #41 (absorption drifts every class into the dominant
one; pre-existing, and #36 improves it — Poland's nature drift over z9→z5 goes −46% →
−7% — without fixing the cause).

#20, #26 and #27 are closed with their evidence.

🤖 Generated with Claude Code

Closes #36 and #13. ## What the issue asked, and what the measurements said **`4·area/perimeter` is a sound width measure.** Validated against `ST_MaximumInscribedCircle` over all 33 062 of Sweden's z9 water polygons: **r = 0.971**, median ratio 1.047, and **45× cheaper** (0.06 s vs 2.7 s). The fractal-perimeter objection does not bite, for a structural reason: both callers measure it on geometry already simplified to that level's tolerance, so detail finer than half a pixel is gone before P is taken. **But on `cover` the measure was never the constraint.** A 1 px width test adds 2 072 candidates to Poland's 612 643 at z11, and *one* in 18 158 at z12. Sweden already had 23 885 of 24 601 z5 polygons under a pixel wide. Whatever was wrong, it was not which polygons the threshold picked. **What was wrong: absorption was not absorbing.** Every candidate is evaluated against one snapshot and takes its largest neighbour's class, with no rule about which of the two is larger — so two adjacent candidates take *each other's* class. Both relabelled, neither merges, count does not move. On Sweden's z6→z5 step, **17 560 of 24 797 absorptions (71%)** were one half of such a pair. At coarse zoom a threshold in pixels makes nearly the whole table a candidate, so this is the normal case, not a corner. Absorption is now **directed**: a candidate absorbs only from something strictly larger (`(n._a, n._id) > (c._a, c._id)`, the first `ORDER BY` term). A local maximum keeps its class and is what its neighbours absorb into — which is also why `found` still means "touches anything at all", since the no-neighbour drop must not delete a local maximum *with* neighbours. Same step, one extra sort term: **24 602 → 12 132 features**, area 2 570 263 → 2 570 284 km² (8 ppm). ## `min_width_pixels` New per-layer knob. On **`cover`** it widens the candidate set (`area <= minArea OR width < minWidth`), so it recolours and can never open a hole; it also enables absorption on its own. On **`simplify`** it is an outright drop ANDed with the area test, which is only safe where something else still draws the feature. Measured at z9→z8 as orphaned area — dropped polygon area with no waterway within a pixel: | | dropped | orphaned | |---|---:|---:| | Poland | 445 / 1 713 km² | **15 / 30 km² (1.8%)** | | Iceland | 258 / 2 377 km² | 77 / 408 km² (17%) | | Sweden | 1 491 / 4 433 km² | 682 / 1 896 km² (43%) | So Poland's `water_bodies` gets it and Sweden's and Iceland's do not; the config comments name the measurement to re-run before that changes, and #40 is what would change it. Rejected before any DB work on `simplify_line`/`filter_only`, where it would be silently ignored. ## Full cascades, all three countries | | z5 | z6 | z7 | z8 | |---|---:|---:|---:|---:| | **Iceland** features | 6 700 → **35** | 6 700 → **132** | 6 700 → **527** | 6 700 → **2 132** | | tile `cover` B | 214 237 → **11 293** | 143 749 → **20 208** | 26 070 → **10 470** | 15 766 → **9 009** | | **Poland** features | 2 381 → **622** | 4 260 → **1 498** | 9 439 → **4 410** | 21 887 → **15 769** | | tile `cover` B | 68 067 → **46 088** | 72 116 → **45 472** | 98 514 → **58 077** | 91 075 → **66 055** | | **Sweden** features | 24 601 → **1 212** | 25 731 → **1 927** | 28 347 → **4 823** | 38 903 → **16 105** | Iceland is the clearest reading, because it had no absorption at all before — nothing in its cascade could *remove* a feature, so the table sat at a flat 6 700 from z9 to z5. Its z5 tile went **254 kB → 51 kB** and renders essentially unchanged. Poland's `water_bodies`: z9 5 675 → 4 281 features, 186 252 → **68 824** points. ## Verification - Area preserved to 0.02% (IS) / 0.06% (PL) / 0.009% (SE); `marine_water` z12→z10 moves 0.00% / +0.04% / +0.34%, so the coastline did not move (#26's property, re-checked *because* a thinness test makes narrow inlets candidates and Sweden's archipelago is the hard case). - `ST_IsValid` clean at z5/z8/z11 for all three; every partition present at every zoom; no leftover `_new` tables. - Rendered over CDP in real time (page reports `map.loaded()` + `areTilesLoaded()`, never a virtual clock) at z5–z11 for all three countries. - `go test` green, `gl-style-validate` clean. - Cost: PL z5–11 90 min, SE 66 min, IS z5–12 83 s — the same distribution as before; the width test does not change it. ## Also in here **#13**, folded in because it says to do it the next time `cover` is rebuilt for another reason and that was now: `urban_vegetation` rolls up to `artificial` at z9 (Iceland was carrying a z12-granularity class to z5), and `glacier` surviving every rollup is written down as the deliberate decision it is. Two spun out rather than folded in: **#40** (bridge centrelines through water polygons that have none — the answer to the "fake centrelines" question on #36, and what would let Sweden and Iceland use the new knob) and **#41** (absorption drifts every class into the dominant one; pre-existing, and #36 improves it — Poland's `nature` drift over z9→z5 goes −46% → −7% — without fixing the cause). #20, #26 and #27 are closed with their evidence. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Issue #36 asked whether area is the right thing to filter polygons on, and
proposed area/perimeter as a width instead. Both halves are true and neither
was what was holding landcover back.

4*area/perimeter is a sound width: the diameter of a disc, twice the width of
a strip, and — measured against ST_MaximumInscribedCircle over all 33 062 of
Sweden's z9 water polygons — r = 0.971 at 45x the speed. The fractal-perimeter
objection does not bite because both callers measure it on geometry already
simplified to that level's tolerance.

But swapping the measure changes almost nothing on `cover`: a 1 px width test
adds 2 072 candidates to Poland's 612 643 at z11, and one in 18 158 at z12.
What was wrong is that absorption was not absorbing. Every candidate is
evaluated against one snapshot and takes its largest neighbour's class with no
rule about which of the two is bigger, so two adjacent candidates take each
other's class: both relabelled, neither merging. On Sweden's z6->z5 step that
was 17 560 of 24 797 absorptions — 71%. At coarse zoom a threshold in pixels
makes nearly the whole table a candidate, so it is the normal case.

Absorption is now directed — a candidate absorbs only from something strictly
larger, as the first ORDER BY term — and a local maximum keeps its class and is
what its neighbours absorb into. `found` therefore still means "touches
anything at all", because the no-neighbour drop must not delete a local maximum
that has neighbours.

min_width_pixels then pays on top. On `cover` it widens the candidate set, so
it recolours and can never open a hole; on `simplify` it deletes, which is only
safe where the waterways centreline still draws the feature. Measured at z9->z8,
a 1 px rule orphans 1 river polygon of 412 in Poland but 43% of dropped area in
Sweden and 17% in Iceland, so Poland's water_bodies gets it and the others do
not. Iceland's `cover` gets absorption for the first time; "17k polygons,
nothing to absorb" was wrong.

Full cascades, per country, features and cover bytes of one fixed tile:

  Iceland z5   6 700 -> 35      214 237 B -> 11 293 B
  Poland  z5   2 381 -> 622      68 067 B -> 46 088 B
  Poland  z7   9 439 -> 4 410    98 514 B -> 58 077 B
  Sweden  z5  24 601 -> 1 212

Area holds to 0.06% at worst and marine_water across z12->z10 moves 0.00% /
+0.04% / +0.34%, so the coastline is where it was. Checked visually over CDP in
real time at z5-z11 for all three countries.

Also folds in #13 while `cover` was being rebuilt anyway: urban_vegetation now
rolls up to artificial at z9, and glacier's survival is written down as the
deliberate decision it is.

Closes #36
Closes #13

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Author
Collaborator

Correction to what I wrote above. I said Poland's river polygons "go to zero at z6" and
that the river is "drawn as a centreline there instead". The first half is true only of the
river-classed polygons, and the sentence as a whole gives the wrong impression. Poland
still has 47 water polygons at z6, and the wide parts of the Vistula are among them.

BDOT10k classes an impoundment as woda stojąca — standing water — so a reservoir on a
river comes through as lake, not river. Two segments of Jezioro Włocławskie, the
Włocławek reservoir on the Vistula, are 79 km² and 62 km² at 3 542 m and 4 004 m wide.
One pixel at z6 is 2 446 m, so they are 1.4 px wide and the rule keeps them. At z5 a pixel
is 4 892 m, they are 0.7 px, and the rule drops them — which is exactly the "loses some
segments at z5, but not all" behaviour, and it is the rule working, not failing.

What is left at z5 is the four things still over a pixel wide: Morze Bałtyckie (66 km),
Śniardwy (10.9 km), Łebsko (8.3 km), Dąbie (8.3 km).

Also worth correcting: cover's water class is not what paints the Vistula at coarse
zoom. Measured against the Wisła's 1 331 km of centreline, cover water lies on 67% of it
at z9, 7.5% at z7 and 1.3% at z6 — so below z7 the river is carried by waterways plus
the surviving impoundment polygons, not by the landcover coverage. That is the correct
behaviour but it is not what I implied.

Rendered before/after at z5/z6/z7 over the Włocławek reach: z6 is indistinguishable, z7
shows the reservoir as a slightly narrower band, and the river is continuous at every zoom
in both. The layer rebuild for the comparison was -regeneralize 5-13, 2.5 min each way,
and the config is back as committed.

**Correction to what I wrote above.** I said Poland's river polygons "go to zero at z6" and that the river is "drawn as a centreline there instead". The first half is true only of the `river`-*classed* polygons, and the sentence as a whole gives the wrong impression. Poland still has **47 water polygons at z6**, and the wide parts of the Vistula are among them. BDOT10k classes an impoundment as *woda stojąca* — standing water — so a reservoir on a river comes through as `lake`, not `river`. Two segments of **Jezioro Włocławskie**, the Włocławek reservoir on the Vistula, are 79 km² and 62 km² at **3 542 m and 4 004 m wide**. One pixel at z6 is 2 446 m, so they are 1.4 px wide and the rule keeps them. At z5 a pixel is 4 892 m, they are 0.7 px, and the rule drops them — which is exactly the "loses some segments at z5, but not all" behaviour, and it is the rule working, not failing. What is left at z5 is the four things still over a pixel wide: Morze Bałtyckie (66 km), Śniardwy (10.9 km), Łebsko (8.3 km), Dąbie (8.3 km). Also worth correcting: `cover`'s water class is **not** what paints the Vistula at coarse zoom. Measured against the Wisła's 1 331 km of centreline, `cover` water lies on 67% of it at z9, 7.5% at z7 and **1.3% at z6** — so below z7 the river is carried by `waterways` plus the surviving impoundment polygons, not by the landcover coverage. That is the correct behaviour but it is not what I implied. Rendered before/after at z5/z6/z7 over the Włocławek reach: **z6 is indistinguishable**, z7 shows the reservoir as a slightly narrower band, and the river is continuous at every zoom in both. The layer rebuild for the comparison was `-regeneralize 5-13`, 2.5 min each way, and the config is back as committed.
art merged commit 336635e43d into main 2026-08-03 08:34:30 +00:00
art deleted branch issue-36-width-measure 2026-08-03 08:34:30 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
art/ismap!42
No description provided.