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 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
+ ![A lanestyle map of Monaco: Boulevard des Moulins with its bus lane in blue, dashed dividers, turn labels on every lane and a roundabout](https://raw.githubusercontent.com/Khoshkhah/lanestyle/master/docs/img/hero.jpg)
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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