MaksForge

Kozica

Android app

A hiking map of the Polish mountains that works with no signal at all.

Building

Phase 4 — The map shows the shape of the mountains: shading and contours under the Trails

Sequence lives in PLAN.md; the live worklist lives in DOD.md. This phase works inside ADR-0008 and writes ADR-0009, which records where the relief comes from. It leans on phase-2c.md: every Region arrives with the app, so the relief can be one pack. CONTEXT.md owes nothing: “relief”, “shading” and “contours” are what everyone calls them, and none names something the app means differently.

What closing this phase means. Today the map is flat: it draws the Trails, forests and water, but nothing on it says where the ground rises, or whether the next kilometre of a red Trail climbs 500 m. After this phase the map is shaded as if lit from the north-west, with a contour every 20 m and a thicker, labelled one every 100 m, across all nine Regions on both sides of the border. Under it is a height grid on the phone, which phase 5 reads for a route’s ascent and walking time. The difference a device can be asked about: the Tatry at zoom 13 shows Kasprowy Wierch’s 1900 m contour under the red Trail, with no seam where the shading crosses into Slovakia.

PLAN.md’s paragraph is right in what it promises and silent on two things decided on 2026-10-22. The DEM is built by us from the three countries’ lidar terrain models, once, not on every release; and it ships as one pack for all Regions together, not as part of each.


0. What this phase inherits

  • MapLibre Native 13.7.1 draws a raster-dem source with a hillshade layer, Terrarium or Mapbox encoding, and since 13.0 the hillshade-method options and color-relief. It reads PMTiles since 11.8. Contours are not computed on the device: maplibre-contour is a GL JS plugin, so contours arrive as vector tiles.
  • Every Region is on the phone (phase 2c), so the map never draws one Region’s relief beside another’s hole, and one archive for all of them overlaps nothing.
  • Regions overlap by a few kilometres (phase 2b § 4). Two shading layers over the same ground would stack into a darker band at every seam; one grid has no seams to stack.
  • The pipeline’s habits. build-map-data.py pins its input, fails rather than build something different, and writes byte-identical output from identical input (ADR-0007). The relief keeps all three, but it is built once rather than per release: the ground does not change between pins.
  • The palette. Every map colour but the five Trail colours is a tone of the scheme’s palettes, generated by scripts/map/gen_style.py; the shading and contour colours join them.

What is in, and what is deliberately not

In: a height grid for the union of the nine Region polygons, built from Poland’s GUGiK NMT, Slovakia’s ÚGKK DMR 5.0 and Czechia’s ČÚZK DMR 4G, at about 12 m (zoom 12). Contours at 20 m with every 100 m line thick and labelled. One fast-follow relief pack holding both archives. The shading, contour and label layers in the style, light and dark, always on. The credit on the map and the three entries on the licences screen. The build, its pin, its own GitHub release and the publish-play.yml step that packs it.

Not in, and each with a reason rather than a phase:

  • Copernicus GLO-30, the obvious global source. It is a surface model: over the forests that cover most of the Beskidy, Bieszczady and Sudety it measures the canopy, so contours would bulge at every forest edge and phase 5 would add a climb at each. ADR-0009.
  • Mapterhorn’s ready-made tiles, which merge the same national models. Decided by the owner: the build is ours, end to end, as the OSM data is. ADR-0009.
  • A finer grid than zoom 12. Zoom 13 adds about 29 MB for the Tatry alone, for gullies no phone screen shows at the zooms people pan at; MapLibre draws zoom 12’s grid beyond its zoom.
  • A switch to turn the relief off. Decided with the owner: it is part of how the map looks. If the shading is too heavy somewhere, the style is fixed.
  • Elevation profiles and ascent. Phase 5’s, which reads the same grid.
  • A per-Region contour interval. The same hillside would change its line count at a Region’s edge, and steepness would read differently from one range to the next.

