Divided casing and one band¶
Status: the solver part is in compute_levels and the renderer draws the divided casing (section 9). The code has no known difference from this page. This page is the specification of the drawing order: the words, the model, the input
and output, the renderer, and the simple tags method.
Words¶
Data
| Word | Meaning |
|---|---|
| edge | one row of the input table: one line with one direction |
| road | one segment, both of its directions together (same end nodes, same vertices). The model works on roads; the result is copied to every edge of the road |
| node | the first or the last vertex of a line, compared by exact coordinates |
| meet | two roads meet when they share a node |
| near | two roads are near when they are within band_dist metres. Roads that meet, and roads that cross, are near (distance 0) |
| level | the integer from the tags of a road: the layer number, else bridge 1, tunnel −1, else 0 |
| band | the integer of a road that decides over and under: a higher band is over a lower band. It is the column band_col, or the level when band_col is not given |
| order | the number of a road (the column order, the road-class order, or the priority order: roundabout, tunnel, bridge, then class): where roads meet, the road with the higher order should have the later fill |
Painting
| Word | Meaning |
|---|---|
| casing | the outline of a road. Fill: its colour |
| casing number, fill number | when the casing, and when the fill, is painted. A higher number is painted later and lies on top. At the same number, all casings are painted before all fills. Each road has one fill number and three casing numbers (a head at each end and a main part) |
| head | the first or last head_m metres of a road: the part at a node. Main: the rest of the road |
| short road | a road shorter than 2 · head_m: it is only a head |
| ground | the number 0: the casing number that most roads have |
| cap | the shape of the end of a drawn line, round or flat. It is how one line is painted, not an extra object |
| blob | the extra circle added at the end of a two-way pair, to fill the notch between its two round lane ends. It is an additional part |
Requirements (section 5)
| Word | Meaning |
|---|---|
| merge | roads that meet have no outline across each other's colour: a clean junction |
| stack | an upper road is painted over a lower road, outline included. A stack pair (upper, lower) is a pair of near roads with different bands whose upper road is not short |
| order wish | where two roads of equal band meet, the one with the higher order has the later fill. A wish is kept when possible |
| given up | a stack pair that could not be satisfied. It is reported in levels_given_up |
1. The idea¶
Every road has one fill number and a casing in three parts: a head at each end and a main part in between.
start node ●━━ head ━━┿━━━━━━ main ━━━━━━┿━━ head ━━● end node
casing numbers: a_start a_main a_end one number for each part
fill number: b one number for the road
- A head is the first or last
head_mmetres of the road (default 5 m). It lies next to a node, so it decides how the road joins the roads that meet there. - The main part is the rest. It decides how the road lies over or under the roads near it.
Why: at a junction the casing must not be painted over the colour of the roads that meet there (a clean junction). Along its body, an upper road must be painted over a lower road, outline included. With one casing number a road that does both has a contradiction. With a head and a main part it does not: the heads join, the main part lies over.
The numbers are found by an optimization (section 7). Its main step is solved as a minimum-cost flow, which is fast on large networks (section 7.5.1).
2. The band¶
Every road has one band, an integer. A higher band is over a lower band.
- If
band_colis given, the band of a road is that column. - If not, the band is the level from the tags: the
layernumber, else bridge 1, tunnel −1, else 0.
The two sources are never mixed. The algorithm sees only the band.
3. Pairs of roads¶
Two roads are in one of four cases. Each case gets its own constraints (section 5).
| Case | The two roads | What applies |
|---|---|---|
| meet | share a node | Q2 Merge, always. Also Q4 (the order wish) if their bands are equal and their order numbers differ |
| cross | their lines cross and they share no node | Q3 Stack, if their bands differ and the upper road is not short (section 6) |
| near | are within band_dist metres (default 10), share no node and do not cross |
Q3 Stack, if their bands differ and the upper road is not short (section 6) |
| not related | none of the above | nothing |
The road with the higher band is the upper road of a Stack pair. Roads that meet are also within band_dist (distance 0), so a pair that meets and has different bands gets Q3 as well as Q2.
This is possible because the two constraints use different parts of the casing.
Which part answers to what:
| Part of the road | Answers to | Uses |
|---|---|---|
| head | Merge, and the order wish | the head's casing number and the fill number |
| main | Stack | the main part's casing number and the fill number |
The fill is one number for the whole road. An order wish exists only between meeting roads with equal bands, and a stack pair always has different bands. So a wish and a Stack never concern the same pair, and the order
never competes with the band. A failed wish (order_violations) can only come from wishes that contradict each other through other constraints.
4. The numbers of a road¶
| Number | For | Range |
|---|---|---|
b |
the fill of the road | real numbers from 0 to 2 · max_level (whole numbers in practice, section 7.6) |
a_start, a_main, a_end |
the casing parts of a road with length ≥ 2 · head_m |
the same |
a |
the whole casing of a short road (length < 2 · head_m), used at both its nodes |
the same |
s |
one per stack pair: the amount by which Q3 is violated (0 = kept) | real, ≥ 0 |
t |
one per order pair: the amount by which Q4 is violated (0 = kept) | real, ≥ 0 |
a@v means the casing number of a road at its node v: a_start at the start node, a_end at the end node, a for a short road. A road that starts and ends at the same node uses both heads at that node.
5. Constraints¶
A casing is painted before the fills of the same number, so "casing number ≤ fill number" means "not painted after".
| Case | Constraint | Meaning | |
|---|---|---|---|
| Q1 | every road (not a pair) | every casing number ≤ its own fill number b |
a casing is not painted after its own fill. The heads must stay low: a head is at a node, so it is also limited by Q2 and cannot be stacked over a lower road |
| Q2 | meet: roads x and y share a node v |
a_x@v ≤ b_y and a_y@v ≤ b_x, for each shared node |
Merge: the head of one road is not painted after the fill of the other |
| Q3 | cross or near, bands differ: u is the upper road, l the lower |
b_l + δ ≤ a_main_u, unless the pair is given up. Only for a long upper road (section 6) |
Stack: the upper road's main casing is painted after the lower road's fill. Only the main part is stacked, because the heads must stay low (Q1). A meet pair with different bands gets Q3 too |
| Q4 | meet, bands equal: x has a higher order number than y |
b_x ≥ b_y + δ, unless the wish is not kept |
the road with the higher order has the later fill |
Not related: no constraint.
How the optimization treats the requirements (section 7):
| Requirement | In the optimization |
|---|---|
| Q1 a casing is not after its own fill | a hard constraint (H1) |
| Q2 Merge at a meeting | a hard constraint (H2): it is never given up |
| Q3 Stack | a penalty in the objective (T1): the amount by which Q3 is violated is penalised, so Q3 is kept whenever the other constraints allow |
| Q4 the order wish | a penalty in the objective (T2): the amount by which Q4 is violated is penalised |
A requirement is what the drawing needs. A constraint (H) or a penalty term (T) is how the optimization model expresses it. The names are different on purpose.
Why the heads must stay low (Q1, Q2), and why Q3 stacks only the main part. A head is at a node, where the upper road joins the roads that meet there, and Q2 says its casing must not be painted after their colours. A head that was also stacked
over the lower road would have to be high, and then every road at that node would have to be high too. So the heads stay low and only the main part goes over the lower road.
Written for a head too, b_l + δ ≤ a_head_u would contradict Q2. Example: the head of a bridge u joins a ground road y (fill b_y = 0), and a ground road l passes under the bridge (fill b_l = 0).
Stack on the head needs a_head_u ≥ 1; Merge at the node needs a_head_u ≤ b_y = 0. Both hold only if b_y ≥ 1: the ground road y would have to be raised above l, although y and l are not related.
So on a head, Merge wins and Stack is not written.
The cost: a crossing or near stretch that lies inside a head (within head_m of a node) is not stacked (open point 2).
What follows from them:
- A road can be high in its middle and still join the roads at its ends. A bridge in one edge needs no ramp pieces, and the roads under it are not pushed down.
- Q3 and Q1 together give b_u > b_l: the upper road's fill is painted after the lower road's fill.
6. Short roads¶
A road shorter than 2 · head_m has no main part. Its whole casing is one head, with one number used at both nodes.
Such a road can be the lower road of a pair, but it is never stacked over another road, because it has no main casing to put over it. So a pair whose upper road is short is not a stack pair:
it gets no Q3, it is not counted as given up, and it is not reported as a failure. Merge (Q2) still holds for it if the roads meet.
The number of such pairs is reported separately, as short_upper_pairs in levels_info, so it is visible. Cost of the rule: a short road that really needs to lie over another road
(for example a 3 m bridge over a road) gets no stack.
7. The optimization problem¶
The model is one linear program with real variables and one objective Z. Section 5 lists the requirements Q1–Q4 of the drawing. The model expresses them as hard constraints
H1–H4 and as the terms T1–T3 of Z. How the program is solved is section 7.5. Q1 and Q2 are hard constraints. Q3 and Q4 are penalties, written with non-negative slack variables.
7.1 Data and sets¶
R the roads (both directions of a segment are one road)
C the casing parts: for a road r with length ≥ 2 · head_m (r, start), (r, main), (r, end); for a short road (r, whole)
M the meetings: (x, y, v) with x ≠ y, roads x and y share the node v
P the stack pairs: (u, l) with u, l ∈ R, distance(u, l) ≤ band_dist, band(u) > band(l), length(u) ≥ 2 · head_m
O the order pairs: (x, y) with x ≠ y, x and y share a node, band(x) = band(y), order(x) > order(y)
road(c) is the road that casing part c belongs to. a_(x@v) is the casing part of road x at its node v (its start head, its end head, or its whole casing if it is short). Pairs with a short upper road are not in P (section 6).
7.2 Variables¶
All variables are real numbers.
b_r fill number of road r in [0, 2L], L = max_level for every r ∈ R
a_c casing number of casing part c in [0, 2L] for every c ∈ C
s_p violation of Q3 for the stack pair p s_p ≥ 0 for every p ∈ P
t_k violation of Q4 for the order pair k t_k ≥ 0 for every k ∈ O
7.3 The model¶
The requirements say "painted after", which is a strict inequality. With real numbers it becomes "painted at least δ after", where δ is the parameter margin (default 1). The margin only fixes the scale: only the order of the numbers matters, and any
positive margin gives the same order.
minimise Z = W1 · T1 + W2 · T2 + T3
T1 = Σ_{p ∈ P} s_p total violation of Q3
T2 = Σ_{k ∈ O} t_k total violation of Q4
T3 = Σ_{c ∈ C} ( b_road(c) − a_c ) compaction: the casings close to their fills
W2 = |C| · 2L + 1 W2 · (one unit of T2) is more than the whole range of T3
W1 = W2 · |O| · (2L + δ) + 1 W1 · (one unit of T1) is more than the whole range of W2 · T2 + T3
subject to
H1 a_c ≤ b_road(c) for every c ∈ C (Q1)
H2 a_(x@v) ≤ b_y and a_(y@v) ≤ b_x for every meeting (x, y, v) ∈ M (Q2)
H3 b_l + δ − s_p ≤ a_(u, main) for every stack pair p = (u, l) ∈ P (Q3, with slack)
H4 b_y + δ − t_k ≤ b_x for every order pair k = (x, y) ∈ O (Q4, with slack)
0 ≤ b_r, a_c ≤ 2L; s_p, t_k ≥ 0
This is one objective (T4 and W4 below are there unless min_positions=False, section 7.3.1). The weights make it a strict priority: any solution with a smaller T1 is better than any with a larger T1 whatever T2 and T3 are; with equal T1, a smaller T2 wins; then T3.
When nothing needs to be violated, s = t = 0 and Z = T3: the stage 0 of section 7.5.
The weights are of the order of 10¹⁴ on a large network, too large for a floating-point solver. The code therefore gets the same optimum in stages (section 7.5): the smallest T1; then the smallest T2 with T1 held; then the smallest T3 with both held.
7.3.1 Fewest positions (min_positions, on by default)¶
A position is a value that some casing or fill number takes; the page has a set of layers for each position. Without a term for it, the number of positions is not minimised by the model above: only T3 is. The option min_positions (on by default) adds a fourth term, the span of the numbers, which is a linear stand-in for the number of positions
(the numbers are integers, so there are at most span + 1 distinct values):
minimise Z = W1 · T1 + W2 · T2 + W4 · T4 + T3
T4 = H − L the span: the highest number minus the lowest
W4 = ( Σ of the positive cost coefficients of T3 ) · 2L + 1 W4 · (one unit of T4) is more than the whole range of T3
with two more variables H and L, and the rows
x_v ≤ H and L ≤ x_v for every variable x_v (every fill and casing number)
0 ≤ H, L ≤ 2L
The priority is T1, then T2, then the span T4, then T3. So the option never gives up a stack or an order wish to save a position, but it does trade compaction (T3) for fewer positions: the casings may lie a little farther from their fills.
The new rows have the form x_i − x_j ≤ 0, like all the others, so stage 0 is still a minimum-cost flow (section 7.5.1); H and L have supplies +W4 and −W4, and the cost coefficients still sum to 0. In the stages 1–3 the term W4 · T4 is part of the cost of stage 3 (T3 is minimised together with it).
The two variables are linked to every other variable. That can make the flow harder or easier: the time is about the same as without the term on most networks, a few times longer on some, and shorter on a very large one. min_positions=False leaves the term out: the model of 7.3 with W4 = 0.
7.4 Requirements, constraints and the terms of the objective¶
| Requirement | Treated as | Constraint | Term of the objective |
|---|---|---|---|
| Q1 casing not after its own fill | hard constraint | H1 | none (T3 measures the distance that H1 keeps non-negative) |
| Q2 Merge at a meeting | hard constraint, never relaxed | H2 | none |
| Q3 Stack | penalty | H3, relaxed by the slack s_p |
T1 = Σ s_p |
| Q4 the order wish | penalty | H4, relaxed by the slack t_k |
T2 = Σ t_k |
| not a requirement: ranges as short as possible | preference | none | T3 |
Q2 has no term of its own. It is never relaxed, so Merge always holds. It appears in the objective only by limiting what T3 can reach (a head cannot be above the fill of the road it meets).
What a slack means. H3 says a_(u,main) − b_l ≥ δ − s_p. If s_p = 0 the stack is kept with the full margin. If 0 < s_p < δ the order is still kept (the upper casing is still after the lower fill), only with a smaller margin.
If s_p ≥ δ the upper casing is not after the lower fill: Q3 is violated and the pair is given up. t_k works the same way for Q4.
The penalty counts the amount, not the number. T1 is the total amount of violation. Where a conflict exists, the solution has the smallest total violation. This differs from "the fewest pairs given up", which is a harder combinatorial problem.
7.5 Solving¶
The problem of 7.3 is one linear program with the one objective Z. It is solved in steps, by what is needed, and each step has one solver.
| Step | What is solved | Solver | When |
|---|---|---|---|
| Stage 0 | the program without slack variables (s = t = 0, Z = T3) |
minimum-cost flow (OR-tools), section 7.5.1 | always tried first |
| Stage 0, not certified | the same program, with the bounds | HiGHS (7.5.3) | the flow cannot certify its answer (list in 7.5.1) |
| Stage 1 | minimise T1 | HiGHS | only when stage 0 has no solution |
| Stage 2 | minimise T2, with Σ s_p ≤ T1* |
HiGHS | same |
| Stage 3 | minimise T3, with Σ s_p ≤ T1* and Σ t_k ≤ T2* |
HiGHS | same |
levels_info["solver"] says flow when stage 0 was solved by the flow and highs otherwise. If stage 0 has a solution, nothing is violated and nothing is given up. If it has none, the stages 1–3 find the optimum of Z: the weights W1, W2 of 7.3 only express a priority,
and they are too large for a floating-point solver, so the three terms are minimised one after the other, each with the earlier ones held at their optimum (T1*, T2*, with a small tolerance). The optimum is the same as that of Z.
7.5.1 Stage 0 as a minimum-cost flow¶
In words. All the rows of the program say the same kind of thing: "this number is at most that number plus a constant". A program made only of such rows has a twin problem in graph form: how to send goods through a network at the smallest cost, a minimum-cost flow. A flow solver is much faster than a general solver, and from its answer the numbers of the roads are read back with a shortest-path pass.
The two programs side by side. Every row of the program has the form x_i − x_j ≤ c (a number is at most another number plus c; here c is 0, or −δ for Stack and the order wish, and with the default δ = 1 it is an integer), and the cost coefficients are integers that sum to 0
(T3 is the sum of b − a: each fill gets +1 and each casing −1).
the program (what we want) the flow (its twin)
minimise Σ_v cost_v · x_v minimise Σ_rows c · f_row
subject to x_i − x_j ≤ c for each row subject to f_row ≥ 0 for each row
(what leaves a node) − (what enters it) = − cost_v
one number x_v for each variable one node for each variable; one arc i → j of cost c for each row (i, j)
Each row becomes an arc from i to j with cost c. A node v has a supply of −cost_v units: a node with supply +1 sends one unit out, a node with supply −1 takes one unit in. The flow f_row is how much runs on the arc. Whatever the program's optimal value is,
the flow's optimal value is its negative (this is the duality of linear programming). The arcs have no upper limit on the flow (in the code: more than the total supply).
A small example. An upper road u over a lower road l. Variables: the casing a_u and fill b_u of u, the casing a_l and fill b_l of l. Rows: a_u ≤ b_u (H1), a_l ≤ b_l (H1), and Stack b_l + 1 ≤ a_u (H3 with δ = 1). The objective is (b_u − a_u) + (b_l − a_l).
rows arcs (i → j, cost c) supplies (− cost)
a_u − b_u ≤ 0 a_u → b_u, 0 a_u: +1 b_u: −1
a_l − b_l ≤ 0 a_l → b_l, 0 a_l: +1 b_l: −1
b_l − a_u ≤ −1 b_l → a_u, −1
The flow: a_u sends its unit to b_u over the first arc, a_l sends its unit to b_l over the second. Both arcs cost 0, so the flow costs 0, and so the program's optimum is 0: both casings sit exactly at their own fills.
From the flow back to the numbers. The flow tells which rows are tight (the arcs that carry flow). The numbers are then read off a graph of distances:
- Take a new node
Zand an arcZ → vof cost 0 to every variable. - For every row
x_i − x_j ≤ can arcj → iof costc(the row saysx_iis at mostx_j + c). - For every row that carries flow, also an arc
i → jof cost−c(the row is tight:x_i = x_j + c). x_vis the shortest distance fromZtov. It is found by relaxing all arcs at once, over and over (vectorised), until no distance changes.- The numbers are shifted so that the lowest is 0.
Such an x satisfies every row, with equality exactly where the flow runs. By complementary slackness this makes it an optimal solution of the program. All numbers are integers.
In the example: the arcs are b_u → a_u (0) and a_u → b_u (0), b_l → a_l (0) and a_l → b_l (0), and a_u → b_l (−1), plus Z to all (0). The distances are a_u = 0, b_u = 0, b_l = −1 (through a_u), a_l = −1. After the shift: a_l = b_l = 0 and a_u = b_u = 1: the upper road is painted after the lower one, as Stack asks.
The bounds 0 ≤ x ≤ 2 · max_level are not in the flow. Shifting x does not change the cost, so the lowest value is 0, and the bound that can matter is the highest value. It is checked after the relaxation.
Checks. The solution is checked against every row (an error if one fails: it would be a bug). The flow cannot certify its answer, and HiGHS solves the program instead, when:
the flow has no solution (the program has no bounded optimum without its bounds); an arc of the flow is saturated (a negative cycle: the rows have no solution); the data are not integers (for example a margin that is not an integer); a variable with a cost is in no row;
the relaxation does not converge in n + 2 rounds; or the highest value is above 2 · max_level.
Why the flow, and why only for stage 0. A flow solver uses the structure of the rows and is much faster than a general solver on this size. The stages 1–3 stay with HiGHS because their conditions "T1 held at its optimum" and "T2 held" are rows with many variables, not of the form x_i − x_j ≤ c, so they are not a flow.
One weighted flow with the weights W1, W2 of 7.3 is possible in principle (a slack becomes a capacity), but the weights reach 10¹⁴ on a large network, and the integer arithmetic of the flow solver (costs times the number of nodes) overflows. The stages 1–3 are only needed when requirements truly conflict, which is rare.
Several optima. The program often has several optimal solutions; the flow returns one of them and HiGHS another. The cost is the same; the numbers can differ in a few roads; the drawing satisfies the same requirements.
7.5.2 The preparation¶
Before any solver, the data are prepared with arrays, not loops over geometries: the end points of all edges are numbered by exact equality (numpy.unique on the coordinates), two edges are one road when their end nodes and their vertices, in one direction or the other, are the same,
the lengths and the bands come from columns, and the near pairs from one STRtree query. The rows of the program are then built from these.
7.5.3 HiGHS¶
HiGHS interior point with crossover, through scipy.optimize.linprog(method="highs-ipm"), on a sparse matrix. time_limit is passed to HiGHS. A solve that is not optimal raises an error. The interior point is used because the dual simplex stalls on large networks. Crossover ends at a vertex, so the numbers are still integers.
7.5.4 Bounds and the ground¶
The variables are non-negative, with an upper limit of 2 · max_level. The lower bound pins the lowest number at 0 (the cost does not change if every number is shifted by the same amount), so the solver returns the lowest layering.
Afterwards the numbers are shifted so that the casing number most roads have is 0, the ground (tunnels then get negative numbers).
7.6 The numbers¶
Every constraint compares two variables (x − y ≤ c), and the constants are 0 and −δ. Such a system has a vertex solution whose values are multiples of δ, and the simplex method returns a vertex. With the default δ = 1 the numbers are integers.
After solving, the values are rounded to 6 decimals. If any value is not within 10⁻⁶ of an integer (for example with δ = 0.5), the values are only ranked: equal values share a position.
7.7 What "given up" means¶
A stack pair is given up when s_p ≥ δ · (1 − 10⁻⁶): Q3 is violated for it. An order wish is not kept when t_k ≥ δ · (1 − 10⁻⁶). Pairs with a short upper road are not in P; they are counted separately as short_upper_pairs.
Without any violation (stage 0) nothing is given up.
8. Input and output¶
rs.compute_levels(edges, method="solve", band_col=None, order=None, band_dist=10.0, head_m=5.0, max_level=20, margin=1.0, time_limit=60.0)
Input¶
Data (what the function reads):
| Argument | What to give | Needed |
|---|---|---|
edges |
GeoDataFrame of road lines (LineString), any CRS; both directions of a road may be present | always |
band_col |
name of a column of integers (null = 0): the band of each road. If not given, the band is calculated from the tag columns below | optional |
layer_col, bridge_col, tunnel_col |
names of the OSM tag columns (defaults layer, bridge, tunnel), read for the band when band_col is not given, and for order="priority" |
optional |
order |
name of a column of numbers, "class" (the road-class order), or "priority" (roundabouts, then tunnels, then bridges, then the road-class order; the default of render_edges): the higher number wins where roads meet. A road with no number (null; for "class", no highway) takes no part in the order: no wish is made for it, with any road |
optional |
highway_col |
name of the road-class column (default highway), read only for order="class" and "priority" |
optional |
junction_col |
name of the OSM junction column (default junction), read only for order="priority": roundabout or circular is a roundabout. A missing column means no roundabouts |
optional |
Settings (how it runs):
| Argument | Default | Meaning |
|---|---|---|
method |
"solve" |
"solve" is the algorithm of this page. "tags" is the closed-form rule of section 10 (it ignores band_col and order) |
band_dist |
10 | metres: two roads closer than this, with different bands, are a stack pair |
head_m |
5.0 | metres: the length of each head; a road shorter than 2 · head_m is one head |
max_level |
20 | the range of the numbers: every casing and fill number is in [0, 2 · max_level] before the shift to the ground. A stack deeper than 2 · max_level / margin + 1 positions cannot be satisfied: the extra pairs are given up |
margin |
1.0 | δ: how much later a road must be painted where one must be painted after another. Only the order of the numbers matters, so it changes the scale and nothing else (with the range: the number of positions that fit). Must be greater than 0 |
min_positions |
True | the span term of 7.3.1: fewer positions (layers) in the page, a little less compaction; False leaves it out |
near_rules |
False | also lift the parts of a stack pair's upper road that only come near the lower one, as last-priority rules (off by default since 2026-10-08; an input of level_input / make since 2026-10-08, written as near rows, see level_input.md) |
time_limit |
60 | seconds for each LP solve |
Output¶
The same table as edges (same rows, order, index and columns) with four integer columns added. The input is not changed.
| Column | Meaning |
|---|---|
casing_start |
casing number of the start head |
casing_level |
casing number of the main part (the one the renderer draws today) |
casing_end |
casing number of the end head |
fill_level |
the fill number |
A short road has the same number in the three casing columns. The two directions of a road share the road's numbers, but casing_start belongs to the first vertex of the row's own geometry: the reversed direction has casing_start and casing_end swapped. The numbers are a ranking: a higher number is painted later. The casing number
that most roads have is 0 (the ground).
Two notes are also set in result.attrs:
| Key | Content |
|---|---|
levels_given_up |
list of (upper_row, lower_row): the stack pairs given up (s ≥ δ), as row positions in the table, counted from 0. A warning is also shown when the list is not empty |
levels_info |
dict: pairs (stack pairs, the set P), roads, short_roads (shorter than 2 · head_m), short_upper_pairs (near pairs with different bands whose upper road is short: not stack pairs, section 6), order_pairs, order_violations (order wishes not kept), solves (number of problems solved: 1 when nothing is violated, else 4; 0 when there is nothing to solve), solver (flow: stage 0 solved as a minimum-cost flow; highs: by HiGHS, section 7.5), status (OPTIMAL), seconds |
9. The renderer¶
The web renderer draws the divided casing. The code is in render_web.py (_mark_levels, _casing_parts, _level_layers, _twin_ends).
9.1 Input¶
rs.render_edges(edges, casing_level_col="casing_level", fill_level_col="fill_level",
casing_start_col="casing_start", casing_end_col="casing_end", head_m=5.0)
| Argument | Default | Meaning |
|---|---|---|
casing_level_col |
None |
column of the casing number of the main part |
fill_level_col |
None |
column of the fill number |
casing_start_col, casing_end_col |
None |
columns of the casing numbers of the start head and the end head. A column that is not given counts as the main number |
head_m |
5.0 | the length of each head in metres. Use the value given to compute_levels |
The numbers are read as follows: an empty or non-numeric value is 0, a float is rounded to an integer, the main casing number is never above the fill number (if it is, the two are swapped), and a head number is never above the fill number (it is lowered to it). The page has one pair of layers for each position that occurs in any of the four columns.
The heads are drawn only when casing_start_col or casing_end_col is given. Without them the casing is one line per edge at the main number, as before. tiles=True works with them (section 12.1).
9.2 The pieces of a casing¶
The fill is one line per edge, in the source roads, at its fill number. The casing comes from its own source, casings, as pieces:
| Edge | Casing pieces |
|---|---|
a LineString of length ≥ 2 · head_m whose three casing numbers are not all equal |
three: the first head_m metres at the start number, the middle at the main number, the last head_m metres at the end number |
a LineString shorter than 2 · head_m whose three numbers are not all equal |
two: the first half at the start number, the second half at the end number (short_road_heads.md) |
| a LineString whose three numbers are equal | one, the whole edge |
| any other geometry | one, as it is |
How the cut is made: the line is projected to local metres (x · 111320 · cos(latitude), y · 111320), cut at head_m from each end, and projected back; coordinates are rounded to 7 decimals. A piece that
comes out empty is dropped.
The ends of the pieces. The line ends are round everywhere in roadstyle, because a round end seals the seam where two roads meet. In the divided casing the main piece ends at two cuts, not at a node. Its ends are
flat (butt): a round end there would reach into the head zone by half the casing width (a fixed number of pixels, many metres when zoomed out), and the main piece is painted above the heads, so the round end could cover the
fill of a road that meets at the node. The heads and the unsplit casings keep round ends: a head is painted low, so it cannot cover a neighbour's fill. The flat ends use the existing flat-end layers of cap_col
(roads-casing-sq): the main piece carries __rs_cap = true. The cost is a small gap at the cut where the road bends sharply.
A piece carries the properties that the casing layers read: every __rs_* property except the fills, the road-class column, the filter column, the metre-width column, and lvl; its own casing number as
__rs_cl; and __rs_road, the id of its edge (the index of the edge in the data). Popup columns are not copied, so the source is small: about one piece more than edges.
casing_start belongs to the first vertex of the edge's own geometry, and casing_end to its last. The two directions of a road run opposite ways, so compute_levels gives the reversed direction its two head numbers swapped.
9.3 The layers and their order¶
For every position p, in ascending order, the page has these layers, in this order:
| Order | Layer | Source | Draws | Filter |
|---|---|---|---|---|
| 1 | blob casing roads-ends-casing (-lv<p> for p ≠ 0) |
ends |
the round casing at the ends of two-way pairs | __rs_cl == p |
| 2 | casing layers roads-casing, -sq, -dash, and roads-casing-bridge for bridges (-lv<p>) |
casings |
the casing pieces; -sq (flat ends) draws the main pieces, the others are round |
__rs_cl == p |
| 3 | fill layers roads-fill, -sq, -pat, -dash<n> (-lv<p>); the blob fill roads-ends-fill just before roads-fill |
roads, ends |
the fills | __rs_fl == p |
| 4 | arrows roads-arrows (-lv<p>) |
slots |
one-way arrows | fl == p |
| 5 | names roads-labels (-lv<p>) |
slots |
street names | fl == p |
So at every position all casings are painted first, then all fills, and a higher position is painted over a lower one. Within a layer the order is not defined: the sort key is the constant 0. The
road class, layer, bridge and tunnel do not order anything (they are inputs of the solver, section 12).
Twin end blobs. A two-way road is drawn as two lanes. Where a plain pair ends at a node, the two round lane ends leave a notch, and a twin end blob (a circle centred on the node, wide as both lanes) fills it. The blob is an extra drawing added at the end of the pair, not a change of the lines. It has a casing ring and a fill circle:
| Part of the blob | Painted at | Why |
|---|---|---|
| fill circle | the fill number of the road (one number for both lanes) | the same as the lanes' fills |
| casing ring | the head number at that node: casing_start or casing_end of the lane that ends there |
the ring sticks out past the node, like a head, so it must be low like the head. At the main number it could cover the fill of a road that meets at the node |
The ring is hidden where another road at that node has a fill number below the head number, because the ring would then cut across that road.
Which pairs get a blob: only plain pairs with round ends. A bridge, a tunnel, a dashed class, and a pair drawn flat with cap_col get none. At the cuts of a divided casing there is no blob: no two lanes end side by side there.
Bridges. The bridge look is drawn in the casing of its position: for every position that has a bridge edge there is one more casing layer, roads-casing-bridge (-lv<p>), from the same source as the other casing
layers. It draws the casing pieces of the edges tagged bridge with the bridge casing colour (black by default, bridge_casing_color), the heavier bridge width and flat ends. The other casing layers leave bridge edges to it.
A bridge is still painted at its own numbers; the fill is the normal fill.
Layers that exist but draw nothing in this mode: the low, high and bridge band layers (their filters match nothing). The 3D bridge decks of view_3d are a separate shape and still follow the tags.
9.4 The page¶
- Colour by, rsColor: they recolour the fill layers of every position. The casing keeps its palette colour.
- Class filter and queries: the class filter applies to the casing layers too. A query by road id also hides the casing pieces of the hidden edges (through
__rs_road). - Hover and selection: they work on the
roadssource (the fill line), not on the casing pieces. rsColorlift: it raises only the fill of the coloured roads within their position. The casing pieces are not lifted.
10. The tags method¶
compute_levels(method="tags") (not the default) is a closed-form rule on the tags and on which roads share a node. No search, no solver, O(number of edges). It ignores band_col and order and raises an error if they are given.
Level of a road, L(r): the layer number if it is a non-zero number, else bridge = 1, tunnel = −1, else 0. (bridge and tunnel are true unless null, NaN, "", "no", "false", "0", 0 or False.)
Level of a node, N(v): let S(v) be the levels of the roads that end at the node v. N(v) is the value in [min S(v), max S(v)] nearest to 0:
So a node where a ground road ends, or where one road going up and one going down end, is 0; otherwise it takes the level nearest the ground.
The numbers of a road:
casing = min( N(start), N(end), L ) the three casing numbers are equal
fill = max( N(start), N(end), L )
Example (bundled sample notebooks/data/sodermalm_edges.gpkg, rows counted from 0): one direction along Centralbron in Stockholm, the bridge, then the Söderledstunneln tunnel, then a ground road.
| Row | Edge | L |
Casing | Fill | Why |
|---|---|---|---|---|---|
| 2134 | Centralbron, bridge, 132 m | 1 | 1 | 1 | only bridge edges end at both nodes: N = 1 |
| 828 | Centralbron, bridge, 132 m | 1 | 0 | 1 | a tunnel edge also ends at its end node (levels 1 and −1): N = 0 |
| 1558 | Söderledstunneln, layer=-1, 180 m |
−1 | −1 | 0 | its start node is that same node: N = 0 |
| 440 | Söderledstunneln, layer=-2, 652 m |
−2 | −2 | −1 | edges of levels −1 and −2 end at the node it shares with row 1558: N = −1 |
| 2219 | Söderledstunneln, layer=-2, 578 m |
−2 | −2 | −1 | both nodes are at −1 |
| 2469 | Söderledstunneln, layer=-2, 185 m |
−2 | −2 | 0 | a ground road ends at its end node: N = 0 |
| 2468 | Söderleden, ground, 29 m | 0 | 0 | 0 | a ground road is always [0, 0] |
Why Merge holds. Two roads sharing the node v both have N(v) among the three values behind their numbers, so N(v) lies between the casing and the fill of both: neither casing is after the other's fill.
Limits. It sees only tagged levels, so a crossing without tags is drawn flat. A bridge stored as one edge from ground to ground gets casing 0 and fill L: its colour is over the road below, but its casing is not after that road's fill, so it has no outline over it.
Skanstullsbron in the sample is such a bridge (row 207: [0, 3]); with method="solve" its main casing is over the roads under it.
11. Storage in the duckOSM file¶
The solve takes long on a large network, so the result can be saved with the data and read back, instead of being computed at every render. The code is in levels_store.py; it needs the duckdb extra.
Where. In the area's own .duckdb file, in a new schema visualization. The schemas of a duckOSM file each hold one kind of layer (driving, walking, cycling: networks; features: OSM features; raw: the import). The
schema visualization holds layers computed for drawing.
| Table | Columns | Content |
|---|---|---|
visualization.edge_levels |
edge_id BIGINT, casing_start, casing_level, casing_end, fill_level (INTEGER) |
one row for every edge, keyed by the content hash edge_id |
visualization.edge_levels_meta |
one row: method, head_m, band_dist, margin, max_level, band_source, order_source, min_positions, n_edges, edge_hash, roadstyle_version, created |
what the numbers were computed with |
band_source is "tags" or the name of the band_col; order_source is the order argument (a column name, "class", or null); min_positions is true, or empty when it was off.
levels = rs.compute_levels(roads, method="solve", order="class") # roads: one row per edge_id, with the edges of all modes
rs.save_levels(con, levels) # con: an open, writable duckdb connection
levels = rs.load_levels(con, roads, order="class") # roads in any order; the expected parameters, with the names and defaults of compute_levels
table = rs.load_levels(con, order="class") # without edges: the table edge_id + the four columns
Rules
- The numbers are computed on the roads of all modes together (one row per edge_id, as in mapstyle's load_roads), because the order depends on all of them. They are not computed per mode.
- save_levels takes the result of compute_levels, which carries its own parameters in result.attrs["levels_params"], so the stored parameters cannot differ from the computation. It replaces both tables. A frame that does not come from
compute_levels, or whose edge_id is not unique, is refused.
- Saving and reading are explicit calls. Nothing is written while rendering.
- load_levels compares the stored parameters with the ones you expect, and, if you give edges, the number and a hash of the edge_id values (edge_hash, over the sorted ids) with the stored ones. If anything differs, or the table does not exist, it raises a
ValueError that says what differs. It never recomputes silently. The hash catches a changed set of edges, not a changed geometry.
- With edges, the result is a copy of edges (in its own row order) with the four columns added.
12. The only way of drawing¶
render_edges(edges) draws by positions and by nothing else.
- No level columns given.
render_edgesfirst callscompute_levels(edges, method="solve", order="class", band_col=band_col)(section 7) on the edges it is given, and draws the result. The result is the same as computing the columns yourself with these arguments. - Level columns given (
casing_level_col,fill_level_col, and the head columns). They are drawn as they are; nothing is computed. - Removed. The three bands (low, ground, high), the class order (
ROAD_Zas a draw order,line-sort-key),order_col, and the separate bridge layers. A road class now acts only throughorder="class"in the solver.band_colstays as an input of the solver (section 2), not as a drawing rule.cap_colstays. - scipy and ortools are core dependencies of roadstyle (no extra).
- 3D (
view_3d). The extruded decks follow the bridge tags. Belowflat_belowthe bridge is its flat line, drawn at its positions; fromflat_belowup that flat line (its casing layer and the bridge edges in the position layers) is hidden and the deck is shown. - Errors, no silent fallback. If the solver finds no solution in
time_limit,compute_levelsraises (as in section 7.5);render_edgesdoes not draw by another rule. tiles=Trueworks with positions (section 12.1).- Time. Computing the positions takes long on a large network. It should be computed once with
compute_levels, saved withsave_levels, and drawn with the columns.
12.1 Tiles¶
tiles=True packs the roads in one PMTiles archive (module tiles.py; the page reads only the tiles in view). With positions the archive has these tile layers, all in the one source roads:
| Tile layer | Features | Made like |
|---|---|---|
roads |
one line per edge (fill, arrows' edge, popups) | as before: simplified per zoom, clipped to the tile |
casings |
the casing pieces of section 9.2 (main piece and heads) | the same as roads: simplified, clipped |
ends |
the twin end caps (points) | whole, once, as slots |
slots |
the labels and arrows | as before |
- A layer of the page whose source was
casingsorendsreads the same tile layer of the sourceroads(source-layer). Nothing else about the layers changes: same ids, same filters, same positions. - The pieces and caps carry the properties the layers read (
__rs_*, the class column,lvl, the width column), and their edge's id as__rs_road/__rs_road2. - The page's filters (class filter,
rsFilter,rsColor) pick a layer's kind by its tile layer name when tiled (roads,casings,ends), by its source name when not. - The positions are computed for the whole network, also with tiles. The page has one set of layers for all tiles on screen, so a number of one tile is compared with the numbers of the next tile at every border. Tiles solved one by one cannot guarantee that two roads that meet at a border have numbers in the right order: the height of a group of roads is free in each solve. So the tiles hold numbers of one solve.
- The archive grows with the pieces: the casing layer has about three features per edge. (open point 8).
13. Open points¶
- A short upper road. Its pairs are not stack pairs (section 6). A short road that truly needs to lie over another road gets no stack.
short_upper_pairsshows how many pairs this concerns. - A crossing inside a head. A crossing can lie within
head_mof a node, where the casing is the head, not the main part. Stack as written (on the main part) is then not what is seen. - Default
head_m. 5 m is the default. It is not a tuned value: other values were not compared at every zoom. A larger value skips more pairs (section 6). - Conflicts. With order wishes only between roads of equal bands, a conflict is rare, so stage 0 (one problem) usually succeeds. The stages 1–3 are tested only with a range that is too small.
- Positions in use. The number of positions is not minimised. Only the total distance between casings and fills is (T3).
- Weight of a part. Each casing part counts once in T3, whatever its length in metres.
- Seams. Where the three pieces of a casing meet, the line caps could show a seam when the three numbers differ. Not checked.
- Tiles. Size and opening time of a large map, tiled against inline, are not compared. MapLibre places arrows and street names per tile, so their places differ from the inline map.