Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions examples/atlas/Project.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@ TextMeasure = "06791c1d-2336-41e1-bd6f-a74c63395da6"
HouseStyle = {path = "../_housestyle"}
# MakieTextRepel has no registered release yet, so it is pinned to a GitHub rev. This atlas
# leg therefore needs network access at `Pkg.instantiate` time; a GH/network outage can turn
# its CI red independently of any code change here.
MakieTextRepel = {rev = "dc7178205ce4b05e8bd86c4ae10419f0932e14e6", url = "https://github.com/jowch/MakieTextRepel.jl"}
# its CI red independently of any code change here. Pinned past PRs #27 (public `warm_solve`)
# and #28, so the demo uses only the public API — no internal-solver reach-through.
MakieTextRepel = {rev = "90f87ed3e9be28f1bfcc4754749fe0c3942fb2c0", url = "https://github.com/jowch/MakieTextRepel.jl"}
TextMeasure = {path = "../.."}

[compat]
Expand Down
6 changes: 3 additions & 3 deletions examples/atlas/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Nothing on this map is hand-positioned. The piece holds to three claims, each en
a guessed width. Each label is measured **once** at a reference size; its per-frame box is that
unit box scaled by `font_px / _REF_PX` (glyph advances scale linearly with size), so the font
engine is touched once per label and every frame after is arithmetic. *(single-measure-then-scale)*
2. **Placed.** Every point label's screen position is the output of MakieTextRepel's `solve_cluster`
2. **Placed.** Every point label's screen position is the output of MakieTextRepel's public `warm_solve`
— one solve per frame over all labels against each other, the sampled coastline, and the areals.
As the camera dives, the previous frame's offsets warm-start the next, so labels glide rather
than jump. Placement is then made deterministic by our own two-pass cull (hard coast clearance +
Expand All @@ -52,7 +52,7 @@ Nothing on this map is hand-positioned. The piece holds to three claims, each en
| `pois.jl` | Hand-authored feature anchors: landmark POIs + the curved region areals (the ONLY hand-placed data). |
| `camera.jl` | The seamless geometric zoom loop (van Wijk & Nuij easing) → `camera_rect(p)`. |
| `lod.jl` | Geographic level-of-detail: `font_px` (ground-em × pixels-per-unit), the `band_alpha` fades, hydrography LoD. |
| `place.jl` | `measure_boxes` (TextMeasure) + `solve_frame` (warm-started `solve_cluster`). |
| `place.jl` | `measure_boxes` (TextMeasure) + `solve_frame` (warm-started `warm_solve`). |
| `render.jl` | The honest per-frame pipeline (`assemble_frame`) + the Makie render layer (basemap, areals, labels, chrome). |
| `loop.jl` | `render_loop` (the MP4) + `render_hero`. |
| `golden.jl` | The deterministic LoD/opacity golden table (geometric, no pixels). |
Expand All @@ -65,7 +65,7 @@ For each loop phase `p`:
camera_rect(p) # camera.jl — this frame's view window (geometric zoom)
→ project anchors to px # the geography lands on screen
→ font_px + band # lod.jl — geographic size + legibility/edge/size fades
→ measure → seed → solve # place.jl — measure boxes, geography-aware seed, solve_cluster
→ measure → seed → solve # place.jl — measure boxes, geography-aware seed, warm_solve
→ cull + leader fade # deterministic visibility (coast clearance + occlusion)
→ draw # render.jl — basemap → hydrography → areals → labels → chrome
```
Expand Down
2 changes: 1 addition & 1 deletion examples/atlas/src/Atlas.jl
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
module Atlas

using TextMeasure
using MakieTextRepel: ProjectionSolver, RepelParams, solve_cluster
using MakieTextRepel: warm_solve
import HouseStyle

# includes added task-by-task:
Expand Down
47 changes: 44 additions & 3 deletions examples/atlas/src/golden.jl
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,17 @@
# and no solver: `font_px` comes from each feature's ground-em (degrees), `band` is the
# legibility/size fade (`band_alpha`) times the viewport edge fade (`_edge_alpha`) over a pure
# AFFINE projection that reproduces the Makie camera to ≪0.1px. So the digest is reproducible
# across machines / fonts / Makie versions. (The solver's pixel offsets — Makie/solver-dependent
# — are deliberately NOT golden'd; this pins the SCALING + FADE behaviour we tune, e.g. town
# growth, the areal cloud hand-off, and the Range dissolving as the inland towns arrive.)
# across machines / fonts / Makie versions. This pins the SCALING + FADE behaviour we tune, e.g.
# town growth, the areal cloud hand-off, and the Range dissolving as the inland towns arrive.
#
# RAW solver pixel offsets are still NOT hashed — they flow through Makie's projection, which
# isn't guaranteed bit-stable across Makie versions. Instead `placement_rows`/`atlas_placement_digest`
# golden the solver's DISCRETE decisions — which side each label leans (offset quadrant) + whether
# it's dropped — taken from the real COLD solve. These rows DO still route anchors through Makie's
# projection (via `assemble_frame`), unlike the fully-affine geometric table above, so the digest
# is ROBUST TO sub-pixel projection drift — not Makie-independent — because a quadrant only flips on
# a genuine re-placement. (Offset magnitude / frame-to-frame continuity is covered separately by the
# warm-start delta bound in test_loop.jl.)
#
# Included after lod.jl / pois.jl (uses their constants + atlas_pois/atlas_areals).

Expand Down Expand Up @@ -89,3 +97,36 @@ end

"SHA-256 hex of the canonical (geometric, deterministic) Atlas LoD/opacity table."
atlas_digest(; pagepx = (1620, 1080)) = digest_rows(golden_rows(; pagepx))

"""
placement_rows(; pagepx=(1620,1080)) -> Vector{String}

The solver's DISCRETE placement decisions across `GOLDEN_FRAMES`. For each point label at each
frame (taken from the real COLD solve — `assemble_frame` with no warm-start, so it's deterministic
and frame-independent), emit `frame|id|dropped|sx|sy` where `sx`/`sy` are the SIGN of the label's
offset from its anchor (which quadrant it leans into). Hashing the discrete side, not the pixel
offset, keeps the digest robust to sub-pixel projection noise (it only changes on a genuine
re-placement). It still routes anchors through Makie (via `assemble_frame`), so it is
robust-to-drift, not fully Makie-independent like the affine geometric table above.
"""
function placement_rows(; pagepx = (1620, 1080))
_PIN_SLO[] = true # golden reflects production placement (SLO pinned); harden against
# a future test leaving the global Ref `assemble_frame` reads flipped.
d = load_atlas_data()
rows = String[]
sgn(x) = x > 0 ? "+" : x < 0 ? "-" : "0"
for frame in GOLDEN_FRAMES
p = frame / _GOLDEN_N
_, _, af = assemble_frame(d, p; pagepx, prev = Dict{Int,Vec2f}()) # cold = deterministic
fp = af.fp
for i in sortperm(fp.ids) # stable order by id
off = fp.offsets[i]
push!(rows, string(frame, "|", fp.ids[i], "|", Int(fp.dropped[i]),
"|", sgn(off[1]), "|", sgn(off[2])))
end
end
return rows
end

"SHA-256 hex of the Atlas discrete-placement table (robust to sub-pixel projection drift; decisions, not pixels)."
atlas_placement_digest(; pagepx = (1620, 1080)) = digest_rows(placement_rows(; pagepx))
2 changes: 1 addition & 1 deletion examples/atlas/src/loop.jl
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
#
# HONESTY INVARIANT (preserved every frame):
# Every label is measured by TextMeasure (via measure_boxes / _unit_box) and
# placed by MakieTextRepel (solve_cluster, via solve_frame). No position is
# placed by MakieTextRepel (warm_solve, via solve_frame). No position is
# hand-picked. The warm-start threads solved offsets from frame N into frame N+1
# so the solver RELAXES rather than re-seeds — this is what makes the video smooth.
#
Expand Down
13 changes: 7 additions & 6 deletions examples/atlas/src/place.jl
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# SPDX-License-Identifier: MIT
# DECISION (Task 1 spike): raw `solve_cluster` + our own overlap recompute.
# DECISION (Task 1 spike): the raw warm-start solve + our own overlap recompute. Originally the
# unexported `solve_cluster`; migrated to the public `warm_solve` (same seam) once it shipped.
# Per-frame data→pixel projection (used by the loop task, NOT here):
# px = Point2f(Makie.project(ax.scene, :data, :pixel, data_pt)[Vec(1,2)]) # no frame lag after update_state_before_display!
using Makie
Expand All @@ -23,9 +24,8 @@ function measure_boxes(strings; fontsize = Float64(HouseStyle.RAMP.body), font =
[Vec2f(layout(prepare(backend, s)).size[1], boxh) for s in strings]
end

const _PARAMS = RepelParams(; only_move=:both, box_padding=5.0,
point_padding=5.5, min_segment_length=2.0)
const _SOLVER = ProjectionSolver(_PARAMS)
# Solver config, passed to the public `warm_solve` per call (kwargs, no solver object).
const _SOLVE_KW = (; only_move=:both, box_padding=5.0, point_padding=5.5, min_segment_length=2.0)

# Cartographic default: seed an uncontested label to the UPPER-RIGHT of its dot (Imhof's
# preferred position) instead of the solver's bare default (straight below). The seed offset
Expand All @@ -51,8 +51,9 @@ function solve_frame(ids, anchors, sizes, bounds; prev, settled, obstacles::Vect
# pinned_offsets must be length n when pin_mask is provided (solver contract)
pinned = pin !== nothing ?
Vec2f[get(prev, id, Vec2f(0,0)) for id in ids] : Vec2f[]
r = solve_cluster(_SOLVER, collect(Point2f, anchors), collect(Vec2f, sizes), bounds;
init_state=init, pin_mask=pin, pinned_offsets=pinned, obstacles=obstacles)
r = warm_solve(collect(Point2f, anchors), collect(Vec2f, sizes), bounds;
init_state=init, pin_mask=pin, pinned_offsets=pinned, obstacles=obstacles,
_SOLVE_KW...)
FramePlacement(collect(Int, ids), collect(Point2f, anchors), collect(Vec2f, sizes),
r.offsets, r.dropped)
end
Expand Down
2 changes: 1 addition & 1 deletion examples/atlas/src/pois.jl
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# pois.jl — hand-placed feature anchors (the ONLY hand-positioned data in the
# piece: each POI / areal carries a real geographic location. Everything that
# follows — every label box — is MEASURED by TextMeasure and PLACED by
# MakieTextRepel's solve_cluster. These structs supply anchors only, never the
# MakieTextRepel's warm_solve. These structs supply anchors only, never the
# label's final screen position.
#
# Included after data.jl (uses project_point / Point2f).
Expand Down
4 changes: 2 additions & 2 deletions examples/atlas/src/render.jl
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
# `using CairoMakie` lives here deliberately — kept out of place.jl.
#
# HONESTY INVARIANT: every point label (town + POI) is MEASURED by TextMeasure
# (measure_boxes) and PLACED by MakieTextRepel (solve_cluster, via solve_frame).
# (measure_boxes) and PLACED by MakieTextRepel (warm_solve, via solve_frame).
# Rotated region "areals" are likewise MEASURED, and their footprints + sampled
# coastline vertices are fed back in as solver OBSTACLES so the label field stays
# clear of them. The only hand-positioned values anywhere are feature anchors
Expand Down Expand Up @@ -423,7 +423,7 @@ end

Build a fresh figure for loop phase `p` and place EVERY label honestly:
- point labels (active towns + on-screen POIs) measured via TextMeasure, placed by
one `solve_cluster` call against each other, the sampled coastline, and the areals;
one `warm_solve` call against each other, the sampled coastline, and the areals;
- areals laid out glyph-by-glyph along an arc (each glyph MEASURED), their per-glyph
boxes added as obstacles.

Expand Down
29 changes: 29 additions & 0 deletions examples/atlas/test/golden/atlas-placement.rows.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
0|5|0|+|-
64|5|0|+|-
104|5|0|+|+
104|8|0|+|-
104|10|0|+|+
104|11|0|+|+
104|20|0|+|+
128|5|0|+|+
128|8|0|+|-
128|10|0|+|+
128|14|0|+|+
128|15|0|+|+
128|16|0|+|+
128|21|0|+|+
180|5|0|+|+
180|10|0|+|+
180|14|0|+|+
180|15|0|+|+
180|16|0|+|-
180|21|0|+|+
180|1001|0|-|-
180|1003|0|+|-
180|1004|0|-|+
180|1006|0|+|+
180|1007|0|+|-
180|1008|0|-|+
180|1009|0|-|+
180|1011|0|-|+
280|5|0|+|-
1 change: 1 addition & 0 deletions examples/atlas/test/golden/atlas-placement.sha256
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
475dfc5295a8e717375e92ee18cacb737c16b73cb09896af72a7c0342fe6627e
2 changes: 1 addition & 1 deletion examples/atlas/test/runtests.jl
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ using Atlas, Test

@testset "Atlas" begin
@testset "loads" begin
@test isdefined(Atlas, :solve_cluster) # MakieTextRepel internal API resolved
@test isdefined(Atlas, :warm_solve) # MakieTextRepel public API resolved
end
include("test_data.jl")
include("test_pois.jl")
Expand Down
30 changes: 29 additions & 1 deletion examples/atlas/test/test_golden.jl
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# SPDX-License-Identifier: MIT
using Atlas
using Atlas: golden_rows, atlas_digest, GOLDEN_FRAMES
using Atlas: golden_rows, atlas_digest, GOLDEN_FRAMES, placement_rows, atlas_placement_digest
using Test

# THE DETERMINISTIC GOLDEN. The gallery invariant: hash the COMPUTED layout table, NEVER pixels.
Expand Down Expand Up @@ -38,3 +38,31 @@ const GOLDEN_DIR = joinpath(@__DIR__, "golden")
@test isfile(path) # fails closed without the sha file
@test cs == strip(read(path, String)) # regression anchor
end

# Placement golden: the solver's DISCRETE decisions (which side each label leans + dropped),
# now that MakieTextRepel guarantees solver determinism (public warm_solve, PR #27). Hashes the
# side quadrant — NOT pixel offsets — so it's robust to sub-pixel projection drift: a quadrant only
# flips on a real re-placement. (It still routes through Makie, so it's robust-to-drift, not fully
# Makie-independent like the geometric table; offset magnitude/continuity is covered by test_loop's
# warm-start delta bound.) Update with `UPDATE_GOLDEN=1`.
@testset "golden: deterministic Atlas placement decisions (side + dropped, robust to projection drift)" begin
prows = placement_rows()
@test !isempty(prows)

ps = atlas_placement_digest()
@test length(ps) == 64 # sha256 hex

# non-vacuous: the solver actually distributes labels — not every label leans the same way.
@test any(r -> endswith(r, "|+|+"), prows) # some lean upper-right
@test any(r -> occursin("|-|", r), prows) # some lean left
@test length(prows) >= 20 # labels actually get placed across the dive

ppath = joinpath(GOLDEN_DIR, "atlas-placement.sha256")
if get(ENV, "UPDATE_GOLDEN", "") == "1"
mkpath(GOLDEN_DIR)
write(ppath, ps)
write(joinpath(GOLDEN_DIR, "atlas-placement.rows.txt"), join(prows, "\n"))
end
@test isfile(ppath) # fails closed without the sha file
@test ps == strip(read(ppath, String)) # regression anchor
end