The tempting one is colouring the land by height, green valleys to brown ridges, with color-relief: no new data, one more layer. Out for this phase, decided with the owner, because the map’s colours already carry meaning (forest, meadow, scree, water and the five Trail colours) and a height tint fights each of them where a Trail has to be picked out at a junction. It is kept as an idea, not a refusal: once the relief draws, a tinted variant is a cheap experiment in the debug build, to be looked at on the phone before anyone decides.


Checkpoints

  Checkpoint Merges Depends on
A The gate — the shading and contours of a hand-made Tatry grid drawn on the test phone with all nine Regions, at a frame rate fit to pan readings, from A’s branch; the debug apparatus as chore: phase 2c
B The relief build — the three downloaders, one grid with no seam or gap, the contours, deterministic, its workflow, its pin and its release chore: A
C The relief on the map — the relief pack, the style’s layers, the credit, the licences entries, publish-play.yml packing it feat: B

The gate is drawing, not data. What could veto the design, a height grid on the phone shaded live, is whether a 2019 mid-range phone pans smoothly with a hillshade layer and a contour layer over nine Regions’ worth of vector layers. That needs a grid of the right shape, not the right source, so A uses a Tatry grid made by hand, for the reading only, and never shipped.

B is the expensive half and goes second. Three agencies’ downloads and a border seam are the phase’s cost. Taken before A, a bad gate would leave a pipeline built for a design it vetoed.

B cuts no release; C does. The relief reaches users only when the map draws it.


1. The gate: does the test phone pan smoothly with the relief drawn?

The question, in the form that can be measured

On the test phone (Redmi Note 8 Pro, API 30), with all nine Regions ready and a Tatry height grid at zoom 0–12 with its 20 m contours, does a scripted pan across the Tatry at zoom 11 and 13 keep its 95th-percentile frame time within 25 % of the same pan without the relief, with no more frames over 100 ms than that pan has?

The statistic. Each run is the same ten swipes at each zoom, on the same build and camera; its p95 is taken over the frames drawn while the camera moves, from each swipe’s camera=move to the map’s idle after it, and frames at rest are counted apart (scripts/map/frame-stats.py). The relief run is compared with the baseline run taken straight before it. A frame over 100 ms counts against the relief only beyond the number the baseline run shows: tile loads at zoom 13 over nine Regions can stall a frame with no relief drawn, and the gate measures what the relief adds, not those.

The apparatus

  • A hand-made Tatry grid, the Tatry polygon’s tiles at zoom 0–12, Terrarium, lossless WebP, 512 px, as relief.pmtiles, with its contours as contours.pmtiles traced from it. Made with whatever is quickest (the planning’s scratch extract from Mapterhorn was one): the reading is about the shape, and nothing here ships.
  • All nine Region archives pushed by hand to files/regions/<id>.pmtiles, which RegionSource.kt’s debug overlay reads as ready, so no Region is Failed and phase 2c’s progress line stays off the map. A connectedAndroidTest run’s closing uninstall wipes them, so they are pushed again after one.
  • The debug half of a relief seam, beside RegionSource.kt’s: the debug build draws files/relief/relief.pmtiles and files/relief/contours.pmtiles when pushed by hand. The release half returns nothing until C.
  • A frame log, debug-only: MapLibre’s frame listener on the map view, logging each frame’s duration under one tag with the camera’s moves and rests, so a frame drawn at rest is not timed as one in motion, and a pan driven by adb shell input swipe so both runs pan the same.

The verdicts, decided now rather than argued about later

  1. p95 within 25 % of the baseline’s, and no frame over 100 ms beyond the baseline’s → B and C as planned.
  2. Slower than that, but p95 under twice the baseline’s → shading from zoom 10 only, and the grid stops at zoom 11; read again. If the second reading passes, that is the design; if not, verdict 3.
  3. p95 twice the baseline’s or worse → the live shading goes: shading baked into a grey image by build-relief.yml, once, beside the height grid and pinned with it, and the grid still shipped, undrawn, for phase 5. This file is rewritten before B starts.

The verdict, taken 2026-10-22

