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 +124 -0
- roadstyle/_settings.py +182 -0
- roadstyle/basemaps.py +113 -0
- roadstyle/cli.py +169 -0
- roadstyle/colors.py +117 -0
- roadstyle/config.py +99 -0
- roadstyle/controls.py +126 -0
- roadstyle/data/defaults.json +629 -0
- roadstyle/edges.py +295 -0
- roadstyle/emit.py +247 -0
- roadstyle/filters.py +54 -0
- roadstyle/interactive.py +186 -0
- roadstyle/legend.py +110 -0
- roadstyle/overlays.py +82 -0
- roadstyle/palettes.py +172 -0
- roadstyle/py.typed +0 -0
- roadstyle/render.py +141 -0
- roadstyle/render_folium.py +116 -0
- roadstyle/render_lonboard.py +115 -0
- roadstyle/render_web.py +1277 -0
- roadstyle/snapshot.py +102 -0
- roadstyle/static/roadstyle.css +147 -0
- roadstyle/static/roadstyle.js +608 -0
- roadstyle/static/web_template.html +594 -0
- roadstyle/style.py +96 -0
- roadstyle/stylers.py +472 -0
- roadstyle/tiles.py +200 -0
- roadstyle/validate.py +62 -0
- roadstyle/vendor/maplibre-gl.css +1 -0
- roadstyle/vendor/maplibre-gl.js +59 -0
- roadstyle/vendor/pmtiles.js +1738 -0
- roadstyle-0.2.0.dist-info/METADATA +406 -0
- roadstyle-0.2.0.dist-info/RECORD +37 -0
- roadstyle-0.2.0.dist-info/WHEEL +5 -0
- roadstyle-0.2.0.dist-info/entry_points.txt +2 -0
- roadstyle-0.2.0.dist-info/licenses/LICENSE +21 -0
- roadstyle-0.2.0.dist-info/top_level.txt +1 -0
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())
|