mapstyle 0.3.0__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. {mapstyle-0.3.0/src/mapstyle.egg-info → mapstyle-0.4.0}/PKG-INFO +5 -8
  2. {mapstyle-0.3.0 → mapstyle-0.4.0}/README.md +3 -3
  3. {mapstyle-0.3.0 → mapstyle-0.4.0}/pyproject.toml +3 -4
  4. {mapstyle-0.3.0 → mapstyle-0.4.0}/src/mapstyle/__init__.py +1 -1
  5. {mapstyle-0.3.0 → mapstyle-0.4.0}/src/mapstyle/map.py +56 -28
  6. {mapstyle-0.3.0 → mapstyle-0.4.0/src/mapstyle.egg-info}/PKG-INFO +5 -8
  7. {mapstyle-0.3.0 → mapstyle-0.4.0}/src/mapstyle.egg-info/SOURCES.txt +0 -1
  8. {mapstyle-0.3.0 → mapstyle-0.4.0}/src/mapstyle.egg-info/requires.txt +1 -5
  9. {mapstyle-0.3.0 → mapstyle-0.4.0}/tests/test_map.py +54 -92
  10. mapstyle-0.3.0/src/mapstyle/node_levels.py +0 -526
  11. {mapstyle-0.3.0 → mapstyle-0.4.0}/LICENSE +0 -0
  12. {mapstyle-0.3.0 → mapstyle-0.4.0}/setup.cfg +0 -0
  13. {mapstyle-0.3.0 → mapstyle-0.4.0}/src/mapstyle/dashboard.js +0 -0
  14. {mapstyle-0.3.0 → mapstyle-0.4.0}/src/mapstyle/icons/bicycle.svg +0 -0
  15. {mapstyle-0.3.0 → mapstyle-0.4.0}/src/mapstyle/icons/bus.svg +0 -0
  16. {mapstyle-0.3.0 → mapstyle-0.4.0}/src/mapstyle/icons/crossing.svg +0 -0
  17. {mapstyle-0.3.0 → mapstyle-0.4.0}/src/mapstyle/icons/parking_p.svg +0 -0
  18. {mapstyle-0.3.0 → mapstyle-0.4.0}/src/mapstyle/icons/traffic_light.svg +0 -0
  19. {mapstyle-0.3.0 → mapstyle-0.4.0}/src/mapstyle/icons/train.svg +0 -0
  20. {mapstyle-0.3.0 → mapstyle-0.4.0}/src/mapstyle/layers.js +0 -0
  21. {mapstyle-0.3.0 → mapstyle-0.4.0}/src/mapstyle/levels.py +0 -0
  22. {mapstyle-0.3.0 → mapstyle-0.4.0}/src/mapstyle/patterns.py +0 -0
  23. {mapstyle-0.3.0 → mapstyle-0.4.0}/src/mapstyle/planner.html +0 -0
  24. {mapstyle-0.3.0 → mapstyle-0.4.0}/src/mapstyle/style.py +0 -0
  25. {mapstyle-0.3.0 → mapstyle-0.4.0}/src/mapstyle/styles/layers.yaml +0 -0
  26. {mapstyle-0.3.0 → mapstyle-0.4.0}/src/mapstyle/styles/modes.yaml +0 -0
  27. {mapstyle-0.3.0 → mapstyle-0.4.0}/src/mapstyle/styles/osm_carto.yaml +0 -0
  28. {mapstyle-0.3.0 → mapstyle-0.4.0}/src/mapstyle/styles/paths.yaml +0 -0
  29. {mapstyle-0.3.0 → mapstyle-0.4.0}/src/mapstyle/styles/themes/google.yaml +0 -0
  30. {mapstyle-0.3.0 → mapstyle-0.4.0}/src/mapstyle/styles/themes/grey.yaml +0 -0
  31. {mapstyle-0.3.0 → mapstyle-0.4.0}/src/mapstyle.egg-info/dependency_links.txt +0 -0
  32. {mapstyle-0.3.0 → mapstyle-0.4.0}/src/mapstyle.egg-info/entry_points.txt +0 -0
  33. {mapstyle-0.3.0 → mapstyle-0.4.0}/src/mapstyle.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mapstyle
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: A whole OpenStreetMap map in one offline HTML page: roads for every travel mode over a full base map, from a duckOSM database
5
5
  Author: Kaveh Khoshkhah
6
6
  License-Expression: MIT
@@ -18,18 +18,15 @@ Classifier: Topic :: Scientific/Engineering :: Visualization
18
18
  Requires-Python: >=3.10
19
19
  Description-Content-Type: text/markdown
20
20
  License-File: LICENSE
21
- Requires-Dist: roadstyle>=0.12.0
21
+ Requires-Dist: roadstyle>=0.14.0
22
22
  Requires-Dist: duckdb
23
23
  Requires-Dist: geopandas
24
24
  Requires-Dist: pyyaml
25
25
  Requires-Dist: pillow
26
26
  Provides-Extra: tiles
27
27
  Requires-Dist: roadstyle[tiles]; extra == "tiles"
28
- Provides-Extra: solver
29
- Requires-Dist: ortools>=9.10; extra == "solver"
30
28
  Provides-Extra: dev
31
29
  Requires-Dist: pytest; extra == "dev"
32
- Requires-Dist: ortools>=9.10; extra == "dev"
33
30
  Provides-Extra: docs
34
31
  Requires-Dist: mkdocs<2,>=1.6; extra == "docs"
35
32
  Requires-Dist: mkdocs-material<10,>=9.5; extra == "docs"
@@ -98,9 +95,9 @@ ms.render_map("monaco.duckdb", dashboard=True).save("dashboard.html") # a dashbo
98
95
  - **A full base map from the same database**: the sea, land use with openstreetmap.org's textures,
99
96
  water, buildings, railways, parking, transit, traffic lights and crossings. No tile server, no API key.
100
97
  - **Roads drawn right at junctions**: two-way roads as two lanes, bridges and overpasses over, tunnels