Verdict 1 (R1): with the Tatry’s relief drawn and all nine Regions ready, the test phone’s p95 frame time stays within 15 % of the baseline at zoom 11 and level with it at zoom 13, and no frame in any run passed 100 ms. B and C go ahead as planned. Frame time with the relief over every Region is not read again: the map draws only the tiles on screen, so the grid’s extent beyond the Tatry adds tiles off screen, not work per frame; C’s R3 and R4 read seams and delivery, not frame time. Two things the verdicts did not anticipate:

  • The relief runs render fewer frames per swipe at zoom 13, about 37 against 49–58, at the same 16.7 ms each. It is not a weaker fling: one swipe from the same camera ends on the same view with and without the relief. The baseline keeps rendering after the motion has stopped, and those frames, at 16.7 ms, are inside its p95 and pull it down, so the comparison is if anything stricter on the relief.
  • The shading is too heavy in steep ground with the gate’s throwaway colours and exaggeration 0.5: the Tatry’s north faces go nearly black under the Trails. That is C’s style work, read again in C’s own screenshots, not a gate result.

2. The relief build

  • scripts/build-relief.py builds relief.pmtiles and contours.pmtiles for the union of the nine polygons in scripts/regions/, clipped to the real state borders of Poland, Slovakia and Czechia: their boundary=administrative, admin_level=2 relations, read from the extracts scripts/osm-data.pin already pins for the map build. Outside the three countries there is no national model. Geofabrik’s .poly outlines would not do: they reach past the border on purpose, so extracts catch what lies along it, and would keep strips of Ukraine in the Bieszczady and of Germany beyond the Karkonosze that the gap check would then fail on by design.
  • Each agency’s data, fetched by its own service, only where it touches the clipped union, and resampled onto the target grid as it arrives and then deleted, so the disk holds small parts, not the source. What each agency actually serves was read on 2026-10-25, before the script was written, and differs from what this section first assumed (ADR-0009’s amendment):
    • Poland, GUGiK NMT: the sheets of its WFS sheet indexes, one layer per survey year in the PL-EVRF2007-NH service and the PL-KRON86-NH one before it. Chosen by ground, not by name: the layers name their sheets in different schemes (1:5000 godła, and local ones such as 6.110.29.05), so one piece of ground sits under several names. The build ranks every candidate, lidar (“Skaning laserowy”) first, 1 m before 0.5 m (the same ground at a quarter of the download), then the newer datum, then the newer survey, and takes each only where no better one already covers the ground. Photogrammetric grids, the 5 m ones and many newer 1 m ones GUGiK marks “Zdj. lotnicze”, fill only what no lidar covers, and in the merge only cells no lidar reaches: averaged in, they would blur it. For the whole union that is 5,041 lidar sheets (4,940 of 1 m EVRF2007 from 2019–2026, 100 of 2012–14 KRON86, one of 0.5 m) and 79 photogrammetric ones; R2 records it. About 215 GB.
    • Czechia, ČÚZK DMR 4G: one zip per SM5 sheet from its ATOM feed, the feed’s own polygons picking the sheets. Each holds a LAZ 5 m point grid, read with laspy and laid back onto its grid. About 1,500 sheets, 0.3 GB.
    • Slovakia, ÚGKK DMR 5.0: published only as one 197 GB archive, so the build reads ÚGKK’s INSPIRE WCS instead, in 5 km squares at 5 m. Its heights are ellipsoidal, so each is corrected to Slovakia’s Baltic heights with ÚGKK’s own grid, pinned by hash: H = h − N, read at Kriváň as 2537.22 − 42.92 = 2494.30 m against the summit’s 2494.7 m. About 0.8 GB.
  • One grid, in one projection, with no seam. Every sheet is reprojected from its national system (Poland’s EPSG:2180, Czechia’s S-JTSK, the WCS’s ETRS89 / TM34) onto Web Mercator at zoom 12’s resolution, about 12 m at 49° N, averaging the source cells under each. Heights are summed in whole centimetres, so the merge gives the same bytes whatever order the parts finish in. Where two countries’ data overlap at a border the mean is taken. Poland’s KRON86 sheets are Kronstadt heights, like Czechia’s and Slovakia’s Baltic Bpv, its EVRF2007 sheets are not; all of them differ by well under a metre, and the build does not correct for it (§ Known gaps).
  • The build fails on a gap. A grid cell inside the clipped union with no value from any sheet stops the build, which reports each connected gap with its size, a cell in it and its Region. If one turns up, it is filled from a named source for that gap only, in an amendment to ADR-0009, not by a fallback added in advance. Cells within 3 grid cells, about 40 m, of a state border are tolerated and counted: where the other side has no national model, OSM’s line and the agency’s own data edge part by a cell or two. Water is the one gap found so far. The first full build stopped on the Rozkoš reservoir in Czechia, about 1 km² ČÚZK leaves out of DMR 4G, as terrain models leave water out. GUGiK’s grids fill their lakes at the water level (Morskie Oko samples at 1395.00 m), so the build does the same for water it names in WATER: each gap there that data surrounds is filled at one level, the lowest tenth of its shore’s heights, since water lies at the bottom of its banks. Any gap not named still fails the build.
  • relief.pmtiles: zoom 0–12, 512 px tiles, Terrarium encoding, lossless WebP. A lossy encoder rewrites the low bits, which are the heights.
  • contours.pmtiles: traced from the same grid, so the lines, the shading and phase 5’s ascent agree. A light smoothing before tracing so a 12 m grid does not draw staircases. 20 m lines with an ele attribute and an index flag every 100 m; the 100 m lines from zoom 11, all of them from zoom 12, nothing below.
  • Deterministic from its sheet lists. Sheets in a fixed order, fixed encoder settings, no timestamps, pinned GDAL and libwebp. The sheets themselves come from live indexes, which gain a survey year at a time, so the build keeps the lists it used as index-{pl,cz,sk}-<hash>.json and the release publishes them; put back in map-data/relief/, they stand in for the live indexes, and a rebuild from them reproduces the archives.
  • Built once, not per release, on the owner’s PC. Decided with the owner on 2026-10-25: a one-time job of about 215 GB suits a home connection better than a hosted runner, whose six-hour limit a single run of it would pass. The script is committed and deterministic, --device binds its downloads to one network interface (the PC’s Ethernet, not the test phone’s tether), and each finished sheet is kept as a part, so a stopped run resumes. Its two archives, its three sheet lists and scripts/map/RELIEF-DATA-LICENCE.txt go to a GitHub release of their own, relief-<date>, and scripts/relief.pin holds that tag and both sha256s. Rebuilding is a decision, not a schedule: the ground does not move, and a national agency publishing a new survey is the reason to.

