lanestyle 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,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,163 @@
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/Khoshkhah/lanestyle/master/docs/img/logo.svg" alt="lanestyle logo" width="96">
3
+ </p>
4
+
5
+ <h1 align="center">lanestyle</h1>
6
+
7
+ <p align="center">
8
+ <b>Lane-level road maps from Python.</b><br>
9
+ 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.
10
+ </p>
11
+
12
+ <p align="center">
13
+ <a href="https://pypi.org/project/lanestyle/"><img src="https://img.shields.io/pypi/v/lanestyle.svg" alt="PyPI"></a>
14
+ <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>
15
+ <a href="https://khoshkhah.github.io/lanestyle/"><img src="https://img.shields.io/badge/docs-khoshkhah.github.io%2Flanestyle-3f51b5.svg" alt="Docs"></a>
16
+ <img src="https://img.shields.io/badge/python-3.10%2B-3776AB.svg" alt="Python 3.10+">
17
+ <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>
18
+ </p>
19
+
20
+ <p align="center">
21
+ <a href="#install">Install</a> ·
22
+ <a href="#quickstart">Quickstart</a> ·
23
+ <a href="#gallery">Gallery</a> ·
24
+ <a href="#what-goes-in">What goes in</a> ·
25
+ <a href="https://khoshkhah.github.io/lanestyle/">Documentation</a>
26
+ </p>
27
+
28
+ ![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)
29
+
30
+ ## Why lanestyle
31
+
32
+ - **Lanes, not roads.** Each lane is its own line, exactly its width in metres from zoom 16 on
33
+ (3.25 m where OSM says nothing), so neighbours sit side by side with no gap and no overlap.
34
+ - **Painted lane lines.** Dashed dividers between lanes of one direction, a solid centre line
35
+ between the two directions, grey edges: drawn to scale, cut at junctions, styled per type.
36
+ - **Turns you can see.** Click a lane: it turns red, the lanes it leads into green, U-turns purple.
37
+ Every lane carries its turn label (`left + thru`, `right`, `fork`, `merge`, `end`).
38
+ - **Built on [roadstyle](https://github.com/Khoshkhah/roadstyle).** Bridges over, tunnels under
39
+ (from the OSM `layer` tag), base maps, the filter box, popups, arrows and the `rs*` JavaScript API
40
+ come with it. lanestyle is to lanes what [mapstyle](https://github.com/Khoshkhah/mapstyle) is to the
41
+ full base map.
42
+ - **From OpenStreetMap.** [duckOSM](https://github.com/Khoshkhah/duckOSM) places the lanes and works
43
+ out the lane-to-lane turns as a GMNS database; `from_gmns` reads it. Any other lane table draws too.
44
+ - **One offline file.** Map, data and styling in a single HTML page: open it, serve it, embed it.
45
+
46
+ ## Install
47
+
48
+ ```bash
49
+ pip install lanestyle
50
+ ```
51
+
52
+ Python ≥ 3.10. Brings roadstyle ≥ 0.11 (line widths in metres), geopandas, shapely 2, duckdb and pandas.
53
+
54
+ ## Quickstart
55
+
56
+ **No data needed.** Monaco's lanes ship with the repo as GeoParquet
57
+ ([`docs/data/`](docs/data), 1 MB):
58
+
59
+ ```python
60
+ import geopandas as gpd, pandas as pd
61
+ import lanestyle as ls
62
+
63
+ lanes = gpd.read_parquet("docs/data/monaco_lanes.parquet")
64
+ turns = pd.read_parquet("docs/data/monaco_turns.parquet")
65
+ ls.render_lanes(lanes, turns=turns).save("monaco.html") # open it: no server needed
66
+ ```
67
+
68
+ **Your own area**, with duckOSM (`pip install "duckosm @ git+https://github.com/Khoshkhah/duckOSM"`):
69
+
70
+ ```bash
71
+ duckosm build --pbf monaco-latest.osm.pbf -o monaco.duckdb -m driving
72
+ duckosm gmns monaco.duckdb -m driving -o monaco_gmns.duckdb
73
+ ```
74
+
75
+ ```python
76
+ lanes, turns = ls.from_gmns("monaco_gmns.duckdb", source_db="monaco.duckdb") # + levels, boundary
77
+ ls.render_lanes(lanes, turns=turns, boundary=ls.read_boundary("monaco.duckdb")).save("monaco.html")
78
+ ```
79
+
80
+ Or from the shell: `python render_lanes.py monaco_gmns.duckdb monaco.html --source-db monaco.duckdb`.
81
+ The whole chain from a raw `.osm.pbf`: [From OSM to lanes](https://khoshkhah.github.io/lanestyle/pipeline/).
82
+
83
+ **More looks:** every roadstyle keyword passes through.
84
+
85
+ ```python
86
+ ls.render_lanes(lanes, turns=turns, basemap="dark_matter") # dark
87
+ ls.render_lanes(lanes, turns=turns, settings={"lanes": {"colors": {"bus": "#d35400"}}}) # your colours
88
+ ls.render_lanes(lanes, settings={"lanes": {"lines": False, "type_label_zoom": None}}) # plain lanes
89
+ ls.render_lanes(lanes, turns=turns, overlays=[rs.Overlay(route_gdf, label="route")]) # a lane route on top
90
+ ```
91
+
92
+ ## Gallery
93
+
94
+ <table>
95
+ <tr>
96
+ <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>
97
+ <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>
98
+ <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>
99
+ </tr>
100
+ <tr>
101
+ <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>
102
+ <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>
103
+ <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>
104
+ </tr>
105
+ </table>
106
+
107
+ Every look with its code: **[the gallery](https://khoshkhah.github.io/lanestyle/gallery/)**.
108
+
109
+ ## What goes in
110
+
111
+ `lanes` is a GeoDataFrame, one row per lane, each line in the direction of travel. Three columns
112
+ are required; the others switch features on:
113
+
114
+ | Column | Powers |
115
+ |---|---|
116
+ | `lane_id`, `geometry`, `highway` | the lane: unique id, centre line, road class (colour, filter box) |
117
+ | `width_m` | its width in metres (3.25 where null) |
118
+ | `use` | `auto`, `bus` or `bike`: bus and bike lanes are painted over the palette |
119
+ | `bridge` / `tunnel` / `layer` | grade separation, read as roadstyle reads them |
120
+ | `name` | the street label (set it on one lane per road) |
121
+ | `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 |
122
+ | `connector`, `from_lane`, `to_lane` | lane connectors: the curves through a junction, drawn like lanes |
123
+ | anything else | shown in the popup |
124
+
125
+ `turns` (optional) has `from_lane`, `to_lane` and a `type` (`thru`, `left`, `right`, `uturn`,
126
+ `diverge`, `merge`): the click colours, the lane type labels, and `turns_in` / `turns_out` in the
127
+ popup. Lane 1 is the leftmost lane (right-hand traffic). Details:
128
+ [The lane table](https://khoshkhah.github.io/lanestyle/guides/lane-table/).
129
+
130
+ ## Settings
131
+
132
+ roadstyle's settings apply as they are; lanestyle adds a `lanes` key
133
+ ([defaults](src/lanestyle/data/lanestyle.json)): the lane and click colours, the three lane line
134
+ types (`color`, `width_m`, `dash_m`), `junction_trim_m`, `type_label_zoom`, `default_width_m`,
135
+ `width_m_zoom`. Override them the roadstyle way, a `lanestyle.json` in the current folder or
136
+ `settings={"lanes": {...}}`, stating only what changes.
137
+
138
+ ## Drive it from JavaScript
139
+
140
+ A lanestyle page is a roadstyle page, so the whole `window.rs*` API is there, with the lane table's
141
+ columns as properties:
142
+
143
+ ```js
144
+ const bus = rsQuery(p => p.use === "bus"); rsColor(bus, "#ff8800"); rsFocus(bus);
145
+ rsSelect(rsQuery(p => p.lane_id === "8121729169906061189_2")[0]); // click a lane by id
146
+ ```
147
+
148
+ ## Fidelity
149
+
150
+ Lane geometry is duckOSM's drive-side offset centre line, one continuous curve along a road.
151
+ Widths are the 3.25 m default wherever OSM lacks `width:lanes`, which today is nearly everywhere.
152
+ Lane-to-lane turns follow `turn:lanes` where tagged and osm2gmns's defaults elsewhere. A lane-level
153
+ *map*, not a survey-grade HD map.
154
+
155
+ ## Documentation
156
+
157
+ **[khoshkhah.github.io/lanestyle](https://khoshkhah.github.io/lanestyle/)**: get started, guides
158
+ (the lane table, lane lines, turns and clicks, the duckOSM pipeline, websites), the gallery, the
159
+ Python API, settings, the command line, and the design notes.
160
+
161
+ ## License
162
+
163
+ MIT.
@@ -0,0 +1,42 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "lanestyle"
7
+ version = "0.1.0"
8
+ description = "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"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Kaveh" }]
14
+ keywords = ["lanes", "lane-level map", "GMNS", "OpenStreetMap", "roadstyle", "duckOSM", "MapLibre", "traffic"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Science/Research",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Topic :: Scientific/Engineering :: GIS",
24
+ "Topic :: Scientific/Engineering :: Visualization",
25
+ ]
26
+ dependencies = ["roadstyle>=0.11", "geopandas", "shapely>=2", "duckdb", "pandas"]
27
+
28
+ [project.optional-dependencies]
29
+ dev = ["pytest"]
30
+
31
+ [project.urls]
32
+ Homepage = "https://github.com/Khoshkhah/lanestyle"
33
+ Repository = "https://github.com/Khoshkhah/lanestyle"
34
+ Issues = "https://github.com/Khoshkhah/lanestyle/issues"
35
+ roadstyle = "https://github.com/Khoshkhah/roadstyle"
36
+ duckOSM = "https://github.com/Khoshkhah/duckOSM"
37
+
38
+ [tool.setuptools.packages.find]
39
+ where = ["src"]
40
+
41
+ [tool.setuptools.package-data]
42
+ lanestyle = ["data/*.json"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ }
@@ -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")])
@@ -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}