The style in a config file¶
Status: agreed 2026-10-19 (the three open points as proposed). Step 1 implemented 2026-10-19 on the local branch
theme (unreleased): data/themes/default.yaml, the loader in _settings.py, render_edges(theme=...), tests/test_theme.py.
Step 2, parts a and b, implemented (2026-10-19, local branch theme): the page sets state values (MapLibre global-state) and builds
no style expression; part c too (the arrow icon and rules and the Tunnels box lists come from the profile); and part d, the palette
view row: Python builds the style once more for each palette that a view names and merges the builds (_merge_palettes): a value that
differs becomes a choice by the state rs_palette, a road's baked colors get a copy per palette (__rs_fill@mono). The check: 24 screenshots
(open, tunnel slider, tunnel palette, Colour by, rsColor, road fill off, a view, an overlay colour; simple, full and tiled pages) are the
same as before. The next section (palette, profile, view) is agreed (2026-10-19, its three open points as proposed) and implemented,
except the palette row of views (it needs the page work of step 2): data/profiles/{default,highsat}.yaml,
data/palettes/{amber,carto,highsat,mono}.yaml, render_edges(profile=..., palette=...). theme= is gone. Steps 2-4 are not
started. It comes before version 2 of duckOSM roads in GeoLibre and RustGIS.
Palette, profile and view (agreed and implemented 2026-10-19)¶
Step 1 put every style value in one theme file. That file mixes two kinds of value: colors, and everything else. A person who wants other colors must not touch the widths and the zoom rules, and the reverse. So the theme splits in two:
| Part | Holds | Picked by | Switched in the page? |
|---|---|---|---|
| Palette | only colors | palette="amber" (a name) |
yes: a view can name it |
| Profile | everything that is not a color: widths by zoom, zoom ranges, sizes, dashes, caps, spacing, the tunnel look | profile="default" (a name) |
no: one profile per page |
| View | a name and the page settings that it changes (core_model_and_views.md) | views={...} |
it is the switch |
A page is one profile, one palette at the start, and its views.
What goes where¶
From the sections of step 1:
| Palette (colors) | Profile (the rest) |
|---|---|
per class: fill, casing |
per class: group, order, minzoom, no_casing, single_line, minor, dash |
| the fallback fill and casing; the palette casing | widths by zoom, zoom rate, casing ratio, links, the two-way split, metre widths; folium's width, casing_width (they leave the palettes) |
| label color and halo color; arrow color | label font, glyphs, sizes, minzooms, fraction, min_px, angle, padding; arrow icon, sizes, minzooms, main_below, spacing |
| bridge casing color, shadow color, deck underlay | bridge casing widths, wing scale, ramp, shadow (on, blur, offset, trim, width scale), decks (heights, ramp, flat_below) |
tunnel colors: toward, towards, the casing palettes, to, the fill dash color |
the tunnel look: strength, casing dash, fill dash, dash extra px, cap, folium dash, opacity scale |
| selection hover and select colors | selection opacity, folium's widths |
| data maps: no-data color, color table fallback | data maps: width, opacities |
| overlay colors; boundary color; background | overlay sizes; boundary width and dash |
The tunnel strength is in the profile: it decides how far a tunnel fades, which is its look, not its color
(2026-10-19: a sample that changed it changed the tunnel's style, not only its color).
Implemented (2026-10-19)¶
- highsat's widths and dashes (no casing on minor classes, other dashes and folium widths, one more class) were in its
palette. They are the bundled profile
highsatnow (decided 2026-10-19).palette="highsat"alone uses the default profile;profile="highsat", palette="highsat"gives the old highsat map exactly (checked: the same page and folium looks). - The proof for the other palettes: the ten reference pages and the folium looks are the same as before the split.
- A trap found on the way: a test reloaded the
roadstyle.palettesmodule, which left twoPALETTESdicts in the process (the renderers kept the first). The test now uses the in-place rebuild thatuse_settingsruns.
Files and names¶
data/palettes/<name>.yaml: the bundled palettesamber(the default),carto,highsat,mono. Each holds every color of the table above. Today only the class colors differ between them; the other colors are the same in all four.data/profiles/<name>.yaml: the bundled profiledefault, the widths and rules of today.- Own palettes and profiles:
./palettes/<name>.yamland./profiles/<name>.yamlin the project, or~/.config/roadstyle/palettes/and.../profiles/. They are picked by name. Each states only what it changes and merges over the bundledamberordefault. - Keywords:
render_edges(palette="carto", profile="default"), names only.theme=of step 1 goes (it was never released).
Views stay as core_model_and_views.md says¶
That note decides: a view sets only what it names, the rest stays as it is, and a view is a list of calls the page
already has. color in a view is a color_options entry (which data decides the fill), not a palette. So:
paletteis a new view row, and a cheap one. For each palette that a view names, Python bakes one more fill and casing column per road, exactly as it does for each color option (__rs_fill__1, ...). The page switches with the code ofrsSetColorField. The other palette colors (labels, arrows, bridges, tunnels, selection) are a few paint values per palette, which Python gives the page. The page only applies them; it computes no color.profileis not a view row. Widths and zoom rules are fixed in the style. A second profile in one page needs a second style and a swap of the whole style (map.setStyle), which is not a call the page has. It can be a later row, if a page needs it.
rs.render_edges(roads, profile="default", palette="amber", views={
"Day": {"palette": "amber", "basemap": "positron"},
"Night": {"palette": "mono", "basemap": "dark_matter"},
"Traffic": {"color": "Flow", "road_fill": True}, # as today: a color option
})
Libraries (mapstyle, lanestyle)¶
overlay_styles.md says that a library keeps its looks in its own theme and that roadstyle ships no
overlay style. With the split, a library ships a profile (its overlay styles: widths, dashes, zooms) and a palette (their
colors). An overlay style names its color by a key of the palette (color: lane), so that a palette switch reaches the
items too.
Open points of this proposal¶
- Opacities (fill, casing, label, arrow): proposal, in the palette, because an opacity is a part of how a color looks.
- The class
dash(footway dashes): proposal, in the profile, because it is a pattern, not a color. - Overlay style colors by palette key (above): proposal, yes; the libraries change with step 3.
Problem¶
The look of a roadstyle map is in three places:
- A config file:
data/defaults.jsonholds the palettes (fill, casing, widths per class), the width model per zoom (roads), the class draw order (z_order), the class minzoom, and some label, arrow, bridge, tunnel and selection values. - Python code:
render_web.pywrites many style values as literals. An inventory (2026-10-19) found more than - Examples: the link width factor
0.6, the hover and select colors#b388ff/#7c4dff, the fallback colors#888888/#000000, the minor class list_MINOR, the arrow spacing and icon sizes, the label minzoom 14 and arrow minzoom 15, the label font, the bridge shadow scale1.6and wing scale1.25, the boundary look (#6a0dad, 2.5 px,[3, 2]). - Browser JavaScript: the page sets the look again at load (
rsSetTunnelStyle,_applyFill,_simpleColor,_simpleDash, the first view), and it holds style values of its own: the arrow icon (SVG path, 96 x 40 px), the arrow rules (_RS_ARROW: 100 px, 150 px, zoom 17), the tunnel dash list. mapstyle'slayers.jsstyles all its feature layers in JavaScript at load (color by kind, outlines, dashes, textures, icons, zoom ranges).
This causes three faults:
- Lost looks. A path that does not run the page's JavaScript loses those looks: the style export, RustGIS, any other app (style_export.md).
- Values that disagree. The same value has two definitions that differ:
- the link width is
0.6in the web renderer andlink_scale0.7 in the config; - the hover and select colors are
#b388ff/#7c4dffon the web and#EE00FF/#D000FFinselection; - the minor classes are three different lists (
_MINOR,minor_no_casing,single_line_classes); - config defaults are written again as literals (labels, arrows, overlays, bridge decks, tiles).
- One rule in two languages. The tunnel look is in Python (
_tun_mix,_tun_hex,_simple_color) and again in JavaScript (_tunMix,_tunHex,_simpleColor).
The rule¶
- The style is a config file. Every color, width, dash, opacity, zoom range, class list, font, icon and size is a value in the file.
- Python reads the file and builds the MapLibre style. Python keeps the rules: the casing and fill layers, the drawing positions, the slots for names and arrows, the bridge decks. Python holds no style value.
- JavaScript does only interaction: click, hover, popups, toggles, filters, the camera, arrow placement. When a person changes the look (a slider, the Colour by menu), JavaScript changes a state value. The style that Python made reads that value. JavaScript builds no style expression.
The theme file¶
One file holds one look: a theme. The name is the one that mapstyle already uses for its YAML looks (overlay_styles.md).
- Format: YAML, because a style file needs comments. JSON is also accepted (the same keys).
- Bundled:
src/roadstyle/data/themes/default.yaml, the look of today's maps, value for value. - Pick a theme:
render_edges(edges, theme="my.yaml")(a path, a bundled name or a dict). A theme states only what it changes, and it merges overdefault.yaml, key by key, as the settings levels do now. - Palettes stay: a palette is a named color set inside the theme (
palettes:), andpalette="amber"picks one.
Sections¶
| Section | Holds | Today in |
|---|---|---|
palettes |
per palette and class: fill, casing, dash, opacity; widths for folium and lonboard | defaults.json |
roads |
per class: group, minzoom, draw order, minor, single line; per group: widths by zoom, zoom rate, casing ratio; links (classes, width factor); fallback fill and casing; caps and joins; the two-way split (offset_frac, width_frac, offset_zoom, the ramp) |
defaults.json and render_web.py |
casing |
opacity, extra, the minor classes without casing, metre casing (casing_m, casing_min_px) |
both |
labels |
color, halo, font, glyphs URL, size by zoom, minzoom, minor minzoom, fraction of the road width, minimum px, max angle, padding | both |
arrows |
color, opacity, icon (SVG path, size, pixel ratio), minzoom, minor minzoom, main classes, size by zoom, the page's placement values (main below zoom 17, 100 px, 150 px), the export's thinning (slot step per zoom) | render_web.py and page JS |
bridges |
casing color, px and m, shadow (color, blur, offset, trim, width scale, zoom ramp), wing scale, decks (base, thickness, ramp, step, opacity, width scale, flat below) | both |
tunnels |
palettes, palette, strength, toward colors, casing dash, fill dash, dash extra px, the slider steps | both and page JS |
selection |
hover and select colors and opacity (web), glow (folium) | both, with different values |
overlays |
default look, and named overlay styles | both |
boundary |
color, width, opacity, dash | render_web.py |
background |
the canvas color under a light or a dark base map | render_web.py |
The settings files keep what is not a look: API keys, the camera, the base map choice, tiles, slot_m.
Example¶
# default.yaml (part)
roads:
fallback: {fill: "#888888", casing: "#000000"}
links:
classes: [motorway_link, trunk_link, primary_link, secondary_link, tertiary_link]
width: 0.6 # of the main class's width
classes:
motorway: {group: major, minzoom: 4, order: 9}
footway: {group: path, minzoom: 15, order: 1, minor: true, single_line: true}
labels:
color: "#5b5b5b"
font: [Noto Sans Regular]
glyphs: https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf
minzoom: 14
minor_minzoom: 16
arrows:
icon: {path: "M 0,2 7,2 7,0 12,2.5 7,5 7,3 0,3 z", width: 96, height: 40, pixel_ratio: 4}
minzoom: 15
place: {main_below: 17, min_len_px: 100, min_gap_px: 150}
selection: {hover: "#b388ff", select: "#7c4dff", opacity: 0.85}
The page: state values, not style in JavaScript¶
- No look change at load. Python bakes the final paint, so the page stops calling
rsSetTunnelStyle(),_applyFill(),_simpleColorand_simpleDashat load. Python applies the first view when it builds the page. - Sliders and menus set state. MapLibre 5.24 (vendored) has the
["global-state", name]expression andmap.setGlobalStateProperty. Python writes the tunnel strength, the tunnel palette colors, the dash ratio and the active color option asglobal-statein the expressions. The tunnel slider then callssetGlobalStateProperty("tunnel_strength", 70), and MapLibre draws again._tunMix,_tunHex,_simpleColor,_simpleDashand_fillExprgo. The tunnel look is then one rule, in Python. rsColor(colors per edge from a host page) sets a state value that holds the groups of ids and colors, which a Python-made expression reads.- Values from the theme. The arrow icon, the arrow placement values, the slider steps and the dash ratio list come from the theme (injected as JSON). The page has no style literal.
- The export bakes the state. For the style export, Python writes the default state values in place of
global-state, because RustGIS cannot readglobal-stateand other apps have no slider.
Step 3: mapstyle and lanestyle (plan, 2026-10-19)¶
Status: plan, waiting for an OK. Its decisions are asked one by one in the conversation, not here.
What the two libraries do today¶
mapstyle combines three choices into one roadstyle palette and one settings= dict (map.mode_settings):
| Choice | Files | Holds |
|---|---|---|
| theme | osm_carto.yaml, themes/google.yaml, themes/grey.yaml |
road and feature colors; grey and google also a color rule: every inherited color moved by saturation and lighten |
| mode | modes.yaml: all, driving, walking, cycling |
widths per group, the classes that stand out (em), lanes (one line or two), fade (the roads for cars move toward white) |
| path style | paths.yaml: komoot, osm, cyclosm, google |
path colors (a palette part) and path widths, casing ratio, minor_no_casing (a profile part); the amount of fade |
Its page script layers.js styles every feature layer at load: color by kind, outline by kind, dashes, textures
(fill-pattern by kind, images made with PIL in patterns.py, darker by the factor 0.6), icons (SVG files, a symbol layer per point
layer, size by zoom, rotated by bearing), zoom ranges, and the colors of the roads you may not use (private, bus, pedestrian, delivery:
four colors written in the script). It also does interaction: rsSetModes, rsSetAccess, rsSetKinds, rsSetInteraction, the boxes,
and the arrow rules scaled for a tilted map (with 100 and 150 written as numbers).
lanestyle keeps its look in data/lanestyle.json (colors, widths, dashes, zooms, trims mixed). The editor (editor.py) writes
more of it in code: the label color, halo and sizes, roadstyle's amber palette with every fill set to the car lane color, and two
debug colors from environment variables. arrows.py writes the arrow color (white), items.py the item order.
What roadstyle adds first (for every library)¶
- A library's folders.
rs.add_style_dirs(path): a library names its folder withprofiles/andpalettes/, at import. The order: the project (./profiles/), the user (~/.config/roadstyle/), the libraries, then roadstyle's own files. - A stack of names.
profile=andpalette=also take a list of names, merged in order, each over the one before:profile=["walking", "komoot"],palette=["osm_carto", "komoot", "grey"]. mapstyle's choices then stay files that combine at render time, not 4 x 4 x 3 files. - Color rules in a palette:
transform: {saturation, lighten}moves every color that the palette inherits and does not set (the themesgreyandgoogle);fade: {classes, toward, amount}moves the colors of those classes (the roads for cars in the walking and cycling modes, a small palette that mapstyle adds to the stack for those modes). - More overlay style fields (the overlay styles live in a profile, their colors in a palette by key, as agreed):
color_byandoutline_by({property, colors: {value: color or palette key}, default}),pattern_by({property, images: {value: image name}}),icon({image, size: [[zoom, size], ...], opacity, rotate_by}). Python makes the layers thatlayers.jsadds now: the pattern fill layer, an icon layer per point layer, the unseen click circle under an icon. - Images: a profile names its image files (
images: {bus: icons/bus.svg}, paths beside the profile file);render_edges(images={name: path or bytes})takes images made in code (mapstyle's textures, in the palette's colors). The page loads them before it shows the layers (it applies given data; it draws nothing itself). - The roads you may not use: a palette gives their colors (
roads.access: {private, bus, pedestrian, delivery}); Python bakes each road's access color from itsaccesscolumn, and the fill expression takes it between rsColor and the color option, aslayers.jsdoes now. The rows in the Roads box stay mapstyle's UI, with the colors from the palette.
mapstyle¶
- Files:
palettes/osm_carto.yaml,google.yaml,grey.yaml(the themes; theirtransform);palettes/<path style>.yaml(komoot, osm, cyclosm, google: the path colors) andpalettes/<path style>_fade.yaml(the fade of the roads for cars);profiles/<mode>.yaml(all, driving, walking, cycling) andprofiles/<path style>.yaml(the path widths); the feature layers' overlay styles inprofiles/features.yaml, their colors in the palettes.layers.yaml(which layers, their order) stays. mode_settingsreturns the two stacks: profile[mode, path style, "features"], palette[theme, path style](plus<path style>_fadein the walking and cycling modes).layers.jskeeps only interaction. The arrow scaling reads the profile's values from_RS_ARROWand scales them.- The textures:
patterns.pystays code (it draws the shapes); the colors come from the palette, the darker factor from the profile.
lanestyle¶
lanestyle.jsonsplits intoprofiles/lanestyle.yaml(widths, dashes, gaps, zooms, trims, arrows) andpalettes/lanestyle.yaml(the lane colors, the line color, the tunnel body color).- The editor's look becomes
profiles/lanestyle_editor.yamlandpalettes/lanestyle_editor.yaml(labels; the car lane fill). - The arrow color goes to the palette, the item order to the profile.
The checks¶
As in steps 1 and 2: the pages of each library are saved before the work (mapstyle: each mode with one path style and each theme; lanestyle: the lane page and the editor page) and compared after it, page by page and by screenshots; the tests of the three projects.
Order¶
- roadstyle: points 1 to 6 above, with tests (branch
theme). - mapstyle, on a branch of its own. 3. lanestyle, on a branch of its own.
Compatibility¶
palette=,settings=and the settings levels keep working. The style keys of a settings file (palettes,roads,selection, and the look keys ofconfig) are read as theme overrides, by a fixed table of old key to new key, for one release. A warning names the new key. Then the old keys go.- The
style=keyword ofrender_edges(data-driven colors) keeps its name and meaning.
Steps¶
- The theme file and its loader.
default.yamlwith the values ofdefaults.jsonand the literals of the inventory. The renderers read every value from it. The disagreeing values get one value each (open point 1). Test: the style dict of every page in the test suite and of Södermalm is the same before and after, except the values that open point 1 changes. Python holds no style literal (a test searchesrender_web.pyfor hex colors). - The page. No look change at load;
global-statefor the sliders andrsColor; theme values for the icon and the placement values. Test: the browser tests, and the tunnel slider and Colour by menu by hand. - mapstyle (its theme with
features:,layers.jswithout styling), then lanestyle. - Version 2 of the export continues on top (style_export.md). The export reads the same theme, so the apps get the same look.
Step 1: what was done and what is left (2026-10-19)¶
- The proof. Ten pages (Södermalm in simple and full mode, tiles, 3D with overlays and color options, four palettes, data
colors) and the folium looks of every class were saved before the work and compared after it. The pages are the same, except
three intended changes: the link width of folium and lonboard (0.7 to 0.6, also in the per-edge
__rs_w/__rs_cwthatroadstyle.jsreads), folium's selection colors, and the order of the classes in the minor class list (the same seven). - The minor class lists stay three. Open point 1 proposed one list with the members of
_MINOR. The three lists have three jobs and different members (no_casing: fill only;single_line: a two-way pair as one line;minor: arrows and names later). One list would make service roads single lines and give living streets a casing. So they are three flags per class inroads.classes, with the members of today: the map does not change. Merging them is a decision still open. - YAML 1.1: the key
onreads astrue. The bridge shadow switch isbridges.shadow.show. - Left in Python on purpose (rules, not looks): geometry constants of the drawing rules:
_SEAM_M,_LAP_M,_MITRE_MAX,_ITEM_JOIN_M, the deck half-width floor, the shadow turn limit,_NAME_MARGIN_M,_ZEBRA_HALF_M,_ARROW_MIN_M, the zebra classes,head_m(a parameter of the levels).rgba(0,0,0,0)(invisible) is not a color choice. - Not in this step: the base map list in
basemaps.py(the switcher's thumbnails and canvas colors: base map data, a file of its own later), the page JavaScript (step 2: the folium hover color ininteractive.py, the arrow icon, the tunnel look at load), mapstyle and lanestyle (step 3).
Open points (agreed 2026-10-19)¶
- The values that disagree. Proposal: keep what the web map shows now: link width
0.6, hover#b388ff, select#7c4dff, and one minor class list with the members of_MINOR. Folium and lonboard then follow the web values. - The name of the keyword. Proposal:
theme=, the word mapstyle uses.style=is taken. - The UI colors of the controls, legend and popups (CSS in the template and in
legend.py,controls.py). They are a look too, but not the map's. Proposal: aui:section in step 2, not in step 1.