3. The relief on the map

  • One relief pack, fast-follow like the Regions, holding both archives: 86.5 + 10.3 MB of files since relief-2026-11-01 keeps whole metres (ADR-0009’s amendment), where Play sent 268 MB before (R2, R4); Play sent 96.8 MB for it in 0.14.3 → 0.14.4 (phase-2b.md R12). It never changes between relief builds, yet Play sends it whole with every update all the same (phase-2b.md R10, R11). publish-play.yml downloads the pinned release’s archives, checks both hashes against the pin and fails on a mismatch, before bundleRelease -PmapPacks.
  • RegionFiles learns the relief pack; it is not a Region, so it does not join REGION_NAMES, and phase 2c’s progress line counts it as one more item: “Downloading maps — 4 of 10”.
  • The style. A relief raster-dem source and a contours vector source, each added once, not per Region. Under the land cover’s outlines and above its fills: the hillshade layer, lit from the north-west, its shadow and highlight tones of the scheme’s neutral palette, lighter in dark mode; then the contour lines, thin at 20 m and thicker at 100 m; then the 100 m labels along their lines, under every Trail and POI label. Their colours are generated by scripts/map/gen_style.py from the palette, never written as literals.
  • The credit. The map’s line becomes “© OpenStreetMap contributors · Relief: GUGiK, ÚGKK SR, ČÚZK”, with the OSM part still linked. The licences screen gains one entry per agency: GUGiK as the source, as Polish law asks; ÚGKK SR and ČÚZK under CC BY 4.0, each with its licence link and “Resampled, merged and traced into contours by Kozica”.