101
- under, crossings over their street. The drawing order of the roads is solved (an interval per road,
102
- `render_map(order=True)`; `pip install mapstyle[solver]` for the exact solution), so a joint of
103
- roads at different levels shows no ring.
98
+ under, crossings over their street. The drawing order of the roads is [roadstyle's](https://github.com/Khoshkhah/roadstyle):
99
+ a casing number and a fill number for every road, read from the database when `duckosm levels` stored them, else computed
100
+ while the page is made, so a joint of roads at different levels shows no ring.
104
101
  - **A route planner demo**: drag a start and an end and see duckOSM's route on the map (routing
105
102
  itself is duckOSM's job).
106
103
  - **A dashboard**: filter by mode, road class and layer, colour by mode, inspect what you click.
@@ -60,9 +60,9 @@ ms.render_map("monaco.duckdb", dashboard=True).save("dashboard.html") # a dashbo
60
60
  - **A full base map from the same database**: the sea, land use with openstreetmap.org's textures,
61
61
  water, buildings, railways, parking, transit, traffic lights and crossings. No tile server, no API key.
62
62
  - **Roads drawn right at junctions**: two-way roads as two lanes, bridges and overpasses over, tunnels
63
- under, crossings over their street. The drawing order of the roads is solved (an interval per road,
64
- `render_map(order=True)`; `pip install mapstyle[solver]` for the exact solution), so a joint of
65
- roads at different levels shows no ring.
63
+ under, crossings over their street. The drawing order of the roads is [roadstyle's](https://github.com/Khoshkhah/roadstyle):
64
+ a casing number and a fill number for every road, read from the database when `duckosm levels` stored them, else computed
65
+ while the page is made, so a joint of roads at different levels shows no ring.
66
66
  - **A route planner demo**: drag a start and an end and see duckOSM's route on the map (routing
67
67
  itself is duckOSM's job).
68
68
  - **A dashboard**: filter by mode, road class and layer, colour by mode, inspect what you click.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "mapstyle"
7
- version = "0.3.0"
7
+ version = "0.4.0"
8
8
  description = "A whole OpenStreetMap map in one offline HTML page: roads for every travel mode over a full base map, from a duckOSM database"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -22,7 +22,7 @@ classifiers = [
22
22
  ]
23
23
 
24
24
  dependencies = [
25
- "roadstyle>=0.12.0", # the page (casing_level_col / fill_level_col, band_col, directed_col, cap_col)
25
+ "roadstyle>=0.14.0", # the page and the drawing order (compute_levels / load_levels, band_col, directed_col, cap_col)
26
26
  "duckdb", # reads the duckOSM db
27
27
  "geopandas",
28
28
  "pyyaml", # styles/*.yaml
@@ -31,8 +31,7 @@ dependencies = [
31
31
 
32
32
  [project.optional-dependencies]
33
33
  tiles = ["roadstyle[tiles]"] # --tiles / render_map(tiles=True)
34
- solver = ["ortools>=9.10"] # the drawing order of the roads (order=True) is solved exactly; without it a heuristic is used
35
- dev = ["pytest", "ortools>=9.10"]
34
+ dev = ["pytest"]
36
35
  docs = ["mkdocs>=1.6,<2", "mkdocs-material>=9.5,<10", "mkdocs-llmstxt>=0.5,<0.6"]
37
36
 
38
37
  [project.urls]
@@ -2,5 +2,5 @@
2
2
 
3
3
  from mapstyle.map import MODES, load_layers, load_roads, render_map
4
4
 
5
- __version__ = "0.3.0"
5
+ __version__ = "0.4.0"
6
6
  __all__ = ["MODES", "load_layers", "load_roads", "render_map", "__version__"]
@@ -374,8 +374,43 @@ def planner_data(db, roads):
374
374
  return data
375
375
 
376
376
 
377
+ def _band(roads):
378
+ """The band of every road: below (-1 ...), on (0) or over (1 ...) the ground (docs/design/stored_levels.md). It is complete, because roadstyle's ``band_col``
379
+ replaces the level from the tags: the OSM ``layer`` if that is a number, else 1 for a bridge, -1 for a tunnel, else 0; except for a path with a
380
+ ``walk_type``: a sidewalk is -1 (under its street) and a crossing 1 (over it). The same as duckOSM's (``duckosm levels``)."""
381
+ import numpy as np
382
+ import pandas as pd
383
+
384
+ layer = pd.to_numeric(roads["layer"], errors="coerce").fillna(0).astype(int).to_numpy()
385
+
386
+ def yes(col):
387
+ return (roads[col].notna() & ~roads[col].astype(str).isin(["", "no", "None", "nan"])).to_numpy()
388
+ tags = np.where(layer != 0, layer, np.where(yes("bridge"), 1, np.where(yes("tunnel"), -1, 0)))
389
+ path = (roads["highway"].isin(PATH_CLASSES) & roads["walk_type"].isin(["sidewalk", "crossing"])).to_numpy()
390
+ return np.where(path, roads["walk_type"].map({"crossing": 1, "sidewalk": -1}).fillna(0).astype(int).to_numpy(), tags).astype(int)
391
+
392
+
393
+ def stored_levels(db, roads):
394
+ """``roads`` with the four columns of ``visualization.edge_levels`` (``casing_start``, ``casing_level``, ``casing_end``, ``fill_level``), or None when the file has no such
395
+ table. The numbers must be those computed for these roads with ``duckosm levels``' defaults: if they are not (the file was rebuilt, other options), a ``ValueError`` says what
396
+ differs and that ``duckosm levels`` must be run again. Nothing is recomputed (docs/design/stored_levels.md)."""
397
+ import duckdb
398
+ import roadstyle as rs
399
+
400
+ con = duckdb.connect(str(db), read_only=True)
401
+ try:
402
+ if not con.execute("SELECT count(*) FROM information_schema.tables WHERE table_schema = 'visualization' AND table_name = 'edge_levels'").fetchone()[0]:
403
+ return None
404
+ try:
405
+ return rs.load_levels(con, roads, band_col="band", order="class")
406
+ except ValueError as e:
407
+ raise ValueError(f"{e}; run `duckosm levels {db}` again") from None
408
+ finally:
409
+ con.close()
410
+
411
+
377
412
  def render_map(db, mode=None, layers=True, planner=False, dashboard=False, interaction=None,
378
- paths=PATHS, theme="osm", order=True, pieces=False, **kwargs):
413
+ paths=PATHS, theme="osm", pieces=False, **kwargs):
379
414
  """``rs.render_edges`` of the db's roads (all modes' edges) in the ``mode``'s style, over the
380
415
  ``features.*`` base map, with mapstyle's rs* functions (``rsSetModes``, ``rsSetKinds``,
381
416
  ``rsSetInteraction``: layers.js). ``layers``: True = every styles/layers.yaml layer, a list of
@@ -386,15 +421,16 @@ def render_map(db, mode=None, layers=True, planner=False, dashboard=False, inter
386
421
  kind and interaction filters (docs/design/dashboard.md). ``mode``: which network stands out
387
422
  (all, driving, walking, cycling); default all (the planner too: in the walking look a road's two directions are one line, so only one can be clicked).
388
423
  ``theme``: the whole map's colours, ``osm`` (default) or a styles/themes/*.yaml
389
- (docs/design/themes.md). ``order``: give each road the position in the drawing order of its casing and of its fill
390
- (``mapstyle.node_levels``, docs/design/node_levels.md; roadstyle's ``casing_level_col`` / ``fill_level_col``), so that connected
391
- roads merge cleanly and a road that passes over another is drawn over it; False: roadstyle's own bands. ``pieces``: the other
392
- approach, cutting a road with a level into pieces (docs/design/levels_plan.md); not the default, and not with ``order``.
424
+ (docs/design/themes.md). The drawing order of the roads is roadstyle's (docs/design/stored_levels.md): every road has a casing number and a fill number, so that connected
425
+ roads merge cleanly and a road that passes over another is drawn over it. They are read from ``visualization.edge_levels`` when the file has them (``duckosm levels``),
426
+ else roadstyle computes them while it renders. ``pieces``: the other approach, cutting a road with a level into pieces (docs/design/levels_plan.md); not the default.
393
427
  The base map is ``blank``: the db's own layers are the map (the sea is ``features.ocean``).
394
428
  ``kwargs`` go to roadstyle
395
429
  (``basemap``, ``tiles``, ``arrows``, ...)."""
396
430
  import roadstyle as rs
397
431
 
432
+ if "order" in kwargs:
433
+ raise ValueError("render_map(order=...) is removed: the drawing order is roadstyle's (docs/design/stored_levels.md)")
398
434
  mode = mode or "all"
399
435
  if planner and kwargs.get("tiles"):
400
436
  raise ValueError("planner=True can't use tiles=True: the planner snaps to the roads in the page")
@@ -436,32 +472,24 @@ def render_map(db, mode=None, layers=True, planner=False, dashboard=False, inter
436
472
  "Road class": {}, "Modes": {"color_by": "modes", "colors": load_style("modes")["mode_colors"]}})
437
473
  html += f"<script>{(_HERE / 'dashboard.js').read_text()}</script>"
438
474
  render = rs.render_report
439
- # a crossing (the zebra) draws over the street it crosses, a sidewalk under the street beside
440
- # it, casings included (roadstyle's band_col; roadstyle/docs/design/draw_order_per_edge.md).
441
- # Paths only: duckOSM also marks a car road "sidewalk" when you walk along its own sidewalk,
442
- # and that road must stay with the other streets (708 road edges in Monaco).
443
- path = roads["highway"].isin(PATH_CLASSES)
444
- roads["_band"] = roads["walk_type"].map({"crossing": 1, "sidewalk": -1}).where(path)
445
- kw["band_col"], kw["cap_col"] = "_band", "_cap"
446
- roads["_cap"] = False
475
+ roads["band"] = _band(roads)
447
476
  if pieces:
448
- # a road with a level (a layer tag, a tunnel) is ground except where it really passes over or under a road: cut
449
- # into pieces for drawing, square-ended (docs/design/levels_plan.md; roadstyle's cap_col)
477
+ # a road with a level (a layer tag, a tunnel) is ground except where it really passes over or under a road: cut into pieces for drawing, square-ended
478
+ # (docs/design/levels_plan.md; roadstyle's cap_col). The band of a piece is ``_band``; a crossing over its street, a sidewalk under it (paths only)
479
+ path = roads["highway"].isin(PATH_CLASSES)
480
+ roads["_band"] = roads["walk_type"].map({"crossing": 1, "sidewalk": -1}).where(path)
481
+ kw["band_col"], kw["cap_col"] = "_band", "_cap"
482
+ roads["_cap"] = False
450
483
  from mapstyle.levels import with_pieces
451
484
  roads = with_pieces(roads, *load_pieces(db, roads))
452
- elif order and kwargs.get("tiles"):
453
- log.warning("tiles=True: the drawing order (order=True) is not supported with vector tiles yet; roadstyle's bands are used")
454
- elif order:
455
- # the drawing order of the casing and of the fill of every road (docs/design/node_levels.md). The positions are doubled,
456
- # so a crossing (the zebra) can sit between the ground and the next position, over its street with its halo, and a mapped
457
- # sidewalk under it (what band_col did)
458
- from mapstyle.node_levels import compute, with_cuts
459
- roads = with_cuts(roads, compute(db))
460
- for band, pos in ((1, 1), (-1, -1)):
461
- m_ = roads["_band"] == band
462
- roads.loc[m_, ["_cl", "_fl"]] = pos
463
- kw["casing_level_col"], kw["fill_level_col"] = "_cl", "_fl"
464
- kw.pop("cap_col")
485
+ else:
486
+ # the drawing order: the numbers stored in the file, else roadstyle computes them from the band (docs/design/stored_levels.md)
487
+ stored = stored_levels(db, roads)
488
+ if stored is None:
489
+ kw["band_col"] = "band"
490
+ else:
491
+ roads = stored
492
+ kw.update(casing_level_col="casing_level", fill_level_col="fill_level", casing_start_col="casing_start", casing_end_col="casing_end")
465
493
  # duckOSM stores every path, and a one-way street's walking-only reverse, as a reverse edge too:
466
494
  # roadstyle draws a pair as two lanes only when both edges are directed (directed_col,
467
495
  # twin_ends.md)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mapstyle
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: A whole OpenStreetMap map in one offline HTML page: roads for every travel mode over a full base map, from a duckOSM database
5
5
  Author: Kaveh Khoshkhah
6
6
  License-Expression: MIT
@@ -18,18 +18,15 @@ Classifier: Topic :: Scientific/Engineering :: Visualization
18
18
  Requires-Python: >=3.10
19
19
  Description-Content-Type: text/markdown
20
20
  License-File: LICENSE
21
- Requires-Dist: roadstyle>=0.12.0
21
+ Requires-Dist: roadstyle>=0.14.0
22
22
  Requires-Dist: duckdb
23
23
  Requires-Dist: geopandas
24
24
  Requires-Dist: pyyaml
25
25
  Requires-Dist: pillow
26
26
  Provides-Extra: tiles
27
27
  Requires-Dist: roadstyle[tiles]; extra == "tiles"
28
- Provides-Extra: solver
29
- Requires-Dist: ortools>=9.10; extra == "solver"
30
28
  Provides-Extra: dev
31
29
  Requires-Dist: pytest; extra == "dev"
32
- Requires-Dist: ortools>=9.10; extra == "dev"
33
30
  Provides-Extra: docs
34
31
  Requires-Dist: mkdocs<2,>=1.6; extra == "docs"
35
32
  Requires-Dist: mkdocs-material<10,>=9.5; extra == "docs"
@@ -98,9 +95,9 @@ ms.render_map("monaco.duckdb", dashboard=True).save("dashboard.html") # a dashbo
98
95
  - **A full base map from the same database**: the sea, land use with openstreetmap.org's textures,
99
96
  water, buildings, railways, parking, transit, traffic lights and crossings. No tile server, no API key.
100
97
  - **Roads drawn right at junctions**: two-way roads as two lanes, bridges and overpasses over, tunnels
101
- under, crossings over their street. The drawing order of the roads is solved (an interval per road,
102
- `render_map(order=True)`; `pip install mapstyle[solver]` for the exact solution), so a joint of
103
- roads at different levels shows no ring.
98
+ under, crossings over their street. The drawing order of the roads is [roadstyle's](https://github.com/Khoshkhah/roadstyle):
99
+ a casing number and a fill number for every road, read from the database when `duckosm levels` stored them, else computed
100
+ while the page is made, so a joint of roads at different levels shows no ring.
104
101
  - **A route planner demo**: drag a start and an end and see duckOSM's route on the map (routing
105
102
  itself is duckOSM's job).
106
103
  - **A dashboard**: filter by mode, road class and layer, colour by mode, inspect what you click.
@@ -6,7 +6,6 @@ src/mapstyle/dashboard.js
6
6
  src/mapstyle/layers.js
7
7
  src/mapstyle/levels.py
8
8
  src/mapstyle/map.py
9
- src/mapstyle/node_levels.py
10
9
  src/mapstyle/patterns.py
11
10
  src/mapstyle/planner.html
12
11
  src/mapstyle/style.py
@@ -1,4 +1,4 @@
1
- roadstyle>=0.12.0
1
+ roadstyle>=0.14.0
2
2
  duckdb
3
3
  geopandas
4
4
  pyyaml
@@ -6,15 +6,11 @@ pillow
6
6
 
7
7
  [dev]
8
8
  pytest
9
- ortools>=9.10
10
9
 
11
10
  [docs]
12
11
  mkdocs<2,>=1.6
13
12
  mkdocs-material<10,>=9.5
14
13
  mkdocs-llmstxt<0.6,>=0.5
15
14
 
16
- [solver]
17
- ortools>=9.10
18
-
19
15
  [tiles]
20
16
  roadstyle[tiles]
@@ -145,101 +145,63 @@ def test_roads_with_a_level_are_cut_into_pieces(monaco, monkeypatch):
145
145
  assert whole[eid] - 1e-9 <= rows.geometry.length.sum() <= whole[eid] + extra + 1e-9
146
146
  off = {}
147
147
  monkeypatch.setattr(rs, "render_edges", lambda g, **kw: off.update(g=g) or SimpleNamespace(_tpl="</body>"))
148
- render_map(monaco, layers=False, order=False, pieces=False)
149
- assert len(off["g"]) == n and not off["g"]["_cap"].any()
148
+ render_map(monaco, layers=False, pieces=False)
149
+ assert len(off["g"]) == n and "_cap" not in off["g"]
150
150
 
151
151
 
152
- def _tiny_db(path, edges):
153
- """A duckOSM-like db with just ``<mode>.edges``: ``edges`` = [(mode, highway, name, [(x, y) in metres], layer, bridge, tunnel)]."""
154
- import math
152
+ def test_the_band_is_complete(monaco):
153
+ """docs/design/stored_levels.md: roadstyle's band_col replaces the level from the tags, so the band has a value for every road: the layer, else a bridge 1 and a tunnel -1;
154
+ a path's sidewalk -1 and crossing 1."""
155
+ from mapstyle.map import _band
156
+ roads = load_roads(monaco)
157
+ band = _band(roads)
158
+ assert len(band) == len(roads) and set(band) >= {-1, 0, 1}
159
+ tunnel = roads["tunnel"].notna() & ~roads["tunnel"].astype(str).isin(["", "no"]) & roads["layer"].isna() & ~roads["walk_type"].isin(["sidewalk", "crossing"])
160
+ assert tunnel.any() and (band[tunnel.to_numpy()] == -1).all() # a tunnel with no layer tag is below the ground
161
+ path = roads["highway"].isin(["footway", "path", "cycleway", "steps", "pedestrian", "bridleway", "corridor"])
162
+ assert (band[(path & (roads["walk_type"] == "crossing")).to_numpy()] == 1).all() and (band[(path & (roads["walk_type"] == "sidewalk")).to_numpy()] == -1).all()
155
163
 
156
- import duckdb
157
- lon0, lat0 = 7.42, 43.73
158
- ll = lambda x, y: (lon0 + x / (111320 * math.cos(math.radians(lat0))), lat0 + y / 110574) # noqa: E731
159
- c = duckdb.connect(str(path))
160
- c.execute("INSTALL spatial; LOAD spatial;")
161
- node = {}
162
- for m in MODES:
163
- c.execute(f"CREATE SCHEMA {m}")
164
- c.execute(f"CREATE TABLE {m}.edges (edge_id BIGINT, source BIGINT, target BIGINT, osm_id BIGINT, highway VARCHAR, name VARCHAR, "
165
- "bridge VARCHAR, tunnel VARCHAR, layer VARCHAR, oneway BOOLEAN, geometry GEOMETRY)")
166
- for i, (mode, hw, nm, pts, lay, br, tu) in enumerate(edges, 1):
167
- nid = lambda q: node.setdefault((round(q[0], 3), round(q[1], 3)), len(node) + 1) # noqa: E731
168
- wkt = "LINESTRING(" + ",".join("%.8f %.8f" % ll(*q) for q in pts) + ")"
169
- c.execute(f"INSERT INTO {mode}.edges VALUES (?,?,?,?,?,?,?,?,?,false,ST_GeomFromText(?))",
170
- [1000 + i, nid(pts[0]), nid(pts[-1]), 500 + i, hw, nm, "yes" if br else None, "yes" if tu else None,
171
- str(lay) if lay else None, wkt])
172
- c.close()
173
- return path
174
-
175
-
176
- def _levels_of(db):
177
- from mapstyle.node_levels import compute
178
- lv = compute(db)
179
- return lv, (lambda e: lv.intervals.get(str(e), (0, 0)))
180
-
181
-
182
- def test_a_raised_path_over_a_street_joined_to_ground_paths(tmp_path):
183
- """The scene of docs/design/node_levels.md: a path with a layer tag joins two ground paths and passes over a street. The
184
- intervals of roads that share a node intersect; the upper road's casing comes after the street's fill."""
185
- R, P = "residential", "pedestrian"
186
- db = _tiny_db(tmp_path / "ramp.duckdb", [
187
- ("walking", P, "ground", [(-40, 0), (0, 0)], 0, 0, 0), ("walking", P, "ground", [(0, 0), (-20, -28)], 0, 0, 0),
188
- ("walking", P, "raised", [(0, 0), (36, 14.4)], 1, 0, 0), ("driving", R, "street", [(17, -30), (17, 40)], 0, 0, 0)])
189
- lv, iv = _levels_of(db)
190
- ground1, ground2, raised, street = (iv(1001 + i) for i in range(4))
191
- assert lv.info["overpass_pairs"] == 1 and lv.info["given_up"] == 0
192
- assert raised[0] > street[1] # over the street, its casing after the street's fill
193
- for ground in (ground1, ground2): # the ground paths meet the raised road at a node
194
- assert ground[0] <= raised[1] and raised[0] <= ground[1]
195
- assert not lv.cuts
196
-
197
-
198
- def test_a_tunnel_under_a_street_and_no_overpass_without_a_crossing(tmp_path):
199
- R = "residential"
200
- db = _tiny_db(tmp_path / "tunnel.duckdb", [
201
- ("driving", R, "road", [(-30, 0), (-12, 0)], 0, 0, 0), ("driving", R, "tunnel", [(-12, 0), (12, 0)], -1, 0, 1),
202
- ("driving", R, "road", [(12, 0), (30, 0)], 0, 0, 0), ("driving", R, "street", [(0, -40), (0, 40)], 0, 0, 0)])
203
- lv, iv = _levels_of(db)
204
- mouth1, tunnel, mouth2, street = (iv(1001 + i) for i in range(4))
205
- assert lv.info["overpass_pairs"] == 1 and street[0] > tunnel[1]
206
- assert mouth1[0] <= tunnel[1] and tunnel[0] <= mouth1[1] and mouth2[0] <= tunnel[1] and tunnel[0] <= mouth2[1]
207
- only = _tiny_db(tmp_path / "flat.duckdb", [("driving", R, "a", [(-30, 0), (0, 0)], 0, 0, 0), ("driving", R, "b", [(0, 0), (30, 0)], 1, 0, 0)])
208
- lv, iv = _levels_of(only)
209
- assert lv.info["overpass_pairs"] == 0 and not lv.intervals # nothing crosses: every road stays at 0
210
-
211
-
212
- def test_node_levels_without_a_solver_and_on_monaco(monaco):
213
- """The heuristic runs without OR-Tools; on Monaco every overpass pair is satisfied after the conflict fixes."""
214
- from mapstyle.node_levels import compute
215
- h = compute(monaco, solver=False)
216
- assert h.info["solver"] is False and h.info["overpass_pairs"] > 100
217
- s = compute(monaco)
218
- assert s.info["unsatisfied"] == 0 and s.info["given_up"] >= s.info["dropped"] and s.info["levels"] <= 12
219
- assert s.info["given_up"] <= 8 # 3 in the sample; a few for another build of it
220
-
221
-
222
- def test_the_default_page_draws_the_order_of_the_roads(monaco, monkeypatch):
223
- """render_map gives roadstyle ``casing_level_col`` / ``fill_level_col``: even positions from the intervals, a crossing at 1 and a
224
- mapped sidewalk at -1; a road cut into pieces keeps its row and appends the other pieces with ``_piece``."""
225
- seen = {}
164
+
165
+ def _seen(monkeypatch, db, **kw):
166
+ """The arguments and the roads that render_map gives roadstyle's render_edges."""
226
167
  from types import SimpleNamespace
227
- monkeypatch.setattr(rs, "render_edges", lambda g, **kw: seen.update(kw, g=g) or SimpleNamespace(_tpl="</body>"))
228
- render_map(monaco, layers=False)
229
- g, base = seen["g"], load_roads(monaco)
230
- assert seen["casing_level_col"] == "_cl" and seen["fill_level_col"] == "_fl" and "cap_col" not in seen
231
- n = len(base)
232
- assert list(g["edge_id"].iloc[:n]) == list(base["edge_id"]) and g["_piece"].iloc[n:].all()
233
- assert (g["_cl"] <= g["_fl"]).all()
234
- cross, side = g["_band"] == 1, g["_band"] == -1
235
- assert cross.any() and side.any()
236
- assert set(g.loc[cross, "_cl"]) == {1} and set(g.loc[cross, "_fl"]) == {1} and set(g.loc[side, "_cl"]) == {-1}
237
- rest = g[~cross & ~side]
238
- assert (rest["_cl"] % 2 == 0).all() and (rest["_fl"] % 2 == 0).all() # the odd positions stay free
239
- off = {}
240
- monkeypatch.setattr(rs, "render_edges", lambda g, **kw: off.update(kw, g=g) or SimpleNamespace(_tpl="</body>"))
241
- render_map(monaco, layers=False, order=False)
242
- assert "casing_level_col" not in off and len(off["g"]) == n
168
+ seen = {}
169
+ monkeypatch.setattr(rs, "render_edges", lambda g, **k: seen.update(k, g=g) or SimpleNamespace(_tpl="</body>"))
170
+ render_map(db, layers=False, **kw)
171
+ return seen
172
+
173
+
174
+ def test_without_stored_levels_roadstyle_computes_the_order(monaco, monkeypatch):
175
+ """docs/design/stored_levels.md: a file without visualization.edge_levels: roadstyle computes the numbers from the complete band; every road is drawn whole."""
176
+ seen = _seen(monkeypatch, monaco)
177
+ assert seen["band_col"] == "band" and "casing_level_col" not in seen and "cap_col" not in seen
178
+ assert len(seen["g"]) == len(load_roads(monaco)) and "band" in seen["g"] and not seen["g"]["band"].isna().any()
179
+
180
+
181
+ def test_stored_levels_are_read_and_a_stale_table_is_an_error(monaco, tmp_path, monkeypatch):
182
+ """With the table (duckosm levels) the page is drawn with its numbers; a table computed with other options is an error that says to run duckosm levels again."""
183
+ import shutil
184
+
185
+ if not DUCKOSM_EXE.exists():
186
+ pytest.skip("needs duckOSM's duckosm")
187
+ db = tmp_path / "stored.duckdb"
188
+ shutil.copy(monaco, db)
189
+ done = subprocess.run([DUCKOSM_EXE, "levels", str(db)], capture_output=True, text=True)
190
+ if done.returncode:
191
+ pytest.skip(f"duckosm levels is not available (duckOSM 0.2.0 with roadstyle 0.13.1 is needed): {done.stderr[-200:]}")
192
+ seen = _seen(monkeypatch, db)
193
+ assert [seen[k] for k in ("casing_level_col", "fill_level_col", "casing_start_col", "casing_end_col")] == ["casing_level", "fill_level", "casing_start", "casing_end"]
194
+ assert "band_col" not in seen and (seen["g"]["casing_level"] <= seen["g"]["fill_level"]).all()
195
+ assert len(seen["g"]) == len(load_roads(db))
196
+ assert _seen(monkeypatch, db, tiles=True)["casing_level_col"] == "casing_level" # also with vector tiles
197
+ subprocess.run([DUCKOSM_EXE, "levels", str(db), "--no-min-positions"], check=True, capture_output=True)
198
+ with pytest.raises(ValueError, match="duckosm levels"):
199
+ render_map(db, layers=False) # the file's numbers were computed with other options: never recomputed silently
200
+
201
+
202
+ def test_the_argument_order_is_removed(monaco):
203
+ with pytest.raises(ValueError, match="removed"):
204
+ render_map(monaco, layers=False, order=True)
243
205
 
244
206
 
245
207
  def test_pieces_of_one_road():
@@ -466,7 +428,7 @@ def test_grey_theme_is_muted_except_the_water_and_green_hints():
466
428
 
467
429
 
468
430
  def test_theme_osm_is_todays_page_and_an_unknown_theme_names_the_choices(monaco):
469
- assert render_map(monaco, layers=False, order=False).html == render_map(monaco, layers=False, order=False, theme="osm").html
431
+ assert render_map(monaco, layers=False).html == render_map(monaco, layers=False, theme="osm").html
470
432
  with pytest.raises(ValueError, match="unknown theme 'nope'; choose from \\('osm', 'google', 'grey'\\)"):
471
433
  render_map(monaco, theme="nope")
472
434
  html = render_map(monaco, theme="grey").html
@@ -1,526 +0,0 @@
1
- """The order in which each road's casing and fill are drawn (docs/design/node_levels.md).
2
-
3
- Every road (both directions of a segment together) gets an interval ``[a, b]`` of integers: ``a`` is the position in the
4
- drawing order where its casing is drawn and ``b`` where its fill is drawn; at each position all casings come before all fills.
5
-
6
- * roads that share a node have intersecting intervals (so they merge cleanly);
7
- * an overpass (two roads whose lines cross, no node in common, different level tags) has disjoint intervals, the upper one
8
- later, so the upper road is drawn over the lower one with its own casing; an overpass that cannot be satisfied is given up.
9
-
10
- Found with OR-Tools CP-SAT (``pip install mapstyle[solver]``); without it a heuristic is used. Only the roads within a few hops
11
- of an overpass are in the problem; every other road is at position 0 (``[0, 0]``).
12
-
13
- Two conflict-driven fixes, applied only where a conflict is left (docs/design/node_levels.md): a pair whose crossing is within
14
- ``EPS_NODE_M`` of an end node of either road is not an overpass; otherwise the road that changes level along itself is cut
15
- at its crossing.
16
- """
17
- import logging
18
- from collections import defaultdict
19
-
20
- log = logging.getLogger(__name__)
21
-
22
- LO, HI = -20, 20 # the ends of an interval are integers in [LO, HI]
23
- EPS_NODE_M = 0.3 # a crossing this close to an end node of either road is a junction missing a node, not an overpass
24
- HOPS = 4 # roads this many nodes away from an overpass road are in the problem; the others are [0, 0]
25
- M_PER_DEG = 95000.0 # rough: only used to compare distances along one road
26
-
27
-
28
- def have_solver():
29
- try:
30
- import ortools # noqa: F401
31
- return True
32
- except ImportError:
33
- return False
34
-
35
-
36
- # ---- the data -------------------------------------------------------------------------------------------------------
37
- def load(con, union):
38
- """``(roads, lk, pairs)``. ``roads``: ``{road id: (s, t, level, length_m)}``, a road being the directed edges with the same
39
- two end nodes and the same line; ``lk``: ``{edge id: road id}`` for the edges of a road with more than one; ``pairs``: the
40
- overpass pairs ``(upper, lower)`` as road ids. ``con`` has the spatial extension loaded."""
41
- t = lambda c: f"({c} IS NOT NULL AND lower(CAST({c} AS VARCHAR)) NOT IN ('', 'no', 'false', '0'))" # noqa: E731
42
- o = "ORDER BY mode IS NULL, mode, pmode"
43
- con.execute(f"""CREATE TEMP TABLE nl_e0 AS SELECT edge_id AS eid, first(source {o}) AS s, first(target {o}) AS t,
44
- first(layer {o}) AS layer, first(bridge {o}) AS bridge, first(tunnel {o}) AS tunnel, first(geometry {o}) AS g
45
- FROM ({union}) GROUP BY edge_id""")
46
- con.execute(f"""CREATE TEMP TABLE nl_lv AS SELECT eid, s, t, g,
47
- COALESCE(NULLIF(TRY_CAST(layer AS INTEGER), 0), CASE WHEN {t('bridge')} THEN 1 WHEN {t('tunnel')} THEN -1 ELSE 0 END) AS l
48
- FROM nl_e0""")
49
- # candidate pairs through a grid (a spatial join over a whole county takes minutes), then the exact test
50
- cs = 0.001
51
- con.execute(f"""CREATE TEMP TABLE nl_bb AS SELECT eid, l, CAST(floor(ST_XMin(g)/{cs}) AS BIGINT) cx0, CAST(floor(ST_XMax(g)/{cs}) AS BIGINT) cx1,
52
- CAST(floor(ST_YMin(g)/{cs}) AS BIGINT) cy0, CAST(floor(ST_YMax(g)/{cs}) AS BIGINT) cy1 FROM nl_lv""")
53
- con.execute("CREATE TEMP TABLE nl_cx AS SELECT eid, l, unnest(range(cx0, cx1 + 1)) i, cy0, cy1 FROM nl_bb")
54
- con.execute("CREATE TEMP TABLE nl_cells AS SELECT eid, l, i, unnest(range(cy0, cy1 + 1)) j FROM nl_cx")
55
- con.execute("""CREATE TEMP TABLE nl_cand AS SELECT DISTINCT a.eid ea, o.eid eo FROM nl_cells a JOIN nl_cells o
56
- ON a.i = o.i AND a.j = o.j AND a.eid <> o.eid AND a.l <> 0 AND a.l <> o.l AND (o.l = 0 OR a.eid < o.eid)""")
57
- raw = con.execute("""SELECT CASE WHEN a.l > o.l THEN a.eid ELSE o.eid END, CASE WHEN a.l > o.l THEN o.eid ELSE a.eid END
58
- FROM nl_cand c JOIN nl_lv a ON a.eid = c.ea JOIN nl_lv o ON o.eid = c.eo
59
- WHERE a.s NOT IN (o.s, o.t) AND a.t NOT IN (o.s, o.t) AND ST_Crosses(a.g, o.g)""").fetchall()
60
- # roads: the two directions of a segment (same end nodes, same line reversed); the road id is the smallest edge id
61
- grp = con.execute("""SELECT a.eid, min(o.eid) FROM nl_lv a JOIN nl_lv o ON least(a.s, a.t) = least(o.s, o.t)
62
- AND greatest(a.s, a.t) = greatest(o.s, o.t) AND ST_Equals(a.g, o.g) GROUP BY a.eid""").fetchall()
63
- lk = {str(a): str(b) for a, b in grp if a != b}
64
- need = {str(x) for pr in raw for x in pr}
65
- need |= {lk.get(x, x) for x in need}
66
- roads = {}
67
- ids = ", ".join(sorted(need)) or "0"
68
- for e, s, t_, l, ln in con.execute(f"SELECT eid, s, t, l, ST_Length(g) FROM nl_lv WHERE eid IN ({ids})").fetchall():
69
- roads[str(e)] = (s, t_, int(l), ln * M_PER_DEG)
70
- pairs = sorted({(lk.get(str(u), str(u)), lk.get(str(l), str(l))) for u, l in raw} - {(x, x) for x in roads})
71
- return roads, lk, pairs
72
-
73
-
74
- def _neighbourhood(con, seeds, hops, lk):
75
- """Roads (as representative edge ids) within ``hops`` nodes of the roads ``seeds``: ``{road: (s, t, level, length_m)}``."""
76
- rows = {}
77
- frontier = set()
78
- for e in seeds:
79
- r = con.execute(f"SELECT eid, s, t, l, ST_Length(g) FROM nl_lv WHERE eid = {e}").fetchone()
80
- rows[str(r[0])] = (r[1], r[2], int(r[3]), r[4] * M_PER_DEG)
81
- frontier |= {r[1], r[2]}
82
- seen = set(frontier)
83
- for _ in range(hops):
84
- if not frontier:
85
- break
86
- ns = ", ".join(str(n) for n in frontier)
87
- new = set()
88
- for e, s, t, l, ln in con.execute(f"SELECT eid, s, t, l, ST_Length(g) FROM nl_lv WHERE s IN ({ns}) OR t IN ({ns})").fetchall():
89
- if str(e) not in lk: # the other direction of a road is not a road of its own
90
- rows[str(e)] = (s, t, int(l), ln * M_PER_DEG)
91
- new |= {s, t}
92
- frontier = new - seen
93
- seen |= new
94
- return rows
95
-
96
-
97
- def join_pairs(items):
98
- at = defaultdict(list)
99
- for e, (s, t, *_) in items.items():
100
- at[s].append(e)
101
- if t != s:
102
- at[t].append(e)
103
- out = set()
104
- for es in at.values():
105
- for i in range(len(es)):
106
- for j in range(i + 1, len(es)):
107
- out.add((es[i], es[j]) if es[i] < es[j] else (es[j], es[i]))
108
- return sorted(out)
109
-
110
-
111
- # ---- the models -----------------------------------------------------------------------------------------------------
112
- def _model(items, pairs, fixed, lits=False):
113
- from ortools.sat.python import cp_model
114
- m = cp_model.CpModel()
115
- a = {e: m.NewIntVar(LO, HI, "") for e in items}
116
- b = {e: m.NewIntVar(LO, HI, "") for e in items}
117
- for e in items:
118
- m.Add(a[e] <= b[e])
119
- for e, (lo, hi) in fixed.items():
120
- m.Add(a[e] == lo)
121
- m.Add(b[e] == hi)
122
- for x, y in join_pairs(items):
123
- m.Add(a[x] <= b[y])
124
- m.Add(a[y] <= b[x])
125
- return m, a, b
126
-
127
-
128
- def _solve(m, limit, workers=8):
129
- from ortools.sat.python import cp_model
130
- sv = cp_model.CpSolver()
131
- sv.parameters.max_time_in_seconds = limit
132
- sv.parameters.num_workers = workers
133
- return sv, sv.Solve(m), cp_model
134
-
135
-
136
- def solve_penalty(items, pairs, boundary, limit=60.0, hint=None):
137
- """Minimise the number of overpass pairs given up. ``boundary``: roads that touch a road outside the problem must contain 0.
138
- Returns ``({road: (a, b)}, [pairs given up], status)``."""
139
- m, a, b = _model(items, pairs, {})
140
- for e in boundary:
141
- m.Add(a[e] <= 0)
142
- m.Add(b[e] >= 0)
143
- v = []
144
- for i, (u, l) in enumerate(pairs):
145
- q = m.NewBoolVar("")
146
- v.append(q)
147
- m.Add(b[l] + 1 <= a[u]).OnlyEnforceIf(q.Not())
148
- if hint:
149
- for e, (lo, hi) in hint.items():
150
- m.AddHint(a[e], max(LO, min(HI, lo)))
151
- m.AddHint(b[e], max(LO, min(HI, hi)))
152
- m.Minimize(sum(v))
153
- sv, st, cp = _solve(m, limit)
154
- if st not in (cp.OPTIMAL, cp.FEASIBLE):
155
- return None, [], sv.StatusName(st)
156
- out = {e: (sv.Value(a[e]), sv.Value(b[e])) for e in items}
157
- return out, [pairs[i] for i, q in enumerate(v) if sv.Value(q)], sv.StatusName(st)
158
-
159
-
160
- def solve_hard(items, pairs, boundary, limit=60.0, rounds=20):
161
- """The overpass pairs as hard constraints. Infeasible: the conflicting sets of pairs (unsatisfiable cores, through assumptions),
162
- one pair released per round until it is feasible. Returns ``(status of the first solve, [cores], [released pairs])``."""
163
- released, cores, first = [], [], None
164
- for _ in range(rounds):
165
- m, a, b = _model(items, pairs, {})
166
- for e in boundary:
167
- m.Add(a[e] <= 0)
168
- m.Add(b[e] >= 0)
169
- lits, names = [], []
170
- gone = set(released)
171
- for u, l in pairs:
172
- if (u, l) in gone:
173
- continue
174
- q = m.NewBoolVar("")
175
- m.Add(b[l] + 1 <= a[u]).OnlyEnforceIf(q)
176
- lits.append(q)
177
- names.append((u, l))
178
- m.AddAssumptions(lits)
179
- sv, st, cp = _solve(m, limit)
180
- first = first or sv.StatusName(st)
181
- if st in (cp.OPTIMAL, cp.FEASIBLE):
182
- return first, cores, released
183
- idx = {lit.Index(): i for i, lit in enumerate(lits)}
184
- core = [names[idx[c]] for c in sv.SufficientAssumptionsForInfeasibility() if c in idx]
185
- if not core:
186
- return first, cores, released
187
- cores.append(core)
188
- released.append(core[0])
189
- return first, cores, released
190
-
191
-
192
- def solve_fixed(items, pairs, fixed, boundary, limit=60.0):
193
- """A feasible assignment, hard pairs; roads in ``fixed`` keep their interval; the others as short as possible."""
194
- m, a, b = _model(items, pairs, fixed)
195
- for e in boundary:
196
- m.Add(a[e] <= 0)
197
- m.Add(b[e] >= 0)
198
- for u, l in pairs:
199
- m.Add(b[l] + 1 <= a[u])
200
- m.Minimize(sum(b[e] - a[e] for e in items if e not in fixed))
201
- sv, st, cp = _solve(m, limit)
202
- if st not in (cp.OPTIMAL, cp.FEASIBLE):
203
- return None
204
- return {e: (sv.Value(a[e]), sv.Value(b[e])) for e in items}
205
-
206
-
207
- def compact(items, pairs, boundary, given_up, limit=60.0):
208
- """Keep at most ``given_up`` overpasses given up and make the intervals as short as possible (fewer distinct numbers)."""
209
- m, a, b = _model(items, pairs, {})
210
- for e in boundary:
211
- m.Add(a[e] <= 0)
212
- m.Add(b[e] >= 0)
213
- v = []
214
- for u, l in pairs:
215
- q = m.NewBoolVar("")
216
- v.append(q)
217
- m.Add(b[l] + 1 <= a[u]).OnlyEnforceIf(q.Not())
218
- m.Add(sum(v) <= given_up)
219
- m.Minimize(sum(b[e] - a[e] for e in items))
220
- sv, st, cp = _solve(m, limit)
221
- if st not in (cp.OPTIMAL, cp.FEASIBLE):
222
- return None, []
223
- return ({e: (sv.Value(a[e]), sv.Value(b[e])) for e in items}, [pairs[i] for i, q in enumerate(v) if sv.Value(q)])
224
-
225
-
226
- def heuristic(items, pairs):
227
- """Without a solver: a level for each node (all start at 0; a raised road is lifted, any other road sunk, until every pair that
228
- is not in a cycle is satisfied); a road's interval is the lowest to the highest level of its two nodes."""
229
- sccs = _scc(pairs, items)
230
- keep = [(u, l) for u, l in pairs if not any(sccs.get(nl) == sccs.get(nu) for nu in items[u][:2] for nl in items[l][:2])]
231
- p = defaultdict(int)
232
- for _ in range(100):
233
- moved = False
234
- for u, l in keep:
235
- for nu in items[u][:2]:
236
- for nl in items[l][:2]:
237
- if p[nu] > p[nl]:
238
- continue
239
- if items[u][2] > 0:
240
- p[nu] = p[nl] + 1
241
- else:
242
- p[nl] = p[nu] - 1
243
- moved = True
244
- if not moved:
245
- break
246
- out = {e: (max(LO, min(HI, min(p[s], p[t]))), max(LO, min(HI, max(p[s], p[t])))) for e, (s, t, *_ ) in items.items()}
247
- return out, [pr for pr in pairs if pr not in set(keep)]
248
-
249
-
250
- def _scc(pairs, items):
251
- g = defaultdict(list)
252
- for u, l in pairs:
253
- for nu in items[u][:2]:
254
- for nl in items[l][:2]:
255
- g[nl].append(nu)
256
- nodes = set(g) | {x for vs in g.values() for x in vs}
257
- index, low, on, stack, comp, c = {}, {}, set(), [], {}, [0]
258
- for root in nodes:
259
- if root in index:
260
- continue
261
- work = [(root, iter(g.get(root, ())))]
262
- index[root] = low[root] = len(index)
263
- stack.append(root)
264
- on.add(root)
265
- while work:
266
- v, it = work[-1]
267
- for w in it:
268
- if w not in index:
269
- index[w] = low[w] = len(index)
270
- stack.append(w)
271
- on.add(w)
272
- work.append((w, iter(g.get(w, ()))))
273
- break
274
- if w in on:
275
- low[v] = min(low[v], index[w])
276
- else:
277
- work.pop()
278
- if work:
279
- low[work[-1][0]] = min(low[work[-1][0]], low[v])
280
- if low[v] == index[v]:
281
- while True:
282
- w = stack.pop()
283
- on.discard(w)
284
- comp[w] = c[0]
285
- if w == v:
286
- break
287
- c[0] += 1
288
- return comp
289
-
290
-
291
- # ---- the whole computation ------------------------------------------------------------------------------------------
292
- class Levels:
293
- """``intervals``: ``{edge_id: (casing, fill)}`` for every directed edge that is not ``(0, 0)`` (anything missing is ``(0, 0)``);
294
- ``cuts``: ``{road id: {"bounds": [m...], "intervals": [(casing, fill)...], "edges": [edge ids]}}``, the roads to draw in pieces
295
- (piece i runs from ``bounds[i]`` to ``bounds[i + 1]`` metres along the road's line); ``info``: counts and timing."""
296
-
297
- def __init__(self, intervals, cuts, info):
298
- self.intervals, self.cuts, self.info = intervals, cuts, info
299
-
300
-
301
- def _near_node(geom, u, l, eps=EPS_NODE_M):
302
- from shapely.geometry import Point
303
- x = geom[u].intersection(geom[l])
304
- pts = [] if x.is_empty else (list(x.geoms) if hasattr(x, "geoms") else [x])
305
- return any(min(Point(g.coords[0]).distance(q), Point(g.coords[-1]).distance(q)) * M_PER_DEG < eps
306
- for g in (geom[u], geom[l]) for q in pts)
307
-
308
-
309
- def _cut_positions(line, uppers, geom, clear=6.0):
310
- """Where to cut ``line`` so that one piece (the one crossing the upper roads) is separate from the node ends: just before the
311
- first crossing and just after the last, never into a node and never past a crossing, as far as ``clear`` metres."""
312
- ts = []
313
- for u in uppers:
314
- x = line.intersection(geom[u])
315
- pts = [] if x.is_empty else (list(x.geoms) if hasattr(x, "geoms") else [x])
316
- ts += [line.project(q) * M_PER_DEG for q in pts]
317
- length = line.length * M_PER_DEG
318
- cuts = []
319
- if ts:
320
- t_min, t_max = min(ts), max(ts)
321
- if t_min - 0.3 > 0.3:
322
- cuts.append(t_min - min(clear, t_min - 0.3))
323
- if length - t_max - 0.3 > 0.3:
324
- cuts.append(t_max + min(clear, length - t_max - 0.3))
325
- return cuts, length
326
-
327
-
328
- def _compress(values):
329
- """Order-preserving integers: negative values to -1, -2 ..., positive to 1, 2 ..., zero stays zero."""
330
- vs = sorted(set(values) | {0})
331
- neg = [v for v in vs if v < 0]
332
- pos = [v for v in vs if v > 0]
333
- m = {0: 0}
334
- m.update({v: -(len(neg) - i) for i, v in enumerate(neg)})
335
- m.update({v: i + 1 for i, v in enumerate(pos)})
336
- return m
337
-
338
-
339
- def compute(db, hops=HOPS, limit=60.0, solver=None):
340
- """The drawing order of the roads of the duckOSM db ``db``: a ``Levels``. ``solver``: use OR-Tools (default: if it is installed)."""
341
- import time
342
-
343
- import duckdb
344
- from shapely import wkb
345
- from shapely.ops import substring
346
-
347
- from mapstyle.map import _roads_union
348
- t0 = time.time()
349
- use = have_solver() if solver is None else solver
350
- con = duckdb.connect(str(db), read_only=True)
351
- try:
352
- union = _roads_union(con, db)
353
- roads, lk, pairs = load(con, union)
354
- info = {"overpass_pairs": len(pairs), "solver": use, "given_up": 0, "dropped": 0, "cut": 0}
355
- if not pairs:
356
- info["seconds"] = round(time.time() - t0, 1)
357
- return Levels({}, {}, info)
358
- seeds = {x for pr in pairs for x in pr}
359
- items = _neighbourhood(con, seeds, hops, lk)
360
- items.update({r: roads[r] for r in seeds if r in roads})
361
- # roads at the edge of the problem touch roads outside it (at 0): they must contain 0
362
- nodes = {n for s, t, *_ in items.values() for n in (s, t)}
363
- ns = ", ".join(str(n) for n in nodes)
364
- outside = set()
365
- for e, s, t in con.execute(f"SELECT eid, s, t FROM nl_lv WHERE s IN ({ns}) OR t IN ({ns})").fetchall():
366
- if str(e) not in items and str(e) not in lk:
367
- outside |= {s, t}
368
- boundary = [r for r, (s, t, *_ ) in items.items() if s in outside or t in outside]
369
- info.update(roads_in_problem=len(items), boundary=len(boundary))
370
- base_h, dropped_h = heuristic(items, pairs)
371
- if not use:
372
- iv, given = base_h, dropped_h
373
- info["given_up"] = len(given)
374
- res, cuts, dropped = iv, {}, []
375
- else:
376
- out0, given, status = solve_penalty(items, pairs, boundary, limit, hint=base_h)
377
- if out0 is None:
378
- raise RuntimeError(f"node levels: the solver returned {status}")
379
- info["status"] = status
380
- base, _ = compact(items, pairs, boundary, len(given), limit)
381
- base = base or out0
382
- res, cuts, dropped = base, {}, []
383
- if given:
384
- res, cuts, dropped, pairs = _fix_conflicts(con, items, pairs, boundary, base, limit, wkb, substring)
385
- info["given_up"] = len(given)
386
- info["dropped"], info["cut"] = len(dropped), len(cuts)
387
- finally:
388
- con.close()
389
- # numbers: order-preserving, small
390
- vals = [v for ab in res.values() for v in ab] + [v for c in cuts.values() for ab in c["intervals"] for v in ab]
391
- cm = _compress(vals)
392
- members = defaultdict(list)
393
- for e, r in lk.items():
394
- members[r].append(e)
395
- intervals = {}
396
- for r, (a, b) in res.items():
397
- a, b = cm[a], cm[b]
398
- if r in cuts or (a, b) == (0, 0):
399
- continue
400
- for e in [r] + members.get(r, []):
401
- intervals[e] = (a, b)
402
- for r, c in cuts.items():
403
- c["intervals"] = [(cm[a], cm[b]) for a, b in c["intervals"]]
404
- c["edges"] = [r] + members.get(r, [])
405
- info["unsatisfied"] = len(given) if not use else 0 # after the conflict fixes the solver's drawing order is feasible
406
- info["levels"] = len({v for ab in intervals.values() for v in ab} | {v for c in cuts.values() for ab in c["intervals"] for v in ab})
407
- info["seconds"] = round(time.time() - t0, 1)
408
- log.info("node levels: %s", info)
409
- return Levels(intervals, cuts, info)
410
-
411
-
412
- def _fix_conflicts(con, items, pairs, boundary, base, limit, wkb, substring):
413
- """The conflicts that are left (docs/design/node_levels.md): drop a pair whose crossing is next to an end node; otherwise cut the
414
- road that changes level along itself. Every road outside the conflicts keeps its interval in ``base``."""
415
- ids = ", ".join(sorted({x for pr in pairs for x in pr}))
416
- geom = {str(e): wkb.loads(bytes(g)) for e, g in con.execute(f"SELECT eid, ST_AsWKB(g) FROM nl_lv WHERE eid IN ({ids})").fetchall()}
417
- items, pairs = dict(items), list(pairs)
418
- dropped, cut, affected, new_node = [], {}, set(), [-1]
419
- for rnd in range(5):
420
- status, cores, released = solve_hard(items, pairs, boundary, limit)
421
- if not cores:
422
- break
423
- todo = {}
424
- for core in cores:
425
- affected |= {x.split("#")[0] for pr in core for x in pr}
426
- near = [pr for pr in core if _near_node(geom, *pr)]
427
- if near:
428
- for pr in near:
429
- if pr in pairs:
430
- pairs.remove(pr)
431
- dropped.append(pr)
432
- continue
433
- lowers = {}
434
- for u, l in core:
435
- lowers.setdefault(l, []).append(u)
436
-
437
- def vertical(e):
438
- ns = set(items[e][:2])
439
- return any(abs(items[o][2] - items[e][2]) >= 2 for o in items if o != e and ns & set(items[o][:2]))
440
- l = ([x for x in lowers if vertical(x)] or list(lowers))[0]
441
- todo.setdefault(l, [])
442
- todo[l] += [u for u in lowers[l] if u not in todo[l]]
443
- for l, ups in todo.items():
444
- root = l.split("#")[0]
445
- if root in cut:
446
- continue
447
- cuts_m, length = _cut_positions(geom[l], ups, geom)
448
- if not cuts_m:
449
- continue
450
- s_, t_, lv, ln = items[l]
451
- nodes = [s_] + [new_node.append(new_node[-1] - 1) or new_node[-1] for _ in cuts_m] + [t_]
452
- bounds = [0.0] + cuts_m + [length]
453
- names = []
454
- for i in range(len(bounds) - 1):
455
- pid = f"{l}#{i}"
456
- geom[pid] = substring(geom[l], bounds[i] / M_PER_DEG, bounds[i + 1] / M_PER_DEG)
457
- items[pid] = (nodes[i], nodes[i + 1], lv, bounds[i + 1] - bounds[i])
458
- names.append(pid)
459
- del items[l]
460
- newp = []
461
- for u2, x2 in pairs:
462
- if x2 == l:
463
- newp += [(u2, pid) for pid in names if geom[u2].crosses(geom[pid])]
464
- elif u2 == l:
465
- newp += [(pid, x2) for pid in names if geom[x2].crosses(geom[pid])]
466
- else:
467
- newp.append((u2, x2))
468
- pairs = sorted(set(newp))
469
- cut[l] = {"bounds": bounds, "pieces": names}
470
- free = set(affected) | {p for c in cut.values() for p in c["pieces"]}
471
- for r in list(free):
472
- ns = set(items[r][:2]) if r in items else set()
473
- free |= {o for o in items if ns & set(items[o][:2])}
474
- fixed = {e: base[e] for e in items if e not in free and e in base}
475
- final = solve_fixed(items, pairs, fixed, boundary, limit) or solve_fixed(items, pairs, {}, boundary, limit)
476
- if final is None:
477
- raise RuntimeError("node levels: no feasible drawing order after the conflict fixes")
478
- cuts_out = {r: {"bounds": c["bounds"], "intervals": [final[p] for p in c["pieces"]]} for r, c in cut.items()}
479
- res = {e: iv for e, iv in final.items() if "#" not in e}
480
- return res, cuts_out, dropped, pairs
481
-
482
-
483
- def with_cuts(roads, levels, scale=2):
484
- """The roads GeoDataFrame with ``_cl`` / ``_fl`` (the drawing-order positions, ``scale`` times the interval numbers so that the odd
485
- positions between them stay free) and the roads that must be drawn in pieces split: the row of a directed edge of a cut road
486
- becomes its first piece (so the row index the planner's graphs point to is unchanged), the other pieces are appended with
487
- ``_piece`` set. ``levels``: a ``Levels``."""
488
- import geopandas as gpd
489
- import pandas as pd
490
- from shapely.ops import substring
491
-
492
- roads = roads.copy()
493
- iv = levels.intervals
494
- roads["_cl"] = [scale * iv.get(e, (0, 0))[0] for e in roads["edge_id"]]
495
- roads["_fl"] = [scale * iv.get(e, (0, 0))[1] for e in roads["edge_id"]]
496
- roads["_piece"] = False
497
- extra = []
498
- for rid, c in levels.cuts.items():
499
- bounds = c["bounds"]
500
- total = bounds[-1]
501
- rep = None
502
- for e in c["edges"]:
503
- idx = roads.index[roads["edge_id"] == e]
504
- if len(idx) == 0:
505
- continue
506
- i = idx[0]
507
- g = roads.at[i, "geometry"]
508
- if rep is None:
509
- rep = g
510
- # the piece bounds run along the road's own line; a twin's line runs the other way
511
- same = ((g.coords[0][0] - rep.coords[0][0]) ** 2 + (g.coords[0][1] - rep.coords[0][1]) ** 2
512
- <= (g.coords[0][0] - rep.coords[-1][0]) ** 2 + (g.coords[0][1] - rep.coords[-1][1]) ** 2)
513
- for n, (a, b) in enumerate(c["intervals"]):
514
- lo, hi = (bounds[n], bounds[n + 1]) if same else (total - bounds[n + 1], total - bounds[n])
515
- line = substring(g, lo / M_PER_DEG, hi / M_PER_DEG)
516
- if n == 0:
517
- roads.at[i, "geometry"], roads.at[i, "_cl"], roads.at[i, "_fl"] = line, scale * a, scale * b
518
- else:
519
- r = roads.loc[i].copy()
520
- r["geometry"], r["_cl"], r["_fl"], r["_piece"] = line, scale * a, scale * b, True
521
- if "k" in r.index:
522
- r["k"] = float("nan")
523
- extra.append(r)
524
- if extra:
525
- roads = gpd.GeoDataFrame(pd.concat([roads, pd.DataFrame(extra)], ignore_index=True), geometry="geometry", crs=roads.crs)
526
- return roads
File without changes
File without changes
File without changes