roadstyle 0.2.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.
roadstyle/__init__.py ADDED
@@ -0,0 +1,124 @@
1
+ """roadstyle — OSM-style road/edge map styling; self-contained MapLibre maps (plus folium & lonboard).
2
+
3
+ import geopandas as gpd
4
+ from roadstyle import render_edges
5
+
6
+ edges = gpd.read_file(...) # needs a `highway` column
7
+ render_edges(edges).save("map.html") # web backend, Voyager base (settings default)
8
+ render_edges(edges, basemap="dark_matter",
9
+ include=["motorway", "trunk", "primary"]).save("major.html")
10
+ """
11
+ from .basemaps import BASEMAPS, Basemap, get_basemap, register_basemap
12
+ from .config import StyleConfig
13
+ from .controls import BaseLayerSwitcher
14
+ from .edges import (
15
+ RoadEdges,
16
+ as_edges,
17
+ from_arrow,
18
+ from_duckdb,
19
+ from_geojson,
20
+ load_edges,
21
+ normalize_edges,
22
+ )
23
+ from .emit import (
24
+ load_spec,
25
+ save,
26
+ save_spec,
27
+ to_geojson,
28
+ to_html,
29
+ to_iframe,
30
+ to_spec,
31
+ )
32
+ from .filters import filter_edges, highway_types
33
+ from .legend import make_legend
34
+ from .overlays import Overlay
35
+ from .palettes import (
36
+ CARTO,
37
+ HIGHSAT,
38
+ PALETTES,
39
+ SELECTION,
40
+ RoadStyle,
41
+ load_palette,
42
+ palette_from_dict,
43
+ palette_to_dict,
44
+ register_palette,
45
+ save_palette,
46
+ )
47
+ from .render import render_edges
48
+ from .snapshot import snapshot
49
+ from .style import base_style, normalize_highway, resolve, selection_style
50
+ from .stylers import (
51
+ CategoricalStyler,
52
+ ClassStyler,
53
+ ColorTableStyler,
54
+ NumericStyler,
55
+ ResolvedFrame,
56
+ Styler,
57
+ build_styler,
58
+ color_by,
59
+ color_by_class,
60
+ color_by_value,
61
+ )
62
+
63
+
64
+ def use_settings(*sources) -> None:
65
+ """Apply settings overrides from code — the in-process equivalent of a ``roadstyle.json``.
66
+
67
+ Each source is a path to a JSON file or a dict, in the same
68
+ ``{"palettes", "config", "selection", "roads"}`` layout as an override file (state only what
69
+ changes; everything else keeps the bundled defaults + any discovered override files, which
70
+ these sources outrank; later sources outrank earlier ones). Call with no arguments to drop
71
+ the programmatic overrides again. For a single render, prefer
72
+ ``render_edges(..., settings=...)`` — it applies and restores automatically.
73
+
74
+ Safe to call at any point before rendering — already-imported styling tables (palettes,
75
+ ``StyleConfig``, the web renderer's road model) are rebuilt in place::
76
+
77
+ import roadstyle as rs
78
+ rs.use_settings("styles/dark-flow.json") # or a dict
79
+ rs.render_edges(edges, backend="web").save("map.html")
80
+
81
+ Two setting sets in one process = call it again between renders. Note the handful of legacy
82
+ class-level defaults (:class:`roadstyle.style.RoadStyle` field defaults) resolve at import and
83
+ are unaffected — palette entries always restate them, so rendering is unaffected too.
84
+ """
85
+ from . import _settings, config, palettes, render_web, style, stylers
86
+
87
+ _settings.set_extra(*sources)
88
+ config.DEFAULT = config._default_config()
89
+ style.DEFAULT = stylers.DEFAULT = render_web.CONFIG = config.DEFAULT
90
+ palettes._reload()
91
+ render_web._load_road_model()
92
+
93
+
94
+ # Single source of truth is [project] version in pyproject.toml (read from the install metadata).
95
+ try:
96
+ from importlib.metadata import version as _pkg_version
97
+ __version__ = _pkg_version("roadstyle")
98
+ except Exception: # running from a bare source tree
99
+ __version__ = "0+unknown"
100
+
101
+ __all__ = [
102
+ "render_edges", "filter_edges", "highway_types", "use_settings", "snapshot",
103
+ "resolve", "base_style", "selection_style", "normalize_highway",
104
+ "PALETTES", "HIGHSAT", "CARTO", "SELECTION", "RoadStyle",
105
+ "BASEMAPS", "Basemap", "get_basemap", "BaseLayerSwitcher",
106
+ # generalization additions (Phase 0)
107
+ "StyleConfig", "register_palette", "register_basemap",
108
+ # styler abstraction (Phase 1)
109
+ "Styler", "ClassStyler", "ResolvedFrame",
110
+ # palette JSON I/O (Phase 2a)
111
+ "load_palette", "save_palette", "palette_to_dict", "palette_from_dict",
112
+ # data-driven styling (Phase 2)
113
+ "CategoricalStyler", "NumericStyler", "ColorTableStyler", "build_styler",
114
+ "color_by", "color_by_value", "color_by_class",
115
+ # canonical input (Phase 3a)
116
+ "RoadEdges", "normalize_edges", "load_edges", "as_edges",
117
+ "from_geojson", "from_arrow", "from_duckdb",
118
+ # legends (Phase 3)
119
+ "make_legend",
120
+ # extra overlay layers (zones / POIs / any geometry)
121
+ "Overlay",
122
+ # web / JSON output (Phase 4)
123
+ "to_spec", "to_geojson", "to_html", "to_iframe", "save", "save_spec", "load_spec",
124
+ ]
roadstyle/_settings.py ADDED
@@ -0,0 +1,182 @@
1
+ """Where roadstyle's built-in styling *data* lives, and how user overrides layer on top.
2
+
3
+ EVERY styling setting ships in ONE bundled JSON file — ``roadstyle/data/defaults.json`` — with
4
+ four sections (same layout as a user override file):
5
+
6
+ palettes per-class colours/widths of each named palette (highsat / carto / mono)
7
+ config the StyleConfig knobs (opacities, casing, minzoom, labels, arrows, ...)
8
+ selection the click/hover highlight colours
9
+ roads the web renderer's road model: ``width`` (px by zoom, per width-group),
10
+ ``width_zoom_rate``, ``casing_ratio``, ``group`` (class -> width-group),
11
+ ``links``, ``zoom_stops``, ``z_order`` (draw priority)
12
+
13
+ This module is the single place that reads those bundled defaults and **merges user overrides**
14
+ on top. A user of the library never edits the package — they create their own file restating
15
+ only the parts they want changed. Override sources, lowest precedence first (later ones win)::
16
+
17
+ 1. ``$XDG_CONFIG_HOME/roadstyle/roadstyle.json`` (or ``~/.config/roadstyle/roadstyle.json``)
18
+ 2. ``./roadstyle.json`` (project-local, current working dir)
19
+ 3. ``$ROADSTYLE_CONFIG`` (explicit file path)
20
+
21
+ An override file is JSON of the form::
22
+
23
+ {
24
+ "palettes": {
25
+ "highsat": {"service": {"fill": "#EEEEEE"}}, # partial — merged per road class
26
+ "mytheme": {"roads": {...}} # new palette (full class entries)
27
+ },
28
+ "config": {"fill_opacity": 0.95, "labels": {"color": "#8899aa"}},
29
+ "selection": {"core": "#FF0000"},
30
+ "roads": {"z_order": {"service": 5}} # partial — merged per table entry
31
+ }
32
+
33
+ Palette overrides are **deep-merged per road class** and ``roads`` tables per entry, so one key
34
+ can change without restating its table; ``config``/``selection`` overrides replace individual
35
+ keys. This module imports nothing from roadstyle (it returns only plain dicts), so
36
+ ``palettes.py``, ``config.py`` and ``render_web.py`` can import it without a cycle.
37
+ """
38
+ from __future__ import annotations
39
+
40
+ import json
41
+ import os
42
+ from functools import lru_cache
43
+ from pathlib import Path
44
+
45
+
46
+ def _read_json(path: Path) -> dict:
47
+ with open(path, encoding="utf-8") as fh:
48
+ return json.load(fh)
49
+
50
+
51
+ @lru_cache(maxsize=1)
52
+ def data_dir() -> Path:
53
+ """Filesystem path to the bundled ``roadstyle/data`` directory (shipped as package data)."""
54
+ return Path(__file__).resolve().parent / "data"
55
+
56
+
57
+ @lru_cache(maxsize=1)
58
+ def _bundled() -> dict:
59
+ """The whole bundled default set — ``data/defaults.json`` (palettes/config/selection/roads)."""
60
+ return _read_json(data_dir() / "defaults.json")
61
+
62
+
63
+ def _bundled_palettes() -> dict[str, dict]:
64
+ """``{palette_name: {class: roadstyle_dict}}`` from the bundled defaults."""
65
+ return _bundled().get("palettes", {})
66
+
67
+
68
+ def _bundled_style() -> dict:
69
+ """``{"config": {...}, "selection": {...}}`` from the bundled defaults."""
70
+ return _bundled()
71
+
72
+
73
+ def override_files() -> list[Path]:
74
+ """Existing user-override files, lowest precedence first (later ones win)."""
75
+ xdg = os.environ.get("XDG_CONFIG_HOME")
76
+ home_cfg = Path(xdg) if xdg else Path.home() / ".config"
77
+ candidates = [home_cfg / "roadstyle" / "roadstyle.json", Path.cwd() / "roadstyle.json"]
78
+ env = os.environ.get("ROADSTYLE_CONFIG")
79
+ if env:
80
+ candidates.append(Path(env))
81
+ return [p for p in candidates if p.is_file()]
82
+
83
+
84
+ def _norm_roads(value):
85
+ """A palette-override value → its ``{class: {...}}`` mapping (accepts a ``roads`` wrapper)."""
86
+ return value.get("roads", value) if isinstance(value, dict) else value
87
+
88
+
89
+ #: programmatic override sources set via :func:`roadstyle.use_settings` — paths or dicts,
90
+ #: applied AFTER the discovered files (highest precedence)
91
+ _EXTRA: list = []
92
+
93
+
94
+ def set_extra(*sources) -> None:
95
+ """Replace the programmatic override sources (the :func:`roadstyle.use_settings` backing) and
96
+ drop caches so the next access sees them. ``None`` entries are ignored (reset with no args)."""
97
+ _EXTRA[:] = [s for s in sources if s is not None]
98
+ refresh()
99
+
100
+
101
+ def _merged_overrides() -> dict:
102
+ """Merge every override source into one ``{"palettes", "config", "selection", "roads"}`` dict."""
103
+ merged: dict = {"palettes": {}, "config": {}, "selection": {}, "roads": {}}
104
+ for src in list(override_files()) + list(_EXTRA):
105
+ if isinstance(src, dict):
106
+ data = src
107
+ else:
108
+ try:
109
+ data = _read_json(Path(src))
110
+ except (OSError, ValueError): # unreadable / malformed JSON → skip, don't crash
111
+ continue
112
+ for name, value in (data.get("palettes") or {}).items():
113
+ dst = merged["palettes"].setdefault(name, {})
114
+ for cls, fields in (_norm_roads(value) or {}).items():
115
+ dst.setdefault(cls, {}).update(fields) # deep-merge per class
116
+ merged["config"].update(data.get("config") or {})
117
+ merged["selection"].update(data.get("selection") or {})
118
+ for table, value in (data.get("roads") or {}).items():
119
+ if isinstance(value, dict): # deep-merge per table entry
120
+ merged["roads"].setdefault(table, {}).update(value)
121
+ else: # lists (links, zoom_stops): replace
122
+ merged["roads"][table] = value
123
+ return merged
124
+
125
+
126
+ @lru_cache(maxsize=1)
127
+ def palettes() -> dict[str, dict]:
128
+ """Bundled palettes with user overrides deep-merged per road class.
129
+
130
+ Returns ``{name: {class: roadstyle_dict}}`` — raw dicts, ready for ``palette_from_dict``.
131
+ """
132
+ out = {name: {cls: dict(fields) for cls, fields in roads.items()}
133
+ for name, roads in _bundled_palettes().items()}
134
+ for name, roads in _merged_overrides()["palettes"].items():
135
+ dst = out.setdefault(name, {})
136
+ for cls, fields in roads.items():
137
+ dst.setdefault(cls, {}).update(fields)
138
+ return out
139
+
140
+
141
+ @lru_cache(maxsize=1)
142
+ def style() -> dict:
143
+ """Bundled ``config`` / ``selection`` with user overrides applied per key."""
144
+ base = _bundled_style()
145
+ ov = _merged_overrides()
146
+ return {
147
+ "config": {**base.get("config", {}), **ov["config"]},
148
+ "selection": {**base.get("selection", {}), **ov["selection"]},
149
+ }
150
+
151
+
152
+ @lru_cache(maxsize=1)
153
+ def roads() -> dict:
154
+ """The web renderer's road model tables, with user overrides deep-merged per entry.
155
+
156
+ Keys: ``width`` (px by zoom, per width-group — zoom keys are strings in JSON, the consumer
157
+ coerces), ``width_zoom_rate``, ``casing_ratio``, ``group``, ``links``, ``zoom_stops``,
158
+ ``z_order``.
159
+ """
160
+ base = _bundled().get("roads", {})
161
+ out = {k: (dict(v) if isinstance(v, dict) else list(v)) for k, v in base.items()}
162
+ for table, value in _merged_overrides()["roads"].items():
163
+ if isinstance(value, dict) and isinstance(out.get(table), dict):
164
+ for k, v in value.items(): # per-entry; width rows merge per zoom
165
+ if isinstance(v, dict) and isinstance(out[table].get(k), dict):
166
+ out[table][k] = {**out[table][k], **v}
167
+ else:
168
+ out[table][k] = v
169
+ else:
170
+ out[table] = value
171
+ return out
172
+
173
+
174
+ def refresh() -> None:
175
+ """Drop cached data so the next access re-reads the files.
176
+
177
+ Call after editing an override file in-process (or from tests that point
178
+ ``$ROADSTYLE_CONFIG`` at a temp file). Note that ``palettes`` / ``config`` already imported
179
+ elsewhere keep their import-time values; ``render_edges(palette=...)`` re-reads ``PALETTES``.
180
+ """
181
+ for fn in (data_dir, _bundled, palettes, style, roads):
182
+ fn.cache_clear()
roadstyle/basemaps.py ADDED
@@ -0,0 +1,113 @@
1
+ """Base-map (tile) providers + thumbnail metadata for the switcher control."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+
6
+ _CARTO_ATTR = "© OpenStreetMap contributors © CARTO"
7
+ _OSM_ATTR = "© OpenStreetMap contributors"
8
+ _ESRI_ATTR = "Tiles © Esri"
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class Basemap:
13
+ key: str
14
+ label: str
15
+ url: str # leaflet tile URL template ({s}{z}{x}{y}{r})
16
+ attr: str
17
+ is_dark: bool = False # dark canvas?
18
+ satellite: bool = False # apply the saturate/brightness tile filter
19
+ lonboard: str | None = None # CartoBasemap name (lonboard backend)
20
+ bg: str = "#444" # thumbnail background (CSS)
21
+ preview: tuple[str, str, str] = ("#888", "#bbb", "#888") # 3 preview road colours
22
+ subdomains: str = "abc"
23
+
24
+
25
+ BASEMAPS: dict[str, Basemap] = {
26
+ "voyager": Basemap(
27
+ "voyager", "Voyager",
28
+ "https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png", _CARTO_ATTR,
29
+ lonboard="Voyager", bg="linear-gradient(180deg,#e8eef0,#d8e0e5)",
30
+ preview=("#ff9933", "#e8ecef", "#9ec5fe")),
31
+ "positron": Basemap(
32
+ "positron", "Positron",
33
+ "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png", _CARTO_ATTR,
34
+ lonboard="Positron", bg="linear-gradient(180deg,#f3f5f7,#e3e8ed)",
35
+ preview=("#888", "#bbb", "#888")),
36
+ "dark_matter": Basemap(
37
+ "dark_matter", "Dark Matter",
38
+ "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png", _CARTO_ATTR,
39
+ is_dark=True, lonboard="DarkMatter", bg="radial-gradient(circle,#18222e,#0b1014)",
40
+ preview=("#22d3a3", "#9ec5fe", "#5b6573")),
41
+ "osm": Basemap(
42
+ "osm", "OpenStreetMap",
43
+ "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", _OSM_ATTR,
44
+ bg="linear-gradient(180deg,#f2efe9,#e8e4db)", preview=("#e07020", "#dcdcdc", "#888")),
45
+ "esri_gray": Basemap(
46
+ "esri_gray", "Light Gray",
47
+ "https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Light_Gray_Base/MapServer/tile/{z}/{y}/{x}",
48
+ _ESRI_ATTR, bg="linear-gradient(180deg,#eceff1,#d8dde1)", preview=("#9aa", "#ccc", "#9aa")),
49
+ "satellite": Basemap(
50
+ "satellite", "Satellite",
51
+ "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
52
+ _ESRI_ATTR, is_dark=True, satellite=True,
53
+ bg="linear-gradient(140deg,#2d3823,#4a3a2a,#2a2820)",
54
+ preview=("#ffe8a0", "#ffd84d", "#fffdf2")),
55
+ # tile-less base maps: url="" means no tile layer at all — just a plain canvas colour (bg).
56
+ # Zero network requests, so a saved map with one of these is fully offline.
57
+ "blank": Basemap(
58
+ "blank", "Blank", "", "", bg="#efede8", preview=("#888", "#bbb", "#888")),
59
+ "blank_dark": Basemap(
60
+ "blank_dark", "Blank dark", "", "", is_dark=True, bg="#14181d",
61
+ preview=("#22d3a3", "#9ec5fe", "#5b6573")),
62
+ }
63
+
64
+ # default set offered by the switcher when the caller doesn't specify one
65
+ DEFAULT_SWITCHER = ["voyager", "positron", "dark_matter", "osm", "satellite", "blank"]
66
+
67
+
68
+ def register_basemap(bm: Basemap) -> None:
69
+ """Register (or replace) a base map, keyed by its ``key``."""
70
+ if not isinstance(bm, Basemap):
71
+ raise TypeError(f"expected a Basemap, got {type(bm).__name__}")
72
+ BASEMAPS[bm.key] = bm
73
+
74
+
75
+ def _basemap_from_provider(tp) -> Basemap:
76
+ """Convert an ``xyzservices.TileProvider`` (duck-typed) into a :class:`Basemap`.
77
+
78
+ Lets any of the hundreds of xyzservices tile sources be used directly, e.g.
79
+ ``render_edges(edges, basemap=xyzservices.providers.CartoDB.Positron)``.
80
+ """
81
+ name = getattr(tp, "name", None) or (
82
+ tp.get("name", "custom") if hasattr(tp, "get") else "custom"
83
+ )
84
+ try:
85
+ url = tp.build_url() # leaflet-style template with {z}/{x}/{y}
86
+ except Exception:
87
+ url = tp.get("url", "") if hasattr(tp, "get") else ""
88
+ attr = ""
89
+ if hasattr(tp, "get"):
90
+ attr = tp.get("attribution", "") or tp.get("html_attribution", "") or ""
91
+ is_dark = "dark" in str(name).lower()
92
+ return Basemap(key=str(name), label=str(name).replace("_", " "), url=url, attr=attr,
93
+ is_dark=is_dark)
94
+
95
+
96
+ def get_basemap(key: str | Basemap) -> Basemap:
97
+ """Resolve a base map from a registered key, a :class:`Basemap`, or an
98
+ ``xyzservices.TileProvider`` (duck-typed via its ``build_url`` method)."""
99
+ if isinstance(key, Basemap):
100
+ return key
101
+ if isinstance(key, str):
102
+ try:
103
+ return BASEMAPS[key]
104
+ except KeyError as err:
105
+ raise ValueError(
106
+ f"unknown basemap {key!r}; choose from {list(BASEMAPS)}"
107
+ ) from err
108
+ if hasattr(key, "build_url"): # xyzservices.TileProvider
109
+ return _basemap_from_provider(key)
110
+ raise TypeError(
111
+ f"basemap must be a key, Basemap, or xyzservices.TileProvider, "
112
+ f"got {type(key).__name__}"
113
+ )
roadstyle/cli.py ADDED
@@ -0,0 +1,169 @@
1
+ """``roadstyle`` command-line interface — turn a road file into a styled map without writing Python.
2
+
3
+ roadstyle edges.gpkg -o map.html --basemap dark_matter
4
+ roadstyle edges.gpkg --color-by aadt --cmap viridis --width-by 1 6 -f web
5
+ roadstyle edges.gpkg --include motorway trunk primary -o major.html
6
+
7
+ A thin wrapper over :func:`roadstyle.render_edges` (the ``web`` MapLibre backend — the default — and
8
+ ``folium``) and :func:`roadstyle.save` / :func:`roadstyle.save_spec` / :func:`roadstyle.to_geojson`
9
+ (the ``rsjs`` roadstyle.js page / JSON outputs). Every styling flag mirrors the Python keyword of
10
+ the same name, so the CLI and the library stay in lock-step.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import json
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ from . import __version__
20
+
21
+ # output format → default file extension for the derived output name
22
+ _EXT = {"web": ".html", "folium": ".html", "rsjs": ".html", "spec": ".json", "geojson": ".geojson"}
23
+
24
+
25
+ def _build_parser() -> argparse.ArgumentParser:
26
+ p = argparse.ArgumentParser(
27
+ prog="roadstyle",
28
+ description="Render a road-edge file (GPKG, GeoJSON, Shapefile, …) into a styled "
29
+ "interactive map or a portable JSON spec.",
30
+ epilog="examples:\n"
31
+ " roadstyle edges.gpkg -o map.html --basemap dark_matter\n"
32
+ " roadstyle edges.gpkg --palette carto --basemap positron\n"
33
+ " roadstyle edges.gpkg --include motorway trunk primary -o major.html\n"
34
+ " roadstyle edges.gpkg --color-by aadt --cmap viridis --width-by 1 6 -f web\n"
35
+ " roadstyle edges.gpkg -f spec -o map_data.json # JSON for your own frontend",
36
+ formatter_class=argparse.RawDescriptionHelpFormatter,
37
+ )
38
+ p.add_argument("input", help="road-data file with a road-class column (any CRS).")
39
+ p.add_argument("-o", "--output",
40
+ help="output path (default: input name with the format's extension).")
41
+ p.add_argument("-f", "--format", default="web",
42
+ choices=["web", "folium", "rsjs", "spec", "geojson"],
43
+ help="web = self-contained MapLibre map (default); folium = interactive folium "
44
+ "HTML; rsjs = standalone roadstyle.js page; spec = JSON spec; "
45
+ "geojson = styled GeoJSON.")
46
+ p.add_argument("--version", action="version", version=f"roadstyle {__version__}")
47
+
48
+ style = p.add_argument_group("styling")
49
+ style.add_argument("--palette", default="highsat", choices=["highsat", "carto"],
50
+ help="class palette (default: highsat).")
51
+ style.add_argument("--basemap", help="the primary base map layer (a key in BASEMAPS; "
52
+ "default from settings: voyager).")
53
+ style.add_argument("--tooltip", nargs="+", metavar="COL",
54
+ help="columns to show in the hover tooltip.")
55
+
56
+ flt = p.add_argument_group("filtering")
57
+ flt.add_argument("--include", nargs="+", metavar="TYPE", help="highway types to keep.")
58
+ flt.add_argument("--exclude", nargs="+", metavar="TYPE", help="highway types to drop.")
59
+
60
+ dd = p.add_argument_group("data-driven styling (colour/size by your own column)")
61
+ dd.add_argument("--color-by", metavar="COLUMN",
62
+ help="colour edges by this column instead of road class.")
63
+ dd.add_argument("--colors", metavar="JSON",
64
+ help='categorical map for --color-by, as JSON, e.g. \'{"a":"#f00"}\'.')
65
+ dd.add_argument("--cmap", help="continuous colormap for a numeric --color-by (e.g. viridis).")
66
+ dd.add_argument("--vmin", type=float, help="lower bound of the numeric colour ramp.")
67
+ dd.add_argument("--vmax", type=float, help="upper bound of the numeric colour ramp.")
68
+ dd.add_argument("--width-by", nargs=2, type=float, metavar=("MIN", "MAX"),
69
+ help="scale line width between MIN and MAX px by the numeric --color-by.")
70
+
71
+ web = p.add_argument_group("web backend (-f web)")
72
+ web.add_argument("--view-3d", action="store_true",
73
+ help="3D view: tilted camera + extruded, ramped bridge decks.")
74
+ web.add_argument("--pitch", type=float, help="starting camera tilt in degrees (0-85).")
75
+ web.add_argument("--bearing", type=float, help="starting camera rotation in degrees.")
76
+ web.add_argument("--no-arrows", action="store_true", help="hide one-way direction arrows.")
77
+ web.add_argument("--no-labels", action="store_true", help="hide street-name labels.")
78
+ web.add_argument("--no-filter", action="store_true", help="hide the road-class filter panel.")
79
+ web.add_argument("--no-basemap-switcher", action="store_true",
80
+ help="hide the in-map base-layer dropdown.")
81
+ web.add_argument("--tiles", action="store_true",
82
+ help="pack the roads as an embedded-PMTiles vector tileset instead of "
83
+ "inline GeoJSON (client-side scale for big networks; needs the "
84
+ "'tiles' extra).")
85
+ web.add_argument("--no-compress", action="store_true",
86
+ help="write the map data as plain JSON instead of gzipped blobs "
87
+ "(bigger files; for very old browsers without DecompressionStream).")
88
+
89
+ src = p.add_argument_group("input")
90
+ src.add_argument("--highway-col", default="highway",
91
+ help="name of the road-class column (default: highway).")
92
+ src.add_argument("--layer", help="layer to read from a multi-layer file (e.g. a GPKG).")
93
+ return p
94
+
95
+
96
+ def main(argv: list[str] | None = None) -> int:
97
+ """Entry point for the ``roadstyle`` console script. Returns a process exit code."""
98
+ args = _build_parser().parse_args(argv)
99
+
100
+ in_path = Path(args.input)
101
+ if not in_path.exists():
102
+ print(f"roadstyle: input file not found: {in_path}", file=sys.stderr)
103
+ return 2
104
+
105
+ out_path = Path(args.output) if args.output else in_path.with_suffix(_EXT[args.format])
106
+
107
+ colors = None
108
+ if args.colors:
109
+ try:
110
+ colors = json.loads(args.colors)
111
+ except json.JSONDecodeError as e:
112
+ print(f"roadstyle: --colors is not valid JSON: {e}", file=sys.stderr)
113
+ return 2
114
+
115
+ # Imported lazily so `roadstyle --version` / `--help` stay fast and don't pull in geopandas.
116
+ from .edges import load_edges
117
+ from .emit import save, save_spec, to_geojson
118
+ from .filters import filter_edges
119
+ from .render import render_edges
120
+
121
+ try:
122
+ edges = load_edges(str(in_path), class_col=args.highway_col, layer=args.layer)
123
+ g = edges.gdf
124
+ col = edges.class_col
125
+
126
+ if args.include or args.exclude:
127
+ g = filter_edges(g, include=args.include, exclude=args.exclude, highway_col=col)
128
+
129
+ # Styling keywords shared by every output path; drop the ones the user didn't set so each
130
+ # function keeps its own defaults.
131
+ style_kw = {
132
+ "palette": args.palette, "highway_col": col,
133
+ "color_by": args.color_by, "colors": colors, "cmap": args.cmap,
134
+ "vmin": args.vmin, "vmax": args.vmax,
135
+ "width_by": tuple(args.width_by) if args.width_by else None,
136
+ "basemap": args.basemap, "tooltip": args.tooltip,
137
+ }
138
+ style_kw = {k: v for k, v in style_kw.items() if v is not None}
139
+
140
+ if args.format == "web":
141
+ web_kw = {"arrows": not args.no_arrows, "labels": not args.no_labels,
142
+ "filter_control": not args.no_filter,
143
+ "basemap_switcher": not args.no_basemap_switcher,
144
+ "compress": not args.no_compress,
145
+ "tiles": args.tiles,
146
+ "view_3d": args.view_3d}
147
+ if args.pitch is not None:
148
+ web_kw["pitch"] = args.pitch
149
+ if args.bearing is not None:
150
+ web_kw["bearing"] = args.bearing
151
+ render_edges(g, backend="web", **style_kw, **web_kw).save(str(out_path))
152
+ elif args.format == "folium":
153
+ render_edges(g, backend="folium", **style_kw).save(str(out_path))
154
+ elif args.format == "rsjs":
155
+ save(g, str(out_path), **style_kw)
156
+ elif args.format == "spec":
157
+ save_spec(g, str(out_path), **style_kw)
158
+ elif args.format == "geojson":
159
+ out_path.write_text(json.dumps(to_geojson(g, **style_kw)), encoding="utf-8")
160
+ except (ValueError, KeyError, FileNotFoundError) as e:
161
+ print(f"roadstyle: {e}", file=sys.stderr)
162
+ return 1
163
+
164
+ print(f"wrote {out_path} ({len(g):,} edges, {args.format})")
165
+ return 0
166
+
167
+
168
+ if __name__ == "__main__":
169
+ raise SystemExit(main())