Copy: “Relief: %1$s” on the map. The agencies’ names and the licence titles are not copy. The licences screen’s “Resampled, merged and traced into contours by Kozica”.


Tests

  • The Terrarium decode, height to colour and back, for a value below sea level, zero, Rysy’s 2499 m and a fraction. Phase 5’s ascent stands on it, and an off-by-one in the byte order reads the Tatry as a plain.
  • The gap check fails for a grid with one empty cell inside the union, and passes for one empty outside it.

Both are scripts/test_build_relief.py, run with the build’s own venv (map-data/relief/venv/bin/python -m unittest scripts/test_build_relief.py); the relief is built only on the owner’s PC, so CI has no venv to run them in.

Documents this phase amends

  • ADR-0009, written with this file.
  • The licences screen and docs/store-listing.md: the 0.7.0 notes, and a sentence in the description that the map shows the shape of the mountains.
  • PLAN.md: this phase’s paragraph, and the status line when it closes.
  • CLAUDE.md, with C: the Stack’s Play Asset Delivery row gains the relief pack, which is not a Region’s; the Layout gains the relief/ module, scripts/build-relief.py and scripts/relief.pin.
  • RELEASING.md: publish-play.yml’s new step that downloads the pinned relief and checks its hashes, with C; the local build and its own relief-<date> releases, with B.
  • play-app-content.md, with C: the dataSync answers cover the relief pack as well as the Regions.
  • The privacy policy owes nothing: the relief arrives from Play, as the maps do.

Known gaps, deliberately

  • The three height datums are not reconciled. Poland’s heights mix PL-EVRF2007-NH and PL-KRON86-NH sheet by sheet, against the Baltic heights Slovakia and Czechia use; they differ by well under a metre. Against 20 m contours that is invisible, and correcting it would be a geodesy step with its own source to pin.
  • Relief stops at Ukraine’s and Germany’s borders, a little short of where the OSM data does.
  • Every install carries the relief’s archives too, 97 MB of files since relief-2026-11-01, where Play sent 268 MB before it (R4) and sends 96.8 MB now (phase-2b.md R12). Accepted with phase 2c’s decision to ship everything.

Readings

PLAN.md rule 3. The test phone’s screen stays on over USB (svc power stayon usb); a pan read with the keyguard up measures nothing.

