lanestyle 0.1.0__py3-none-any.whl
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.
- lanestyle/__init__.py +7 -0
- lanestyle/data/lanestyle.json +23 -0
- lanestyle/gmns.py +132 -0
- lanestyle/junctions.py +50 -0
- lanestyle/lines.py +268 -0
- lanestyle/render.py +403 -0
- lanestyle-0.1.0.dist-info/METADATA +196 -0
- lanestyle-0.1.0.dist-info/RECORD +11 -0
- lanestyle-0.1.0.dist-info/WHEEL +5 -0
- lanestyle-0.1.0.dist-info/licenses/LICENSE +21 -0
- lanestyle-0.1.0.dist-info/top_level.txt +1 -0
lanestyle/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""lanestyle — lane-level maps on roadstyle: a lane table (from_gmns reads one from a duckOSM GMNS db)
|
|
2
|
+
drawn one line per lane, at its width in metres."""
|
|
3
|
+
from lanestyle.gmns import boundary_from_geojson, from_gmns, read_boundary
|
|
4
|
+
from lanestyle.render import lane_settings, render_lanes, write_serve
|
|
5
|
+
|
|
6
|
+
__version__ = "0.1.0"
|
|
7
|
+
__all__ = ["from_gmns", "read_boundary", "boundary_from_geojson", "render_lanes", "lane_settings", "write_serve"]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"lanes": {
|
|
3
|
+
"colors": {
|
|
4
|
+
"bus": "#9db8d9",
|
|
5
|
+
"bike": "#3f8fc9",
|
|
6
|
+
"clicked": "#e0453a",
|
|
7
|
+
"turns_into": "#149a86",
|
|
8
|
+
"uturn": "#8e44ad"
|
|
9
|
+
},
|
|
10
|
+
"lines": {
|
|
11
|
+
"divider": {"color": "#e8e8e8", "width_m": 0.15, "dash_m": [3, 9]},
|
|
12
|
+
"centre": {"color": "#e8e8e8", "width_m": 0.15, "dash_m": null},
|
|
13
|
+
"edge": {"color": "#6b6b6b", "width_m": 0.10, "dash_m": null}
|
|
14
|
+
},
|
|
15
|
+
"junction_trim_m": 1,
|
|
16
|
+
"fillet_m": 0,
|
|
17
|
+
"line_min_device_px": 1.5,
|
|
18
|
+
"type_label_zoom": 18,
|
|
19
|
+
"default_width_m": 3.25,
|
|
20
|
+
"casing_m": 0,
|
|
21
|
+
"width_m_zoom": 16
|
|
22
|
+
}
|
|
23
|
+
}
|
lanestyle/gmns.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""The GMNS reader: a duckOSM GMNS database -> the lane table and turns render_lanes draws."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def _cols(con, schema, table, db=None):
|
|
5
|
+
q = "SELECT column_name FROM duckdb_columns() WHERE schema_name = ? AND table_name = ?"
|
|
6
|
+
args = [schema, table]
|
|
7
|
+
if db:
|
|
8
|
+
q, args = q + " AND database_name = ?", args + [db]
|
|
9
|
+
return {c for (c,) in con.execute(q, args).fetchall()}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def from_gmns(gmns_db, mode="driving", source_db=None):
|
|
13
|
+
"""Read ``gmns_<mode>.lane`` / ``.link`` / ``.movement`` into ``(lanes, turns)``.
|
|
14
|
+
|
|
15
|
+
``lanes``: a GeoDataFrame, one row per lane (EPSG:4326, each line in the direction of travel)
|
|
16
|
+
with ``lane_id``, ``highway`` (the link's ``facility_type``), ``width_m`` (``lane.width``; null
|
|
17
|
+
where untagged, render_lanes fills the default), ``use``, ``name`` (lane 1 only: one label per
|
|
18
|
+
road), ``link_id``, ``lane_num``, ``turn``, ``from_node_id`` / ``to_node_id`` and
|
|
19
|
+
``reverse_link_id`` (the same road the other way: same nodes swapped and the same geometry, so
|
|
20
|
+
the two halves of a one-way loop are not a pair; null on a one-way road),
|
|
21
|
+
for the lane lines, ``lanes`` (the link's lane count) and, from ``source_db``, ``bridge`` /
|
|
22
|
+
``tunnel`` / ``layer`` and the OSM way ``osm_id``.
|
|
23
|
+
|
|
24
|
+
Levels come from the GMNS ``link`` if it has those columns, else from ``source_db`` (the
|
|
25
|
+
duckOSM database the GMNS file was made from: ``link_id`` = ``<mode>.edges.edge_id``), else
|
|
26
|
+
every lane is at ground level (no level columns).
|
|
27
|
+
|
|
28
|
+
With duckOSM's ``lane_connector`` table, each lane-to-lane connector is a row too
|
|
29
|
+
(``connector`` True, ``from_lane`` / ``to_lane``; its level and road class from the lane it leaves).
|
|
30
|
+
|
|
31
|
+
``turns``: a DataFrame ``from_lane``, ``to_lane``, ``type`` (the movement type; ``uturn`` is
|
|
32
|
+
drawn in its own colour), from ``movement``: each lane of the inbound link in
|
|
33
|
+
``start_ib_lane``..``end_ib_lane`` into the outbound lane at the same place in
|
|
34
|
+
``start_ob_lane``..``end_ob_lane`` (equal-length ranges paired in order, as osm2gmns and duckOSM
|
|
35
|
+
write them; NULL = every lane). Empty without a movement table."""
|
|
36
|
+
import uuid
|
|
37
|
+
|
|
38
|
+
import duckdb
|
|
39
|
+
import geopandas as gpd
|
|
40
|
+
import pandas as pd
|
|
41
|
+
|
|
42
|
+
g = f"gmns_{mode}"
|
|
43
|
+
con = duckdb.connect(str(gmns_db), read_only=True)
|
|
44
|
+
try:
|
|
45
|
+
con.execute("INSTALL spatial; LOAD spatial;")
|
|
46
|
+
if not _cols(con, g, "lane"):
|
|
47
|
+
raise ValueError(f"no '{g}.lane' in {gmns_db}: build a GMNS db first (duckOSM `duckosm gmns`)")
|
|
48
|
+
link_cols = _cols(con, g, "link")
|
|
49
|
+
same = " AND ST_Equals(r.geom, k.geom)" if "geom" in link_cols else ""
|
|
50
|
+
nlanes = "k.lanes, " if "lanes" in link_cols else ""
|
|
51
|
+
lvl, src = "", f"lanestyle_src_{uuid.uuid4().hex[:8]}" # one db instance per file per process:
|
|
52
|
+
# a caller's own connection to gmns_db may already hold an attachment, so never reuse a name
|
|
53
|
+
if {"bridge", "tunnel", "layer"} <= link_cols:
|
|
54
|
+
lvl = ", k.bridge, k.tunnel, k.layer"
|
|
55
|
+
elif source_db:
|
|
56
|
+
con.execute(f"ATTACH '{str(source_db).replace(chr(39), chr(39) * 2)}' AS {src} (READ_ONLY)")
|
|
57
|
+
src_cols = _cols(con, mode, "edges", db=src)
|
|
58
|
+
if not {"edge_id", "bridge", "tunnel", "layer"} <= src_cols:
|
|
59
|
+
raise ValueError(f"no {mode}.edges with bridge / tunnel / layer in {source_db}")
|
|
60
|
+
lvl = ", e.bridge, e.tunnel, e.layer" + (", e.osm_id" if "osm_id" in src_cols else "")
|
|
61
|
+
join = f"LEFT JOIN {src}.{mode}.edges e ON e.edge_id = l.link_id" if lvl.startswith(", e.") else ""
|
|
62
|
+
df = con.execute(
|
|
63
|
+
f"SELECT l.lane_id::VARCHAR AS lane_id, k.facility_type AS highway, l.width AS width_m, "
|
|
64
|
+
f" COALESCE(l.allowed_uses, 'auto') AS use, "
|
|
65
|
+
f" CASE WHEN l.lane_num = 1 THEN k.name END AS name, "
|
|
66
|
+
f" l.link_id, l.lane_num, {nlanes}l.turn, k.from_node_id, k.to_node_id, "
|
|
67
|
+
f" (SELECT min(r.link_id) FROM {g}.link r WHERE r.from_node_id = k.to_node_id "
|
|
68
|
+
f" AND r.to_node_id = k.from_node_id AND r.link_id <> k.link_id{same}) AS reverse_link_id"
|
|
69
|
+
f" {lvl}, ST_AsWKB(l.geom) AS geom "
|
|
70
|
+
f"FROM {g}.lane l JOIN {g}.link k ON k.link_id = l.link_id {join} "
|
|
71
|
+
f"WHERE l.geom IS NOT NULL ORDER BY l.link_id, l.lane_num").df()
|
|
72
|
+
conn = None # duckOSM's lane connectors (docs/design/gmns_lane_connectors.md)
|
|
73
|
+
if _cols(con, g, "lane_connector"):
|
|
74
|
+
conn = con.execute(f"SELECT connector_id::VARCHAR AS lane_id, from_lane_id::VARCHAR AS from_lane, "
|
|
75
|
+
f"to_lane_id::VARCHAR AS to_lane, width AS width_m, ST_AsWKB(geom) AS geom "
|
|
76
|
+
f"FROM {g}.lane_connector WHERE geom IS NOT NULL").df()
|
|
77
|
+
turns = pd.DataFrame({"from_lane": [], "to_lane": [], "type": []}, dtype=object)
|
|
78
|
+
if _cols(con, g, "movement"):
|
|
79
|
+
turns = con.execute(
|
|
80
|
+
f"SELECT DISTINCT il.lane_id::VARCHAR AS from_lane, ol.lane_id::VARCHAR AS to_lane, m.type "
|
|
81
|
+
f"FROM {g}.movement m "
|
|
82
|
+
f"JOIN {g}.lane il ON il.link_id = m.ib_link_id AND (m.start_ib_lane IS NULL OR "
|
|
83
|
+
f" il.lane_num BETWEEN m.start_ib_lane AND COALESCE(m.end_ib_lane, m.start_ib_lane)) "
|
|
84
|
+
f"JOIN {g}.lane ol ON ol.link_id = m.ob_link_id AND (m.start_ob_lane IS NULL OR "
|
|
85
|
+
f" m.start_ib_lane IS NULL OR ol.lane_num = m.start_ob_lane + il.lane_num - m.start_ib_lane) "
|
|
86
|
+
f"ORDER BY 1, 2").df()
|
|
87
|
+
finally:
|
|
88
|
+
if lvl.startswith(", e."):
|
|
89
|
+
con.execute(f"DETACH {src}")
|
|
90
|
+
con.close()
|
|
91
|
+
for c in [c for c in ("link_id", "reverse_link_id", "from_node_id", "to_node_id", "osm_id") if c in df]:
|
|
92
|
+
df[c] = df[c].astype("Int64") # BIGINT hash ids: never float64
|
|
93
|
+
df["connector"] = False
|
|
94
|
+
if conn is not None and len(conn): # a connector looks like the lane it leaves
|
|
95
|
+
lane_cols = [c for c in df.columns if c not in ("lane_id", "width_m", "name", "lane_num", "turn", "geom",
|
|
96
|
+
"connector")]
|
|
97
|
+
conn = conn.merge(df[["lane_id"] + lane_cols].rename(columns={"lane_id": "from_lane"}), on="from_lane", how="left")
|
|
98
|
+
conn["connector"] = True
|
|
99
|
+
df = pd.concat([df, conn], ignore_index=True)
|
|
100
|
+
for c in [c for c in ("link_id", "reverse_link_id", "from_node_id", "to_node_id", "osm_id") if c in df]:
|
|
101
|
+
df[c] = df[c].astype("Int64")
|
|
102
|
+
geom = gpd.GeoSeries.from_wkb(df.pop("geom").map(bytes), crs=4326)
|
|
103
|
+
return gpd.GeoDataFrame(df, geometry=geom, crs=4326), turns
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def read_boundary(db):
|
|
107
|
+
"""The area's boundary from a duckOSM database (``main.boundary``, written when the area was built
|
|
108
|
+
with one) as a shapely geometry, or None. Pass it on: ``render_lanes(..., boundary=geom)``."""
|
|
109
|
+
import duckdb
|
|
110
|
+
from shapely import wkb
|
|
111
|
+
|
|
112
|
+
con = duckdb.connect(str(db), read_only=True)
|
|
113
|
+
try:
|
|
114
|
+
con.execute("INSTALL spatial; LOAD spatial;")
|
|
115
|
+
if "geom" not in _cols(con, "main", "boundary"):
|
|
116
|
+
return None
|
|
117
|
+
row = con.execute("SELECT ST_AsWKB(ST_Union_Agg(geom)) FROM main.boundary").fetchone()
|
|
118
|
+
finally:
|
|
119
|
+
con.close()
|
|
120
|
+
return wkb.loads(bytes(row[0])) if row and row[0] is not None else None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def boundary_from_geojson(path):
|
|
124
|
+
"""A GeoJSON file's geometries as one shapely geometry (plain json + shapely, no GDAL)."""
|
|
125
|
+
import json
|
|
126
|
+
|
|
127
|
+
import shapely
|
|
128
|
+
from shapely.geometry import shape
|
|
129
|
+
|
|
130
|
+
gj = json.loads(open(path, encoding="utf-8").read())
|
|
131
|
+
feats = gj.get("features", [gj] if gj.get("type") == "Feature" else [{"geometry": gj}])
|
|
132
|
+
return shapely.union_all([shape(f["geometry"]) for f in feats if f.get("geometry")])
|
lanestyle/junctions.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Fillets: the paved gaps between lane surfaces. Where a lane joins a road, or two carriageways
|
|
2
|
+
start to diverge, a sliver between the surfaces shows the background. A real road paves it. The
|
|
3
|
+
whole lane surface is closed (grown by ``fillet_m``, shrunk back) and what the closing adds is drawn
|
|
4
|
+
under the lanes, in the colour of the nearest lane."""
|
|
5
|
+
import math
|
|
6
|
+
|
|
7
|
+
from lanestyle.lines import _band
|
|
8
|
+
|
|
9
|
+
_CLASS_ORDER = ["motorway", "trunk", "primary", "secondary", "tertiary", "unclassified", "residential",
|
|
10
|
+
"living_street", "service"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _rank(cls):
|
|
14
|
+
c = str(cls or "").replace("_link", "")
|
|
15
|
+
return _CLASS_ORDER.index(c) if c in _CLASS_ORDER else len(_CLASS_ORDER)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def junction_fillets(lanes, s):
|
|
19
|
+
"""GeoJSON polygons (``cls``: the road class whose colour to use, ``b``: the band) of the gaps
|
|
20
|
+
narrower than ``2 × fillet_m`` anywhere between lane surfaces (lanes and connectors): the whole
|
|
21
|
+
surface is closed (grown by ``fillet_m``, shrunk back) and what the closing adds is drawn under
|
|
22
|
+
the lanes, in the colour and band of the nearest lane. Corners at junctions, the slivers between
|
|
23
|
+
two carriageways that start to diverge, the notches between adjacent lanes' round ends. Real
|
|
24
|
+
medians wider than ``2 × fillet_m`` stay open. None when off."""
|
|
25
|
+
import shapely
|
|
26
|
+
|
|
27
|
+
r_m = float(s.get("fillet_m") or 0)
|
|
28
|
+
if not r_m or not len(lanes):
|
|
29
|
+
return None
|
|
30
|
+
g = lanes.to_crs(lanes.estimate_utm_crs())
|
|
31
|
+
surf = g.geometry.buffer(g["width_m"] / 2, cap_style="round")
|
|
32
|
+
u = shapely.union_all(surf.to_numpy())
|
|
33
|
+
add = shapely.make_valid(u.buffer(r_m).buffer(-r_m).difference(u)).simplify(0.05)
|
|
34
|
+
parts = [q for p in getattr(add, "geoms", [add]) for q in getattr(shapely.make_valid(p), "geoms", [shapely.make_valid(p)])
|
|
35
|
+
if q.geom_type == "Polygon" and q.area > 0.3 and not q.buffer(-0.12).is_empty and q.is_valid]
|
|
36
|
+
if not parts:
|
|
37
|
+
return None
|
|
38
|
+
tree = shapely.STRtree(g.geometry.to_numpy())
|
|
39
|
+
cls = list(g["highway"]) if "highway" in g else [None] * len(g)
|
|
40
|
+
band = [_band(r) for r in g.itertuples(index=False)]
|
|
41
|
+
import geopandas as gpd
|
|
42
|
+
|
|
43
|
+
geo = gpd.GeoSeries(parts, crs=g.crs).to_crs(4326)
|
|
44
|
+
rnd = lambda ring: [[round(x, 7), round(y, 7)] for x, y in ring] # noqa: E731
|
|
45
|
+
feats = []
|
|
46
|
+
for part, gm in zip(parts, geo, strict=True):
|
|
47
|
+
k = int(tree.nearest(part.representative_point()))
|
|
48
|
+
feats.append({"type": "Feature", "properties": {"cls": cls[k], "b": band[k]},
|
|
49
|
+
"geometry": {"type": "Polygon", "coordinates": [rnd(gm.exterior.coords)] + [rnd(i.coords) for i in gm.interiors]}})
|
|
50
|
+
return {"type": "FeatureCollection", "features": feats}
|
lanestyle/lines.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""Lane lines (docs/design/lanestyle_on_roadstyle.md, 2b): dividers, centre lines and edge lines,
|
|
2
|
+
cut from the lanes' own geometry, as GeoJSON for the page's line layers."""
|
|
3
|
+
import math
|
|
4
|
+
|
|
5
|
+
_TRUTHY_NOT = (None, "", "no", "false", "0", 0, False)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _truthy(v):
|
|
9
|
+
return v not in _TRUTHY_NOT and not (isinstance(v, float) and math.isnan(v))
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _band(r):
|
|
13
|
+
"""roadstyle's draw band for a lane (render_web._mark_lvl): the OSM ``layer`` tag, else 1 for a
|
|
14
|
+
bridge, -1 for a tunnel; the look (tunnel / bridge) from the tags."""
|
|
15
|
+
try:
|
|
16
|
+
ly = int(float(getattr(r, "layer", None)))
|
|
17
|
+
except (TypeError, ValueError):
|
|
18
|
+
ly = 0
|
|
19
|
+
br, tu = _truthy(getattr(r, "bridge", None)), _truthy(getattr(r, "tunnel", None))
|
|
20
|
+
lvl = ly or (1 if br else -1 if tu else 0)
|
|
21
|
+
if lvl < 0:
|
|
22
|
+
return "tunnel" if tu else "low"
|
|
23
|
+
if lvl > 0:
|
|
24
|
+
return "bridge" if br else "high"
|
|
25
|
+
return "ground"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _coords(g):
|
|
29
|
+
r = lambda cs: [[round(x, 7), round(y, 7)] for x, y in cs]
|
|
30
|
+
if g.geom_type == "LineString":
|
|
31
|
+
return {"type": "LineString", "coordinates": r(g.coords)}
|
|
32
|
+
return {"type": "MultiLineString", "coordinates": [r(p.coords) for p in g.geoms]}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _paired(g):
|
|
36
|
+
"""{link_id: partner link_id} for one-way links whose lane 1's left edge lies on another one-way
|
|
37
|
+
link's lane 1 left edge, running the other way, for at least half its length: a road mapped as
|
|
38
|
+
two one-way ways (duckOSM places them side by side, docs/design/gmns_paired_carriageways.md).
|
|
39
|
+
Their shared edge is the centre line, not two edge lines."""
|
|
40
|
+
import pandas as pd
|
|
41
|
+
import shapely
|
|
42
|
+
|
|
43
|
+
rows = [r for r in g.itertuples(index=False)
|
|
44
|
+
if r.lane_num == 1 and pd.isna(getattr(r, "reverse_link_id", None))]
|
|
45
|
+
left = [r.geometry.offset_curve(r.width_m / 2, join_style="mitre", mitre_limit=2.0) for r in rows]
|
|
46
|
+
keep = [k for k, a in enumerate(left) if a.geom_type == "LineString" and not a.is_empty]
|
|
47
|
+
rows, left = [rows[k] for k in keep], [left[k] for k in keep]
|
|
48
|
+
if not rows:
|
|
49
|
+
return {}
|
|
50
|
+
tree = shapely.STRtree(left)
|
|
51
|
+
out = {}
|
|
52
|
+
for i, a in enumerate(left):
|
|
53
|
+
if rows[i].link_id in out:
|
|
54
|
+
continue
|
|
55
|
+
near = a.buffer(0.6)
|
|
56
|
+
for j in tree.query(near):
|
|
57
|
+
b = left[j]
|
|
58
|
+
if rows[j].link_id in (rows[i].link_id, *out):
|
|
59
|
+
continue
|
|
60
|
+
(ax, ay), (bx, by) = _dir(a), _dir(b)
|
|
61
|
+
if ax * bx + ay * by > -0.866: # not running the other way
|
|
62
|
+
continue
|
|
63
|
+
if b.intersection(near).length >= 0.5 * min(a.length, b.length):
|
|
64
|
+
out[rows[i].link_id], out[rows[j].link_id] = rows[j].link_id, rows[i].link_id
|
|
65
|
+
break
|
|
66
|
+
return out
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _dir(line):
|
|
70
|
+
"""The line's overall direction (start to end), a unit vector."""
|
|
71
|
+
(x0, y0), (x1, y1) = line.coords[0], line.coords[-1]
|
|
72
|
+
n = math.hypot(x1 - x0, y1 - y0) or 1.0
|
|
73
|
+
return (x1 - x0) / n, (y1 - y0) / n
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _start_dir(line):
|
|
77
|
+
"""The line's start direction (its first segment), a unit vector."""
|
|
78
|
+
(x0, y0), (x1, y1) = line.coords[0], line.coords[1]
|
|
79
|
+
n = math.hypot(x1 - x0, y1 - y0) or 1.0
|
|
80
|
+
return (x1 - x0) / n, (y1 - y0) / n
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _end_dir(line):
|
|
84
|
+
"""The line's end direction (its last segment), a unit vector."""
|
|
85
|
+
(x0, y0), (x1, y1) = line.coords[-2], line.coords[-1]
|
|
86
|
+
n = math.hypot(x1 - x0, y1 - y0) or 1.0
|
|
87
|
+
return (x1 - x0) / n, (y1 - y0) / n
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def lane_lines(lanes, s):
|
|
91
|
+
"""The lines between and beside lanes as a GeoJSON FeatureCollection; properties ``t`` (type:
|
|
92
|
+
``divider`` / ``centre`` / ``edge``), ``b`` (roadstyle band) and ``k`` (the line's width in
|
|
93
|
+
metres over cos(latitude): × 512·2^z / C is its width in pixels). None when lines are off or
|
|
94
|
+
the table has no ``link_id`` / ``lane_num``.
|
|
95
|
+
|
|
96
|
+
In a link, lane 1 is the leftmost lane in the direction of travel and the lane numbers grow to
|
|
97
|
+
the right (duckOSM, right-hand traffic): lane k's right edge is the divider to lane k+1, the
|
|
98
|
+
last lane's the road's edge, lane 1's left edge the centre line (two-way: a ``reverse_link_id``;
|
|
99
|
+
or a one-way link paired with the opposite one beside it, ``_paired``; drawn once, by the smaller
|
|
100
|
+
link id) or the edge (one-way). At each end node a line stops where it
|
|
101
|
+
enters the surface of the other links there (every link at that node but its own and its
|
|
102
|
+
reverse): at a junction (3 or more neighbours) ``junction_trim_m`` short of it, elsewhere right
|
|
103
|
+
at it, so the inner edges of a sharp bend meet instead of crossing."""
|
|
104
|
+
# ponytail: right-hand traffic only (every area so far); left-hand needs duckOSM's lane order there
|
|
105
|
+
from collections import defaultdict
|
|
106
|
+
|
|
107
|
+
import pandas as pd
|
|
108
|
+
import shapely
|
|
109
|
+
|
|
110
|
+
styles = s.get("lines") or {}
|
|
111
|
+
if not any(styles.values()) or not {"link_id", "lane_num"} <= set(lanes.columns):
|
|
112
|
+
return None
|
|
113
|
+
if "connector" in lanes: # connectors carry no lane lines
|
|
114
|
+
lanes = lanes[~lanes["connector"].fillna(False).astype(bool)]
|
|
115
|
+
g = lanes.to_crs(lanes.estimate_utm_crs())
|
|
116
|
+
lat = lanes.geometry.representative_point().y.to_numpy()
|
|
117
|
+
last = g.groupby("link_id")["lane_num"].transform("max").to_numpy()
|
|
118
|
+
trim = float(s.get("junction_trim_m") or 0)
|
|
119
|
+
partner = _paired(g)
|
|
120
|
+
at, surface, cuts = defaultdict(set), {}, {} # node -> its links; link -> its lanes' surface
|
|
121
|
+
if {"from_node_id", "to_node_id"} <= set(g.columns):
|
|
122
|
+
nb = defaultdict(set)
|
|
123
|
+
for lk, a, b in set(zip(g["link_id"], g["from_node_id"], g["to_node_id"])):
|
|
124
|
+
nb[a].add(b)
|
|
125
|
+
nb[b].add(a)
|
|
126
|
+
at[a].add(lk)
|
|
127
|
+
at[b].add(lk)
|
|
128
|
+
junction = {n for n in at if len(nb[n]) >= 3}
|
|
129
|
+
polys = g.geometry.buffer(g["width_m"] / 2, cap_style="flat")
|
|
130
|
+
surface = {lk: shapely.union_all(p.to_numpy()) for lk, p in polys.groupby(g["link_id"].to_numpy())}
|
|
131
|
+
|
|
132
|
+
# a lane that goes on into another link's lane (lane k's end on lane k's start within 0.3 m,
|
|
133
|
+
# heading on within 45 degrees, the same width: duckOSM places a road's pieces as one run) is
|
|
134
|
+
# the same road. Such lanes form a chain: its lines are offset once from the chain's merged
|
|
135
|
+
# geometry and each piece takes its share, so the joints have no ticks or kinks; and the chain's
|
|
136
|
+
# links never cut each other's lines. The heading matters: a one-lane one-way road has its lane
|
|
137
|
+
# on its own line, so at a T-junction the side road's lane starts exactly where the road's ends.
|
|
138
|
+
from collections import Counter
|
|
139
|
+
|
|
140
|
+
from shapely.geometry import LinearRing, LineString, Point, Polygon
|
|
141
|
+
from shapely.ops import substring
|
|
142
|
+
|
|
143
|
+
rows = list(g.itertuples(index=False))
|
|
144
|
+
nxt, goes_on = {}, defaultdict(set)
|
|
145
|
+
if {"from_node_id", "to_node_id"} <= set(g.columns):
|
|
146
|
+
starts = [(r.link_id, r.lane_num, Point(r.geometry.coords[0]), _start_dir(r.geometry), r.width_m) for r in rows]
|
|
147
|
+
tree = shapely.STRtree([p for _, _, p, _, _ in starts])
|
|
148
|
+
cand = {}
|
|
149
|
+
for i, r in enumerate(rows):
|
|
150
|
+
end, (ex, ey) = Point(r.geometry.coords[-1]), _end_dir(r.geometry)
|
|
151
|
+
c = [j for j in tree.query(end.buffer(0.3))
|
|
152
|
+
if starts[j][0] != r.link_id and starts[j][1] == r.lane_num and starts[j][2].distance(end) <= 0.3
|
|
153
|
+
and ex * starts[j][3][0] + ey * starts[j][3][1] > 0.707 and abs(starts[j][4] - r.width_m) < 0.01]
|
|
154
|
+
if len(c) == 1:
|
|
155
|
+
cand[i] = c[0]
|
|
156
|
+
taken = Counter(cand.values())
|
|
157
|
+
nxt = {i: j for i, j in cand.items() if taken[j] == 1}
|
|
158
|
+
for i, j in nxt.items():
|
|
159
|
+
goes_on[rows[i].link_id].add(rows[j].link_id)
|
|
160
|
+
goes_on[rows[j].link_id].add(rows[i].link_id)
|
|
161
|
+
prv = {j: i for i, j in nxt.items()}
|
|
162
|
+
chains, chain_of = [], {}
|
|
163
|
+
for start in [i for i in range(len(rows)) if i not in prv] + list(range(len(rows))):
|
|
164
|
+
if start in chain_of:
|
|
165
|
+
continue
|
|
166
|
+
ch, x = [], start
|
|
167
|
+
while x is not None and x not in chain_of:
|
|
168
|
+
chain_of[x] = len(chains)
|
|
169
|
+
ch.append(x)
|
|
170
|
+
x = nxt.get(x)
|
|
171
|
+
chains.append(ch)
|
|
172
|
+
closed = {ci for ci, ch in enumerate(chains) if len(ch) > 1 and nxt.get(ch[-1]) == ch[0]}
|
|
173
|
+
offset_cache = {}
|
|
174
|
+
|
|
175
|
+
def chain_line(ci, off):
|
|
176
|
+
"""The chain's merged lanes offset once: (line, is_ring), or None when the geometry won't do."""
|
|
177
|
+
if (ci, off) in offset_cache:
|
|
178
|
+
return offset_cache[ci, off]
|
|
179
|
+
coords = list(rows[chains[ci][0]].geometry.coords)
|
|
180
|
+
for i in chains[ci][1:]:
|
|
181
|
+
c = list(rows[i].geometry.coords)
|
|
182
|
+
coords += c[1:] if Point(c[0]).distance(Point(coords[-1])) < 0.01 else c
|
|
183
|
+
res = None
|
|
184
|
+
if ci in closed:
|
|
185
|
+
if Point(coords[0]).distance(Point(coords[-1])) >= 0.01:
|
|
186
|
+
coords.append(coords[0])
|
|
187
|
+
poly = Polygon(coords)
|
|
188
|
+
if poly.is_valid and poly.area >= 1.0:
|
|
189
|
+
ccw = LinearRing(coords).is_ccw
|
|
190
|
+
buf = poly.buffer(-abs(off) if (off > 0) == ccw else abs(off)) # left (+) is inside a ccw ring
|
|
191
|
+
if not buf.is_empty and buf.geom_type == "Polygon":
|
|
192
|
+
bd = list(buf.exterior.coords)
|
|
193
|
+
res = (LineString(bd if LinearRing(bd).is_ccw == ccw else bd[::-1]), True)
|
|
194
|
+
else:
|
|
195
|
+
line = LineString(coords).offset_curve(off, join_style="mitre", mitre_limit=2.0)
|
|
196
|
+
if line.geom_type == "LineString" and not line.is_empty:
|
|
197
|
+
res = (line, False)
|
|
198
|
+
offset_cache[ci, off] = res
|
|
199
|
+
return res
|
|
200
|
+
|
|
201
|
+
def piece_line(i, off):
|
|
202
|
+
"""Lane i's line at offset ``off``: its share of the chain's one offset line, else its own."""
|
|
203
|
+
gm = rows[i].geometry
|
|
204
|
+
ci = chain_of[i]
|
|
205
|
+
if len(chains[ci]) > 1 and chain_line(ci, off):
|
|
206
|
+
line, ring = chain_line(ci, off)
|
|
207
|
+
s0, s1 = line.project(Point(gm.coords[0])), line.project(Point(gm.coords[-1]))
|
|
208
|
+
if ring and s1 <= s0:
|
|
209
|
+
seg = LineString(list(substring(line, s0, line.length).coords) + list(substring(line, 0, s1).coords)[1:])
|
|
210
|
+
else:
|
|
211
|
+
seg = substring(line, s0, s1)
|
|
212
|
+
if seg.geom_type == "LineString" and seg.length >= 0.1:
|
|
213
|
+
return seg.simplify(0.05)
|
|
214
|
+
return gm.offset_curve(off, join_style="mitre", mitre_limit=2.0).simplify(0.05)
|
|
215
|
+
|
|
216
|
+
def cut(node, link, mates):
|
|
217
|
+
"""The other links' surface at a node (grown by the trim at a junction): where lines stop.
|
|
218
|
+
``mates``: the link's reverse and paired carriageway, whose surface it shares an edge with,
|
|
219
|
+
and the links its lanes go on into (one road)."""
|
|
220
|
+
if node not in at:
|
|
221
|
+
return None
|
|
222
|
+
mates = set(mates) | goes_on.get(link, set())
|
|
223
|
+
if (node, link) not in cuts:
|
|
224
|
+
other = [surface[k] for k in at[node] if k != link and k not in mates]
|
|
225
|
+
u = shapely.union_all(other) if other else None
|
|
226
|
+
cuts[node, link] = u.buffer(trim) if u is not None and trim and node in junction else u
|
|
227
|
+
return cuts[node, link]
|
|
228
|
+
out = []
|
|
229
|
+
for i, r in enumerate(rows):
|
|
230
|
+
half = r.width_m / 2
|
|
231
|
+
rev = getattr(r, "reverse_link_id", None)
|
|
232
|
+
todo = []
|
|
233
|
+
mate = partner.get(r.link_id)
|
|
234
|
+
if r.lane_num == 1:
|
|
235
|
+
if not (rev is None or pd.isna(rev)):
|
|
236
|
+
if r.link_id < rev:
|
|
237
|
+
todo.append(("centre", half))
|
|
238
|
+
elif mate is not None: # one side of a road mapped as 2 ways
|
|
239
|
+
if r.link_id < mate:
|
|
240
|
+
todo.append(("centre", half))
|
|
241
|
+
else:
|
|
242
|
+
todo.append(("edge", half))
|
|
243
|
+
todo.append(("edge" if r.lane_num == last[i] else "divider", -half))
|
|
244
|
+
mates = {m for m in (None if rev is None or pd.isna(rev) else rev, mate) if m is not None}
|
|
245
|
+
stops = [c for c in (cut(getattr(r, "from_node_id", None), r.link_id, mates),
|
|
246
|
+
cut(getattr(r, "to_node_id", None), r.link_id, mates)) if c is not None]
|
|
247
|
+
band, sec = _band(r), 1 / math.cos(math.radians(lat[i]))
|
|
248
|
+
for t, off in todo:
|
|
249
|
+
st = styles.get(t)
|
|
250
|
+
if not st:
|
|
251
|
+
continue
|
|
252
|
+
line = piece_line(i, off)
|
|
253
|
+
for c in stops:
|
|
254
|
+
line = line.difference(c)
|
|
255
|
+
line = shapely.line_merge(line) if line.geom_type == "MultiLineString" else line
|
|
256
|
+
parts = [p for p in getattr(line, "geoms", [line]) if p.length >= 0.5] # no crumbs
|
|
257
|
+
if not parts:
|
|
258
|
+
continue
|
|
259
|
+
line = parts[0] if len(parts) == 1 else shapely.MultiLineString(parts)
|
|
260
|
+
out.append((t, band, round(st["width_m"] * sec, 4), line))
|
|
261
|
+
if not out:
|
|
262
|
+
return None
|
|
263
|
+
import geopandas as gpd
|
|
264
|
+
|
|
265
|
+
geo = gpd.GeoSeries([o[3] for o in out], crs=g.crs).to_crs(4326)
|
|
266
|
+
return {"type": "FeatureCollection", "features": [
|
|
267
|
+
{"type": "Feature", "properties": {"t": t, "b": b, "k": k}, "geometry": _coords(gm)}
|
|
268
|
+
for (t, b, k, _), gm in zip(out, geo)]}
|
lanestyle/render.py
ADDED
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
"""The engine: a lane table drawn with roadstyle, one line per lane at its width in metres.
|
|
2
|
+
|
|
3
|
+
import lanestyle as ls
|
|
4
|
+
lanes, turns = ls.from_gmns("monaco_gmns.duckdb", source_db="monaco.duckdb")
|
|
5
|
+
ls.render_lanes(lanes, turns=turns).save("lanes.html")
|
|
6
|
+
|
|
7
|
+
Design: docs/design/lanestyle_on_roadstyle.md.
|
|
8
|
+
"""
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from lanestyle.junctions import junction_fillets
|
|
13
|
+
from lanestyle.lines import lane_lines
|
|
14
|
+
|
|
15
|
+
_POPUP = ["name", "lane_type", "connects", "highway", "lane_id", "lane_num", "lanes", "use", "turn", "width_m", "tunnel", "bridge",
|
|
16
|
+
"layer", "turns_in", "turns_out", "from_lane", "to_lane", "link_id", "reverse_link_id", "osm_id", "from_node_id",
|
|
17
|
+
"to_node_id"] # the ones present and not null show
|
|
18
|
+
_MARKED = ("bus", "bike") # uses painted over the palette; any other use keeps the road colour
|
|
19
|
+
|
|
20
|
+
# click a lane: it turns `clicked`, the lanes its turns lead into `turns_into`, U-turns `uturn`.
|
|
21
|
+
# roadstyle feature ids are indexes into its source, so lane_id -> id is built on the first click.
|
|
22
|
+
_CLICK_JS = """<script>
|
|
23
|
+
(function(){
|
|
24
|
+
const T = __TURNS__, C = __COLORS__;
|
|
25
|
+
let idx = null;
|
|
26
|
+
const ids = ls => (ls || []).map(l => idx[l]).filter(i => i !== undefined);
|
|
27
|
+
document.addEventListener("rs:select", e => {
|
|
28
|
+
const d = e.detail || {};
|
|
29
|
+
if (d.overlay || d.id == null) return;
|
|
30
|
+
if (!idx) { idx = {}; const all = rsQuery(() => true), rows = rsGetProps(all);
|
|
31
|
+
all.forEach((id, k) => { idx[rows[k].lane_id] = id; }); }
|
|
32
|
+
const t = T[(d.properties || {}).lane_id] || [];
|
|
33
|
+
rsColor([[[d.id], C.clicked], [ids(t[0]), C.turns_into], [ids(t[1]), C.uturn]]);
|
|
34
|
+
});
|
|
35
|
+
document.addEventListener("rs:deselect", () => rsColor(null));
|
|
36
|
+
})();
|
|
37
|
+
</script>
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# the lane lines: one MapLibre line layer per band and type, right after that band's fill, so a
|
|
42
|
+
# bridge covers the lines of the street under it; widths in metres (k = width_m / cos(lat)) from
|
|
43
|
+
# width_m_zoom, dashes in multiples of the line width (dash_m / width_m), exact at every zoom
|
|
44
|
+
_LINES_JS = """<script>
|
|
45
|
+
(function(){
|
|
46
|
+
const D = __LINES__, S = __STYLES__, Z = __ZOOM__;
|
|
47
|
+
// never thinner than one physical pixel of this screen (a sub-pixel line vanishes in some browsers
|
|
48
|
+
// and at browser zooms under 100 %), true to scale above that; faded in over two zoom levels
|
|
49
|
+
const MIN = __MIN_DEVICE_PX__ / (window.devicePixelRatio || 1); // compact columns (_compact), rebuilt here
|
|
50
|
+
const L = {type: "FeatureCollection", features: D.c.map((c, i) => ({type: "Feature",
|
|
51
|
+
properties: {t: D.types[D.t[i]], b: D.bands[D.b[i]], k: D.k[i]},
|
|
52
|
+
geometry: {type: Array.isArray(c[0][0]) ? "MultiLineString" : "LineString", coordinates: c}}))};
|
|
53
|
+
// after the band's last fill layer: roadstyle (main, 2026-09-30) draws tunnel stretches at street
|
|
54
|
+
// level (roads-tunnelgr-*, after the under-road pieces); older versions have only roads-tunnel-fill
|
|
55
|
+
const AFTER = {tunnel: ["roads-tunnelgr-fill", "roads-tunnel-under-fill", "roads-tunnel-fill"],
|
|
56
|
+
low: ["roads-low-fill"], ground: ["roads-fill"], high: ["roads-high-fill"],
|
|
57
|
+
bridge: ["roads-bridge-fill"]};
|
|
58
|
+
const px = z => 512 * Math.pow(2, z) / 40075016.686;
|
|
59
|
+
function add(){
|
|
60
|
+
if (map.getSource("lane-lines")) return;
|
|
61
|
+
map.addSource("lane-lines", {type: "geojson", data: L});
|
|
62
|
+
const ids = map.getStyle().layers.map(l => l.id);
|
|
63
|
+
for (const b in AFTER) {
|
|
64
|
+
const after = AFTER[b].find(id => ids.includes(id));
|
|
65
|
+
if (!after) continue;
|
|
66
|
+
const i = ids.indexOf(after);
|
|
67
|
+
for (const t in S) {
|
|
68
|
+
const s = S[t];
|
|
69
|
+
if (!s) continue;
|
|
70
|
+
const w = ["interpolate", ["exponential", 2], ["zoom"]];
|
|
71
|
+
for (let z = Z; z <= 22; z++) w.push(z, ["max", ["*", ["get", "k"], px(z)], MIN]);
|
|
72
|
+
const paint = {"line-color": s.color, "line-width": w,
|
|
73
|
+
"line-opacity": ["interpolate", ["linear"], ["zoom"], Z, 0.35, Z + 2, 1]};
|
|
74
|
+
if (s.dash_m) paint["line-dasharray"] = s.dash_m.map(d => d / s.width_m);
|
|
75
|
+
map.addLayer({id: "lane-lines-" + b + "-" + t, type: "line", source: "lane-lines", minzoom: Z,
|
|
76
|
+
filter: ["all", ["==", ["get", "t"], t], ["==", ["get", "b"], b]],
|
|
77
|
+
layout: {"line-cap": "butt", "line-join": "round"}, paint: paint}, ids[i + 1]);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (map.isStyleLoaded()) add(); else map.once("load", add);
|
|
82
|
+
})();
|
|
83
|
+
</script>
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _compact(fc):
|
|
88
|
+
"""The lines as columns, not GeoJSON features: a city's ~35k lines are half wrapping otherwise."""
|
|
89
|
+
fs = fc["features"]
|
|
90
|
+
types = sorted({f["properties"]["t"] for f in fs})
|
|
91
|
+
bands = sorted({f["properties"]["b"] for f in fs})
|
|
92
|
+
return {"types": types, "bands": bands,
|
|
93
|
+
"t": [types.index(f["properties"]["t"]) for f in fs],
|
|
94
|
+
"b": [bands.index(f["properties"]["b"]) for f in fs],
|
|
95
|
+
"k": [f["properties"]["k"] for f in fs], "c": [f["geometry"]["coordinates"] for f in fs]}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# roadstyle's tunnel look (a 45 % see-through fill over a dashed casing, light dashes on the fill) is
|
|
99
|
+
# made for road-width lines; at lane width the dashes become blocks, every connector dashes from its
|
|
100
|
+
# own start (fans where they overlap), and see-through lanes show every overlap. Lane maps draw
|
|
101
|
+
# tunnels at 85 %, a plain casing, no fill dashes: a tunnel still reads lighter, overlaps hardly show
|
|
102
|
+
_ROADSTYLE = {"config": {"tunnel_gap_shade": 0, "tunnel_dash_shade": 0, "tunnel_opacity_scale": 0.85,
|
|
103
|
+
"tunnel_fill_dash": []}}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _break_twins(g):
|
|
107
|
+
"""roadstyle pairs two lines with swapped end points (to 6 decimals) as a two-way road's two
|
|
108
|
+
directions and shifts them apart. Lanes are never such a pair (each has its own geometry), but the
|
|
109
|
+
two halves of a one-way loop are: nudge one end of the later one by 2e-6 degrees (~0.2 m)."""
|
|
110
|
+
# ponytail: geometry nudge; roadstyle's `twoway_col` (in progress on its main) replaces it
|
|
111
|
+
from shapely.geometry import LineString
|
|
112
|
+
|
|
113
|
+
key = lambda c: (round(c[0], 6), round(c[1], 6))
|
|
114
|
+
seen, geoms = set(), list(g.geometry)
|
|
115
|
+
for i, ln in enumerate(geoms):
|
|
116
|
+
if ln is None or ln.geom_type != "LineString":
|
|
117
|
+
continue
|
|
118
|
+
cs = list(ln.coords)
|
|
119
|
+
a, z = key(cs[0]), key(cs[-1])
|
|
120
|
+
if (z, a) in seen:
|
|
121
|
+
cs[-1] = (cs[-1][0] + 2e-6, cs[-1][1] + 2e-6)
|
|
122
|
+
geoms[i] = LineString(cs)
|
|
123
|
+
z = key(cs[-1])
|
|
124
|
+
seen.add((a, z))
|
|
125
|
+
return g.set_geometry(geoms, crs=g.crs)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# a lane's type label: the turns that leave it, in this order, as these words
|
|
129
|
+
_TYPE_ORDER = ["left", "uturn", "thru", "diverge", "merge", "right"]
|
|
130
|
+
_TYPE_WORD = {"left": "left", "uturn": "U-turn", "thru": "thru", "diverge": "fork", "merge": "merge",
|
|
131
|
+
"right": "right"}
|
|
132
|
+
|
|
133
|
+
# the type label along each lane (lane_type), from zoom __ZOOM__, in roadstyle's street-name font
|
|
134
|
+
_LABELS_JS = """<script>
|
|
135
|
+
(function(){
|
|
136
|
+
function add(){
|
|
137
|
+
if (map.getLayer("lane-type-labels")) return;
|
|
138
|
+
const font = map.getLayer("roads-labels") ? map.getLayoutProperty("roads-labels", "text-font") : null;
|
|
139
|
+
map.addLayer({id: "lane-type-labels", type: "symbol", source: "roads", minzoom: __ZOOM__,
|
|
140
|
+
filter: ["!", ["to-boolean", ["get", "connector"]]], // lanes only; a connector says it in its popup
|
|
141
|
+
layout: Object.assign({"symbol-placement": "line", "text-field": ["get", "lane_type"], "text-size": 11,
|
|
142
|
+
"symbol-spacing": 220, "text-keep-upright": true}, font ? {"text-font": font} : {}),
|
|
143
|
+
paint: {"text-color": "#1b1b1b", "text-halo-color": "#ffffff", "text-halo-width": 1.5}});
|
|
144
|
+
}
|
|
145
|
+
if (map.isStyleLoaded()) add(); else map.once("load", add);
|
|
146
|
+
})();
|
|
147
|
+
</script>
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# bus / bike lanes are coloured with roadstyle's "colour by" (Road class + Lane use, the latter on),
|
|
154
|
+
# but its dropdown and legend box are hidden: the colours get rows in the Roads filter box instead,
|
|
155
|
+
# under the road classes, before Bridges / Tunnels
|
|
156
|
+
_USE_ROWS_JS = """<style>.co-ctrl,.co-lg{display:none!important}</style>
|
|
157
|
+
<script>
|
|
158
|
+
(function(){
|
|
159
|
+
const ROWS = __ROWS__;
|
|
160
|
+
function add(){
|
|
161
|
+
const body = document.querySelector(".flt-ctrl .flt-body");
|
|
162
|
+
if (!body) return setTimeout(add, 200);
|
|
163
|
+
if (body.querySelector(".ls-use")) return;
|
|
164
|
+
const before = body.querySelector(".flt-grade");
|
|
165
|
+
ROWS.forEach(([label, color], i) => {
|
|
166
|
+
const lab = document.createElement("label"); lab.className = "ls-use";
|
|
167
|
+
if (i === 0) lab.style.cssText = "margin-top:4px;padding-top:4px;border-top:1px solid #ddd";
|
|
168
|
+
const pad = document.createElement("input"); pad.type = "checkbox"; pad.style.visibility = "hidden";
|
|
169
|
+
const sw = document.createElement("span"); sw.className = "flt-sw"; sw.style.background = color;
|
|
170
|
+
lab.appendChild(pad); lab.appendChild(sw); lab.appendChild(document.createTextNode(" " + label));
|
|
171
|
+
body.insertBefore(lab, before);
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
add();
|
|
175
|
+
})();
|
|
176
|
+
</script>
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _lane_types(g, turns):
|
|
181
|
+
"""Each lane's type label: the turns that leave it (``left + thru``, ``U-turn``, ``fork``, ``merge``)
|
|
182
|
+
or ``end`` where none does; a bus or bike lane says so first (``bus · thru``)."""
|
|
183
|
+
out = {}
|
|
184
|
+
types = turns["type"] if "type" in turns else ["turn"] * len(turns)
|
|
185
|
+
for a, t in zip(turns["from_lane"].astype(str), types):
|
|
186
|
+
out.setdefault(a, set()).add(t)
|
|
187
|
+
|
|
188
|
+
def label(lane, use):
|
|
189
|
+
ts = out.get(lane, set())
|
|
190
|
+
words = " + ".join(_TYPE_WORD.get(t, t) for t in _TYPE_ORDER + sorted(ts - set(_TYPE_ORDER)) if t in ts)
|
|
191
|
+
return (words or "end") if use == "auto" else f"{use} · {words or 'end'}"
|
|
192
|
+
return [label(lane, use) for lane, use in zip(g["lane_id"], g["use"])]
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# roadstyle ends its tunnel layers flat (butt caps, for the casing's dash ticks); at lane width two
|
|
196
|
+
# flat ends meeting at an angle leave a wedge of background, so lane maps give them round ends like
|
|
197
|
+
# every other road layer (Kaveh: "use curving for road end points")
|
|
198
|
+
_ROUND_ENDS_JS = """<script>
|
|
199
|
+
(function(){
|
|
200
|
+
function round(){
|
|
201
|
+
for (const l of map.getStyle().layers)
|
|
202
|
+
if (l.type === "line" && l.id.startsWith("roads-tunnel") && map.getLayoutProperty(l.id, "line-cap") === "butt")
|
|
203
|
+
map.setLayoutProperty(l.id, "line-cap", "round");
|
|
204
|
+
}
|
|
205
|
+
if (map.isStyleLoaded()) round(); else map.once("load", round);
|
|
206
|
+
})();
|
|
207
|
+
</script>
|
|
208
|
+
"""
|
|
209
|
+
|
|
210
|
+
# the junction fillets, a fill layer under each band's lanes, in the node's main road's colour
|
|
211
|
+
# (roadstyle's class colours, read from the page)
|
|
212
|
+
_FILLETS_JS = """<script>
|
|
213
|
+
(function(){
|
|
214
|
+
const F = __FILLETS__;
|
|
215
|
+
const BEFORE = {tunnel: ["roads-tunnel-fill"], low: ["roads-low-fill"], ground: ["roads-fill"],
|
|
216
|
+
high: ["roads-high-fill"], bridge: ["roads-bridge-fill"]};
|
|
217
|
+
function add(){
|
|
218
|
+
if (map.getSource("lane-fillets")) return;
|
|
219
|
+
map.addSource("lane-fillets", {type: "geojson", data: F});
|
|
220
|
+
const cols = window.RS_CLASS_COLORS || {};
|
|
221
|
+
const color = ["match", ["get", "cls"]];
|
|
222
|
+
for (const k in cols) color.push(k, cols[k]);
|
|
223
|
+
color.push("#888888");
|
|
224
|
+
const ids = map.getStyle().layers.map(l => l.id);
|
|
225
|
+
for (const b in BEFORE) {
|
|
226
|
+
const before = BEFORE[b].find(id => ids.includes(id));
|
|
227
|
+
if (!before) continue;
|
|
228
|
+
map.addLayer({id: "lane-fillets-" + b, type: "fill", source: "lane-fillets",
|
|
229
|
+
filter: ["==", ["get", "b"], b],
|
|
230
|
+
paint: {"fill-color": Object.keys(cols).length ? color : "#888888",
|
|
231
|
+
"fill-opacity": b === "tunnel" ? 0.85 : 1}}, before);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (map.isStyleLoaded()) add(); else map.once("load", add);
|
|
235
|
+
})();
|
|
236
|
+
</script>
|
|
237
|
+
"""
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _merge(a, b):
|
|
241
|
+
"""``a`` updated by ``b``, nested dicts merged (state only what changes)."""
|
|
242
|
+
out = dict(a)
|
|
243
|
+
for k, v in (b or {}).items():
|
|
244
|
+
out[k] = _merge(out[k], v) if isinstance(v, dict) and isinstance(out.get(k), dict) else v
|
|
245
|
+
return out
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def lane_settings(settings=None):
|
|
249
|
+
"""lanestyle's settings: ``data/lanestyle.json``, then a ``lanestyle.json`` in the current folder,
|
|
250
|
+
then ``settings["lanes"]``, each overriding only what it states (the roadstyle.json way)."""
|
|
251
|
+
s = json.loads((Path(__file__).parent / "data" / "lanestyle.json").read_text())["lanes"]
|
|
252
|
+
local = Path("lanestyle.json")
|
|
253
|
+
if local.is_file():
|
|
254
|
+
s = _merge(s, json.loads(local.read_text()).get("lanes"))
|
|
255
|
+
return _merge(s, (settings or {}).get("lanes"))
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def render_lanes(lanes, turns=None, palette="mono", settings=None, **kwargs):
|
|
259
|
+
"""Draw a lane table (see :func:`lanestyle.from_gmns` for the columns) as one roadstyle map and
|
|
260
|
+
return roadstyle's ``WebMap`` (``.save(path)``, ``.html``).
|
|
261
|
+
|
|
262
|
+
Each lane is one line on its own geometry, exactly ``width_m`` metres wide from
|
|
263
|
+
``width_m_zoom`` on (``default_width_m`` where null). Bus and bike lanes are painted over the
|
|
264
|
+
palette (a "Lane use" colouring; the legend lists only uses present). ``turns`` (``from_lane``,
|
|
265
|
+
``to_lane``, optional ``type``) makes a lane clickable: it turns red and the lanes it leads into
|
|
266
|
+
green, U-turns purple. Lane lines (dividers, centre and edge lines, styled per type under
|
|
267
|
+
``lines``) come from ``link_id`` / ``lane_num`` (+ ``reverse_link_id``, node ids; see
|
|
268
|
+
:func:`lanestyle.lines.lane_lines`). ``settings``: roadstyle settings, plus a ``"lanes"`` key
|
|
269
|
+
for lanestyle's own (``data/lanestyle.json``). Other keywords go to ``roadstyle.render_edges``."""
|
|
270
|
+
import roadstyle as rs
|
|
271
|
+
|
|
272
|
+
s = lane_settings(settings)
|
|
273
|
+
g = lanes.copy()
|
|
274
|
+
g["width_m"] = g["width_m"].fillna(s["default_width_m"]) if "width_m" in g else s["default_width_m"]
|
|
275
|
+
g["use"] = g["use"].fillna("auto") if "use" in g else "auto"
|
|
276
|
+
present = {u: s["colors"][u] for u in _MARKED if u in set(g["use"])}
|
|
277
|
+
opts = {}
|
|
278
|
+
if present:
|
|
279
|
+
opts = dict(color_options={"Road class": {}, "Lane use": {"color_by": "use", "colors": present}},
|
|
280
|
+
color_active="Lane use")
|
|
281
|
+
conn = g["connector"].fillna(False).astype(bool) if "connector" in g else None
|
|
282
|
+
if conn is not None: # arrows on lanes, not on connectors
|
|
283
|
+
g["oneway"] = ~conn
|
|
284
|
+
if turns is not None and len(turns): # what each lane is for (label + popup)
|
|
285
|
+
g["lane_type"] = _lane_types(g, turns)
|
|
286
|
+
if conn is not None and conn.any(): # a connector says what it is, in words
|
|
287
|
+
kind = dict(zip(zip(turns["from_lane"].astype(str), turns["to_lane"].astype(str)),
|
|
288
|
+
turns["type"] if "type" in turns else ["turn"] * len(turns)))
|
|
289
|
+
road = dict(zip(g["lane_id"], g.get("name", [None] * len(g))))
|
|
290
|
+
num = dict(zip(g["lane_id"], g.get("lane_num", [None] * len(g))))
|
|
291
|
+
first = {} # a road's name sits on its lane 1 only
|
|
292
|
+
for lid, nm in road.items():
|
|
293
|
+
if isinstance(nm, str):
|
|
294
|
+
first[str(lid).rsplit("_", 1)[0]] = nm
|
|
295
|
+
|
|
296
|
+
def says(a, b):
|
|
297
|
+
def lane(x):
|
|
298
|
+
n = num.get(x)
|
|
299
|
+
nm = first.get(str(x).rsplit("_", 1)[0]) or "(unnamed road)"
|
|
300
|
+
return f"lane {int(n)} of {nm}" if n == n and n is not None else str(x)
|
|
301
|
+
return f"{lane(a)} → {lane(b)}"
|
|
302
|
+
g.loc[conn, "lane_type"] = ["connector · " + _TYPE_WORD.get(kind.get((a, b)), kind.get((a, b)) or "turn")
|
|
303
|
+
for a, b in zip(g.loc[conn, "from_lane"], g.loc[conn, "to_lane"])]
|
|
304
|
+
g.loc[conn, "connects"] = [says(a, b) for a, b in zip(g.loc[conn, "from_lane"], g.loc[conn, "to_lane"])]
|
|
305
|
+
if turns is not None and len(turns): # how many lanes lead in / out, for the popup
|
|
306
|
+
g["turns_in"] = g["lane_id"].map(turns.groupby(turns["to_lane"].astype(str)).size()).fillna(0).astype(int)
|
|
307
|
+
g["turns_out"] = g["lane_id"].map(turns.groupby(turns["from_lane"].astype(str)).size()).fillna(0).astype(int)
|
|
308
|
+
lines = lane_lines(g, s)
|
|
309
|
+
fillets = junction_fillets(g, s) # connectors included: their corners are the usual gaps
|
|
310
|
+
m = rs.render_edges(
|
|
311
|
+
_break_twins(g), palette=palette,
|
|
312
|
+
width_m_col="width_m", width_m_zoom=s["width_m_zoom"], casing_m=s["casing_m"],
|
|
313
|
+
road_popup=[c for c in _POPUP if c in g.columns],
|
|
314
|
+
**{"select_color": s["colors"]["clicked"], **kwargs}, # roadstyle's own selection glow
|
|
315
|
+
settings=_merge(_ROADSTYLE, {k: v for k, v in (settings or {}).items() if k != "lanes"}),
|
|
316
|
+
**opts)
|
|
317
|
+
js = _ROUND_ENDS_JS
|
|
318
|
+
if fillets:
|
|
319
|
+
js += _FILLETS_JS.replace("__FILLETS__", json.dumps(fillets, separators=(",", ":")))
|
|
320
|
+
if present: # bus / bike colours as rows in the Roads box
|
|
321
|
+
js += _USE_ROWS_JS.replace("__ROWS__", json.dumps([[f"{u} lanes", c] for u, c in present.items()]))
|
|
322
|
+
if lines:
|
|
323
|
+
js += (_LINES_JS.replace("__LINES__", json.dumps(_compact(lines), separators=(",", ":")))
|
|
324
|
+
.replace("__STYLES__", json.dumps(s["lines"])).replace("__ZOOM__", json.dumps(s["width_m_zoom"]))
|
|
325
|
+
.replace("__MIN_DEVICE_PX__", json.dumps(s.get("line_min_device_px", 1))))
|
|
326
|
+
if turns is not None and len(turns):
|
|
327
|
+
via = {} # (from lane, to lane) -> its connector
|
|
328
|
+
if conn is not None and conn.any():
|
|
329
|
+
via = dict(zip(zip(g.loc[conn, "from_lane"], g.loc[conn, "to_lane"]), g.loc[conn, "lane_id"]))
|
|
330
|
+
js += _click_js(turns, s, via)
|
|
331
|
+
if s.get("type_label_zoom") is not None:
|
|
332
|
+
js += _LABELS_JS.replace("__ZOOM__", json.dumps(s["type_label_zoom"]))
|
|
333
|
+
if not js:
|
|
334
|
+
return m
|
|
335
|
+
html = m.html
|
|
336
|
+
i = html.rfind("</body>")
|
|
337
|
+
return type(m)(html[:i] + js + html[i:])
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _click_js(turns, s, via=None):
|
|
341
|
+
nxt, via = {}, via or {}
|
|
342
|
+
types = turns["type"] if "type" in turns else [None] * len(turns)
|
|
343
|
+
for a, b, t in zip(turns["from_lane"].astype(str), turns["to_lane"].astype(str), types):
|
|
344
|
+
nxt.setdefault(a, [[], []])[t == "uturn"].append(b)
|
|
345
|
+
if (a, b) in via: # and the connector into it
|
|
346
|
+
nxt[a][t == "uturn"].append(via[a, b])
|
|
347
|
+
return (_CLICK_JS.replace("__TURNS__", json.dumps(nxt, separators=(",", ":")))
|
|
348
|
+
.replace("__COLORS__", json.dumps({k: s["colors"][k] for k in ("clicked", "turns_into", "uturn")})))
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
_SERVE_PY = '''#!/usr/bin/env python3
|
|
352
|
+
"""Static server for this lanestyle render.
|
|
353
|
+
python serve.py [port] -> http://localhost:8080/__INDEX__"""
|
|
354
|
+
import sys, http.server, socketserver
|
|
355
|
+
from functools import partial
|
|
356
|
+
from pathlib import Path
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
class Handler(http.server.SimpleHTTPRequestHandler):
|
|
360
|
+
def do_GET(self):
|
|
361
|
+
if self.path in ("/", "/index.html") and self.path != "/__INDEX__": # the root URL opens the map
|
|
362
|
+
self.send_response(302)
|
|
363
|
+
self.send_header("Location", "/__INDEX__")
|
|
364
|
+
self.end_headers()
|
|
365
|
+
return
|
|
366
|
+
super().do_GET()
|
|
367
|
+
|
|
368
|
+
def end_headers(self):
|
|
369
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
370
|
+
self.send_header("Cache-Control", "no-store") # a rebuilt map shows on a plain reload
|
|
371
|
+
super().end_headers()
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
socketserver.TCPServer.allow_reuse_address = True
|
|
375
|
+
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8080
|
|
376
|
+
here = str(Path(__file__).resolve().parent)
|
|
377
|
+
for p in range(port, port + 20): # 8080 busy? hop to the next free port
|
|
378
|
+
try:
|
|
379
|
+
httpd = socketserver.TCPServer(("", p), partial(Handler, directory=here))
|
|
380
|
+
except OSError:
|
|
381
|
+
continue
|
|
382
|
+
if p != port:
|
|
383
|
+
print(f"port {port} busy -> using {p}")
|
|
384
|
+
print(f"serving {here}")
|
|
385
|
+
print(f" open: http://localhost:{p}/ (-> __INDEX__)")
|
|
386
|
+
print(f" REMOTE: forward port {p} (VS Code auto-forwards it in the Ports panel; "
|
|
387
|
+
f"or `ssh -L {p}:localhost:{p} <host>`), then open the URL above")
|
|
388
|
+
httpd.serve_forever()
|
|
389
|
+
break
|
|
390
|
+
else:
|
|
391
|
+
sys.exit(f"no free port in {port}..{port + 19}")
|
|
392
|
+
'''
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def write_serve(out_html):
|
|
396
|
+
"""Drop a ``serve.py`` next to ``out_html`` — ``python serve.py [port]`` serves that folder (auto-hops
|
|
397
|
+
off a busy port) and prints the map URL. Returns the serve.py path."""
|
|
398
|
+
from pathlib import Path
|
|
399
|
+
|
|
400
|
+
out_html = Path(out_html)
|
|
401
|
+
serve = out_html.parent / "serve.py"
|
|
402
|
+
serve.write_text(_SERVE_PY.replace("__INDEX__", out_html.name))
|
|
403
|
+
return serve
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: lanestyle
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Lane-level road maps on roadstyle: every lane drawn at its real width, with lane lines, turns and a click-to-see-connectivity view, from a lane table or a duckOSM GMNS database
|
|
5
|
+
Author: Kaveh
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Khoshkhah/lanestyle
|
|
8
|
+
Project-URL: Repository, https://github.com/Khoshkhah/lanestyle
|
|
9
|
+
Project-URL: Issues, https://github.com/Khoshkhah/lanestyle/issues
|
|
10
|
+
Project-URL: roadstyle, https://github.com/Khoshkhah/roadstyle
|
|
11
|
+
Project-URL: duckOSM, https://github.com/Khoshkhah/duckOSM
|
|
12
|
+
Keywords: lanes,lane-level map,GMNS,OpenStreetMap,roadstyle,duckOSM,MapLibre,traffic
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Science/Research
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Scientific/Engineering :: GIS
|
|
21
|
+
Classifier: Topic :: Scientific/Engineering :: Visualization
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Requires-Dist: roadstyle>=0.11
|
|
26
|
+
Requires-Dist: geopandas
|
|
27
|
+
Requires-Dist: shapely>=2
|
|
28
|
+
Requires-Dist: duckdb
|
|
29
|
+
Requires-Dist: pandas
|
|
30
|
+
Provides-Extra: dev
|
|
31
|
+
Requires-Dist: pytest; extra == "dev"
|
|
32
|
+
Dynamic: license-file
|
|
33
|
+
|
|
34
|
+
<p align="center">
|
|
35
|
+
<img src="https://raw.githubusercontent.com/Khoshkhah/lanestyle/master/docs/img/logo.svg" alt="lanestyle logo" width="96">
|
|
36
|
+
</p>
|
|
37
|
+
|
|
38
|
+
<h1 align="center">lanestyle</h1>
|
|
39
|
+
|
|
40
|
+
<p align="center">
|
|
41
|
+
<b>Lane-level road maps from Python.</b><br>
|
|
42
|
+
Every lane drawn at its real width, with painted lane lines, turns, and a click that shows where a lane leads. One offline HTML file.
|
|
43
|
+
</p>
|
|
44
|
+
|
|
45
|
+
<p align="center">
|
|
46
|
+
<a href="https://pypi.org/project/lanestyle/"><img src="https://img.shields.io/pypi/v/lanestyle.svg" alt="PyPI"></a>
|
|
47
|
+
<a href="https://github.com/Khoshkhah/lanestyle/actions/workflows/test.yml"><img src="https://github.com/Khoshkhah/lanestyle/actions/workflows/test.yml/badge.svg?branch=master" alt="Tests"></a>
|
|
48
|
+
<a href="https://khoshkhah.github.io/lanestyle/"><img src="https://img.shields.io/badge/docs-khoshkhah.github.io%2Flanestyle-3f51b5.svg" alt="Docs"></a>
|
|
49
|
+
<img src="https://img.shields.io/badge/python-3.10%2B-3776AB.svg" alt="Python 3.10+">
|
|
50
|
+
<a href="https://github.com/Khoshkhah/lanestyle/blob/master/LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License: MIT"></a>
|
|
51
|
+
</p>
|
|
52
|
+
|
|
53
|
+
<p align="center">
|
|
54
|
+
<a href="#install">Install</a> ·
|
|
55
|
+
<a href="#quickstart">Quickstart</a> ·
|
|
56
|
+
<a href="#gallery">Gallery</a> ·
|
|
57
|
+
<a href="#what-goes-in">What goes in</a> ·
|
|
58
|
+
<a href="https://khoshkhah.github.io/lanestyle/">Documentation</a>
|
|
59
|
+
</p>
|
|
60
|
+
|
|
61
|
+

|
|
62
|
+
|
|
63
|
+
## Why lanestyle
|
|
64
|
+
|
|
65
|
+
- **Lanes, not roads.** Each lane is its own line, exactly its width in metres from zoom 16 on
|
|
66
|
+
(3.25 m where OSM says nothing), so neighbours sit side by side with no gap and no overlap.
|
|
67
|
+
- **Painted lane lines.** Dashed dividers between lanes of one direction, a solid centre line
|
|
68
|
+
between the two directions, grey edges: drawn to scale, cut at junctions, styled per type.
|
|
69
|
+
- **Turns you can see.** Click a lane: it turns red, the lanes it leads into green, U-turns purple.
|
|
70
|
+
Every lane carries its turn label (`left + thru`, `right`, `fork`, `merge`, `end`).
|
|
71
|
+
- **Built on [roadstyle](https://github.com/Khoshkhah/roadstyle).** Bridges over, tunnels under
|
|
72
|
+
(from the OSM `layer` tag), base maps, the filter box, popups, arrows and the `rs*` JavaScript API
|
|
73
|
+
come with it. lanestyle is to lanes what [mapstyle](https://github.com/Khoshkhah/mapstyle) is to the
|
|
74
|
+
full base map.
|
|
75
|
+
- **From OpenStreetMap.** [duckOSM](https://github.com/Khoshkhah/duckOSM) places the lanes and works
|
|
76
|
+
out the lane-to-lane turns as a GMNS database; `from_gmns` reads it. Any other lane table draws too.
|
|
77
|
+
- **One offline file.** Map, data and styling in a single HTML page: open it, serve it, embed it.
|
|
78
|
+
|
|
79
|
+
## Install
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
pip install lanestyle
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Python ≥ 3.10. Brings roadstyle ≥ 0.11 (line widths in metres), geopandas, shapely 2, duckdb and pandas.
|
|
86
|
+
|
|
87
|
+
## Quickstart
|
|
88
|
+
|
|
89
|
+
**No data needed.** Monaco's lanes ship with the repo as GeoParquet
|
|
90
|
+
([`docs/data/`](docs/data), 1 MB):
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
import geopandas as gpd, pandas as pd
|
|
94
|
+
import lanestyle as ls
|
|
95
|
+
|
|
96
|
+
lanes = gpd.read_parquet("docs/data/monaco_lanes.parquet")
|
|
97
|
+
turns = pd.read_parquet("docs/data/monaco_turns.parquet")
|
|
98
|
+
ls.render_lanes(lanes, turns=turns).save("monaco.html") # open it: no server needed
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
**Your own area**, with duckOSM (`pip install "duckosm @ git+https://github.com/Khoshkhah/duckOSM"`):
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
duckosm build --pbf monaco-latest.osm.pbf -o monaco.duckdb -m driving
|
|
105
|
+
duckosm gmns monaco.duckdb -m driving -o monaco_gmns.duckdb
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
lanes, turns = ls.from_gmns("monaco_gmns.duckdb", source_db="monaco.duckdb") # + levels, boundary
|
|
110
|
+
ls.render_lanes(lanes, turns=turns, boundary=ls.read_boundary("monaco.duckdb")).save("monaco.html")
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Or from the shell: `python render_lanes.py monaco_gmns.duckdb monaco.html --source-db monaco.duckdb`.
|
|
114
|
+
The whole chain from a raw `.osm.pbf`: [From OSM to lanes](https://khoshkhah.github.io/lanestyle/pipeline/).
|
|
115
|
+
|
|
116
|
+
**More looks:** every roadstyle keyword passes through.
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
ls.render_lanes(lanes, turns=turns, basemap="dark_matter") # dark
|
|
120
|
+
ls.render_lanes(lanes, turns=turns, settings={"lanes": {"colors": {"bus": "#d35400"}}}) # your colours
|
|
121
|
+
ls.render_lanes(lanes, settings={"lanes": {"lines": False, "type_label_zoom": None}}) # plain lanes
|
|
122
|
+
ls.render_lanes(lanes, turns=turns, overlays=[rs.Overlay(route_gdf, label="route")]) # a lane route on top
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Gallery
|
|
126
|
+
|
|
127
|
+
<table>
|
|
128
|
+
<tr>
|
|
129
|
+
<td width="33%" valign="top"><a href="https://khoshkhah.github.io/lanestyle/gallery/"><img src="https://raw.githubusercontent.com/Khoshkhah/lanestyle/master/docs/img/gallery/lane_lines.jpg" alt="Lane lines" width="100%"></a><br><b>Lane lines</b><br><sub>dividers, centre line, edges, to scale</sub></td>
|
|
130
|
+
<td width="33%" valign="top"><a href="https://khoshkhah.github.io/lanestyle/gallery/"><img src="https://raw.githubusercontent.com/Khoshkhah/lanestyle/master/docs/img/gallery/roundabout.jpg" alt="A roundabout" width="100%"></a><br><b>A roundabout</b><br><sub>one ring, one road</sub></td>
|
|
131
|
+
<td width="33%" valign="top"><a href="https://khoshkhah.github.io/lanestyle/gallery/"><img src="https://raw.githubusercontent.com/Khoshkhah/lanestyle/master/docs/img/gallery/tunnel.jpg" alt="A tunnel" width="100%"></a><br><b>A tunnel</b><br><sub>under the street, from the <code>layer</code> tag</sub></td>
|
|
132
|
+
</tr>
|
|
133
|
+
<tr>
|
|
134
|
+
<td width="33%" valign="top"><a href="https://khoshkhah.github.io/lanestyle/gallery/"><img src="https://raw.githubusercontent.com/Khoshkhah/lanestyle/master/docs/img/gallery/bus_lane.jpg" alt="Bus lanes" width="100%"></a><br><b>Bus lanes</b><br><sub>painted over the palette, a row in the Roads box</sub></td>
|
|
135
|
+
<td width="33%" valign="top"><a href="https://khoshkhah.github.io/lanestyle/gallery/"><img src="https://raw.githubusercontent.com/Khoshkhah/lanestyle/master/docs/img/gallery/click.jpg" alt="Click a lane" width="100%"></a><br><b>Click a lane</b><br><sub>red, into green, U-turns purple</sub></td>
|
|
136
|
+
<td width="33%" valign="top"><a href="https://khoshkhah.github.io/lanestyle/gallery/"><img src="https://raw.githubusercontent.com/Khoshkhah/lanestyle/master/docs/img/gallery/overview.jpg" alt="Zoomed out" width="100%"></a><br><b>Zoomed out</b><br><sub>roadstyle's class widths below zoom 16</sub></td>
|
|
137
|
+
</tr>
|
|
138
|
+
</table>
|
|
139
|
+
|
|
140
|
+
Every look with its code: **[the gallery](https://khoshkhah.github.io/lanestyle/gallery/)**.
|
|
141
|
+
|
|
142
|
+
## What goes in
|
|
143
|
+
|
|
144
|
+
`lanes` is a GeoDataFrame, one row per lane, each line in the direction of travel. Three columns
|
|
145
|
+
are required; the others switch features on:
|
|
146
|
+
|
|
147
|
+
| Column | Powers |
|
|
148
|
+
|---|---|
|
|
149
|
+
| `lane_id`, `geometry`, `highway` | the lane: unique id, centre line, road class (colour, filter box) |
|
|
150
|
+
| `width_m` | its width in metres (3.25 where null) |
|
|
151
|
+
| `use` | `auto`, `bus` or `bike`: bus and bike lanes are painted over the palette |
|
|
152
|
+
| `bridge` / `tunnel` / `layer` | grade separation, read as roadstyle reads them |
|
|
153
|
+
| `name` | the street label (set it on one lane per road) |
|
|
154
|
+
| `link_id`, `lane_num` (+ `reverse_link_id`, `from_node_id`, `to_node_id`) | the lane lines: which lanes share a road, which road is the other direction, where junctions are |
|
|
155
|
+
| `connector`, `from_lane`, `to_lane` | lane connectors: the curves through a junction, drawn like lanes |
|
|
156
|
+
| anything else | shown in the popup |
|
|
157
|
+
|
|
158
|
+
`turns` (optional) has `from_lane`, `to_lane` and a `type` (`thru`, `left`, `right`, `uturn`,
|
|
159
|
+
`diverge`, `merge`): the click colours, the lane type labels, and `turns_in` / `turns_out` in the
|
|
160
|
+
popup. Lane 1 is the leftmost lane (right-hand traffic). Details:
|
|
161
|
+
[The lane table](https://khoshkhah.github.io/lanestyle/guides/lane-table/).
|
|
162
|
+
|
|
163
|
+
## Settings
|
|
164
|
+
|
|
165
|
+
roadstyle's settings apply as they are; lanestyle adds a `lanes` key
|
|
166
|
+
([defaults](src/lanestyle/data/lanestyle.json)): the lane and click colours, the three lane line
|
|
167
|
+
types (`color`, `width_m`, `dash_m`), `junction_trim_m`, `type_label_zoom`, `default_width_m`,
|
|
168
|
+
`width_m_zoom`. Override them the roadstyle way, a `lanestyle.json` in the current folder or
|
|
169
|
+
`settings={"lanes": {...}}`, stating only what changes.
|
|
170
|
+
|
|
171
|
+
## Drive it from JavaScript
|
|
172
|
+
|
|
173
|
+
A lanestyle page is a roadstyle page, so the whole `window.rs*` API is there, with the lane table's
|
|
174
|
+
columns as properties:
|
|
175
|
+
|
|
176
|
+
```js
|
|
177
|
+
const bus = rsQuery(p => p.use === "bus"); rsColor(bus, "#ff8800"); rsFocus(bus);
|
|
178
|
+
rsSelect(rsQuery(p => p.lane_id === "8121729169906061189_2")[0]); // click a lane by id
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## Fidelity
|
|
182
|
+
|
|
183
|
+
Lane geometry is duckOSM's drive-side offset centre line, one continuous curve along a road.
|
|
184
|
+
Widths are the 3.25 m default wherever OSM lacks `width:lanes`, which today is nearly everywhere.
|
|
185
|
+
Lane-to-lane turns follow `turn:lanes` where tagged and osm2gmns's defaults elsewhere. A lane-level
|
|
186
|
+
*map*, not a survey-grade HD map.
|
|
187
|
+
|
|
188
|
+
## Documentation
|
|
189
|
+
|
|
190
|
+
**[khoshkhah.github.io/lanestyle](https://khoshkhah.github.io/lanestyle/)**: get started, guides
|
|
191
|
+
(the lane table, lane lines, turns and clicks, the duckOSM pipeline, websites), the gallery, the
|
|
192
|
+
Python API, settings, the command line, and the design notes.
|
|
193
|
+
|
|
194
|
+
## License
|
|
195
|
+
|
|
196
|
+
MIT.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
lanestyle/__init__.py,sha256=tNLxVer3vVM69SdCjXd47fvudNA3YmO-GHOSEvgnlHA,439
|
|
2
|
+
lanestyle/gmns.py,sha256=vY5O184DacfUeejDVvWTZfLIGVa-c3wZeEXrfxRgEaM,7735
|
|
3
|
+
lanestyle/junctions.py,sha256=PyXxzlK2t65yxlegXFS8FpU-6mTTB2y2sZfW8Zvw9ns,2664
|
|
4
|
+
lanestyle/lines.py,sha256=-Sj4TjP-2QhifWy23hXREecod4UO6NaH2deL6OaQnbw,12972
|
|
5
|
+
lanestyle/render.py,sha256=BCpeyJj4JaL86PpMz4Kd2BiCywRa5Ky2jAr_QTqVOSw,20238
|
|
6
|
+
lanestyle/data/lanestyle.json,sha256=r-3vTL-eD1dNilS7u_e7eWx2T9oe_kmIoVcZdPmt0ck,586
|
|
7
|
+
lanestyle-0.1.0.dist-info/licenses/LICENSE,sha256=DUKTbcaVakZEV31GY9Lbh9wLIdmJMCWOpKo8EwtA2Ts,1062
|
|
8
|
+
lanestyle-0.1.0.dist-info/METADATA,sha256=4KtvOrzDlagF-ccK2w5p4FNa-kb0CdnSBENsRoCRG8k,10534
|
|
9
|
+
lanestyle-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
10
|
+
lanestyle-0.1.0.dist-info/top_level.txt,sha256=gVmhr6eJklcEu-yLcO2dop2hgrv3tv8ypHPpQJXqOf8,10
|
|
11
|
+
lanestyle-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kaveh
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
lanestyle
|