# Reading Command Result
R1 The gate: frame times of the scripted pan at zoom 11 and 13 over the Tatry, with and without the relief, all nine Regions ready the debug build with all nine Region archives pushed to files/regions/ and A’s archives to files/relief/; per run, on adb -s mnbm7hw8ivdqzx9l: stop the app; shell am broadcast -n io.github.srednimax.kozica.debug/app.kozica.debug.CameraReceiver -f 0x00000020 --ef lat 49.23 --ef lon 20.0 --ef zoom <11\|13>; stop it again; wake, wm dismiss-keyguard, launch, wait 10 s; logcat -c; 5 × (input swipe 850 1300 250 1100 250, 1.2 s, input swipe 250 1100 850 1300 250, 1.2 s); logcat -d -s KozicaFrames \| python3 scripts/map/frame-stats.py. The baseline runs with files/relief renamed to files/relief-off 2026-10-22, test phone, debug build, the Tatry’s grid cut from Mapterhorn at zoom 0–12 (13.7 MB) and contours traced from it at 20 m (0.9 MB), all nine Regions from v0.5.0’s release. p95 frame time, baseline against relief: zoom 11, 19.5 / 22.4, 19.5 / 19.7 and 19.0 / 20.2 ms; zoom 13, 17.5 / 17.6, 18.1 / 17.8 and 17.5 / 17.4 ms. Median 16.4–16.7 ms throughout; no frame over 100 ms in any run; every run’s 10 swipes showed exactly 9 rests, so no stall hid among them. The first zoom 11 relief run logged 173 frames against the usual 375 and did not repeat; it is kept and counted, the worst case at +15 %, and without it zoom 11 is within 6 %. Verdict 1
R2 Each source’s sheets and download for the clipped union, the kinds of Polish sheet used, and the build’s peak disk and hours build-relief.py’s own log, from the full local run 2026-10-25, the owner’s PC over its Ethernet, 8 sheets at a time, at about 1.5 sheets a second. 7,119 sheets: Poland 5,120 (5,041 lidar: 4,940 of 1 m PL-EVRF2007-NH from 2019–2026, 100 of 1 m PL-KRON86-NH from 2012–14, one of 0.5 m; and 79 photogrammetric, 10 EVRF2007 of 2024 and 69 KRON86 of 2017), Czechia 1,665, Slovakia 334 WCS squares. Downloads about 215 GB, nearly all Poland’s lidar; the kept parts take 579 MB, so the disk never held more than a few sheets. About 70 minutes of downloads after the indexes, then about 20 for the merge and the tiles. The first full run stopped on the Rozkoš reservoir’s gap (§ 2); filled from its shore, the second found no gaps among 240,932,278 cells. relief.pmtiles 257,420,171 bytes, contours.pmtiles 10,294,992; published as relief-2026-10-25 and pinned. Summits sampled: Rysy 2495.0 m, Kriváň 2490.6, Kasprowy Wierch 1985.0, Babia Góra 1722.3, Śnieżka 1603.1, Pradziad 1490.6, Lysá hora 1322.2, Tarnica 1339.7, Łysica 612.5: each a 12 m cell’s mean, a few metres under a sharp top
R3 No seam: the shading and contours across the Polish–Slovak border in the Tatry and the Polish–Czech one in the Karkonosze release-shaped debug build with C’s archives; adb exec-out screencap -p at zoom 13 over each 2026-10-25, test phone, C’s debug build with the pinned relief-2026-10-25 archives (fetched by fetch-relief.py, hashes matching) pushed to files/relief/ and all nine Regions to files/regions/, camera set by CameraReceiver. At zoom 13 over the Rysy ridge (49.18 N 20.075 E) and over Śnieżka (50.736 N 15.74 E), the shading and the contours run straight across the dashed state border: no step, no gap, no change of tone. The Rozkoš reservoir (50.383 N 16.064 E) draws as water with no ring or plateau in it. Pass
R4 The relief pack arrives from Play with the Regions, and the progress line counts it C’s release from the internal track, as phase 2c’s R1 and R3 2026-10-25, test phone, 0.7.0 (versionCode 190) from the internal track after adb uninstall, never opened. Play requested the relief pack by itself with the nine Regions, all ten within 0.2 s of the install, and downloaded it whole in 44 s: six parts, 267,784,625 bytes. Opened five minutes later: KozicaPack read all ten TRANSFERRING (3), relief among them, and no fetch line. A 15 s screenrecord from the launch, at 2 fps: the line counted “Pobieranie map — 0 z 10” up to “9 z 10” over the first 5 s as the Regions unpacked, held at 9 while the relief did, and about 10 s after the launch the shading and contours appeared, the line went, and the attribution gained “Rzeźba terenu: GUGiK, ÚGKK SR, ČÚZK”. Pass
R5 The contours against the ground: Kasprowy Wierch’s summit, 1987 m, between its 1980 m contour and the next; Śnieżka’s, 1603 m, between 1600 m and 1620 m the map at zoom 14, screencap; the grid sampled at each summit by scripts/build-relief.py --sample 2026-10-25, as R3: at zoom 14.5 Kasprowy Wierch’s summit marker (1987 m) sits inside the innermost 20 m rings above the 1900 m index line; at zoom 13 Śnieżka (1603.3 m) above its 1400 m and higher lines. The grid samples 1985.0 m at Kasprowy Wierch and 1603.1 m at Śnieżka (build-relief.py --sample, R2), so each summit lies between its 1980/1600 m contour and the next. Pass

Hi, I'm Maksymilian.

MaksForge is where I build the small tools I wish existed. Each one solves a single problem.