Customizing the look
Every drawn state — normal, hover, selected, preview and (for faces) invalid — comes from a fixed
set of style tables in @vectorstream/adapter (darstellung.ts): FLAECHE, KANTE, GRIFF,
MITTELPUNKT, PUNKT, HILFSLINIE, STEUERGRIFF, plus the label tables BESCHRIFTUNG_FARBE and
BESCHRIFTUNG_GROESSE, and the single-object BESCHRIFTUNG_SCHRIFT/BESCHRIFTUNG_KASTEN. Until
this feature, all three map adapters imported these constants directly — there was no published
way to change a color or a line width for an embedding application's own corporate design.
The darstellung option, present on LeafletAdapterOptions, OpenLayersAdapterOptions and
MapLibreAdapterOptions, fixes that: a partial override, merged with the built-in defaults
per field, set once when the adapter is built.
The override: per field, not per whole table#
An override names only what actually changes. Every field it leaves out — on a state it doesn't mention, or on a field of a state it does — keeps the built-in default:
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { baueDarstellung, type DarstellungUeberschreibung } from '@vectorstream/adapter';
import { LeafletAdapter } from '@vectorstream/leaflet';
/**
* A corporate-design override, the whole feature in one example (Lücke 3,
* "Darstellung/Stil-Schnittstelle von außen"): only the fields that actually change from the
* built-in defaults — a brand color for faces and edges in their resting state. Everything left
* out (line width, opacity, every other state, every other table) keeps its default;
* `LeafletAdapter`/`OpenLayersAdapter`/`MapLibreAdapter` all merge this into the full set of style
* tables once, at construction, through the same `baueDarstellung` — never again afterward.
*/
export function markenDarstellung(): DarstellungUeberschreibung {
return {
flaechen: { normal: { farbe: '#7a1fa2', fuellung: '#7a1fa2' } },
kanten: { normal: { farbe: '#7a1fa2' } },
};
}
/** A Leaflet map whose faces and edges are drawn in the brand color above; every other state (hover, selected, preview, invalid) keeps its built-in appearance. */
export function starteMarkenKarte(container: HTMLElement): { map: L.Map; adapter: LeafletAdapter } {
const map = L.map(container).setView([51.5112, 7.4555], 17);
const adapter = new LeafletAdapter(map, { darstellung: markenDarstellung() });
return { map, adapter };
}
/**
* An unknown key — here a misspelled state — is rejected right at construction, in the current
* locale, instead of being silently ignored (then the map would just show the default, and
* nobody would know why). The message carries both the wrong path and the valid alternatives.
*/
export function pruefeTippfehler(): string {
try {
baueDarstellung({ flaechen: { normall: { farbe: '#7a1fa2' } } } as unknown as DarstellungUeberschreibung);
return 'no error';
} catch (fehler) {
return fehler instanceof Error ? fehler.message : 'unknown error';
}
}
In this example, flaechen.normal.breite (the outline width), flaechen.hover/selected/
preview/invalid, and every table besides flaechen/kanten are entirely untouched — the map
still shows the built-in yellow hover, orange selection and so on, just with the brand color where
nothing else was said.
What can be overridden#
One group per style table, each named after the table it overrides:
DarstellungUeberschreibung field |
Default table | Keyed by |
|---|---|---|
flaechen |
FLAECHE |
FaceState (normal, hover, selected, preview, invalid) |
kanten |
KANTE |
EdgeState (normal, hover, selected, preview) |
griffe |
GRIFF |
HandleState (normal, hover, selected, active) — node handles |
mittelpunkte |
MITTELPUNKT |
HandleState — edge-midpoint handles |
steuergriffe |
STEUERGRIFF |
move/radius/other — plain-circle control handles |
punkte |
PUNKT |
HandleState | 'preview' — free-standing points drawn as a circle |
hilfslinien |
HILFSLINIE |
standard/auswahl/zeichnung/rasthilfe — guide-line roles |
beschriftungFarbe |
BESCHRIFTUNG_FARBE |
label state → font color |
beschriftungGroesse |
BESCHRIFTUNG_GROESSE |
size level → pixels |
beschriftungSchrift |
BESCHRIFTUNG_SCHRIFT |
one object: familie, zeilenhoehe |
beschriftungKasten |
BESCHRIFTUNG_KASTEN |
one object: hintergrund, polsterX, polsterY, eckenradius |
invalid is already covered today (flaechen.invalid) — the only state that exists on any
table right now. A planned Geofencing task adds further invalid states to EdgeState and
ScenePoint; each override group's type (TeilUeberschreibung<typeof FLAECHE> and so on) is
derived straight from its table, so the moment KANTE/PUNKT gain that key, kanten.invalid/
punkte.invalid become overridable automatically — no change to this feature needed. Guide
lines are the one exception to that automatic story: SceneGuide has no state field at all —
hilfslinien's keys are its four fixed roles (standard, auswahl, zeichnung, rasthilfe),
not a state. An "invalid" guide line would need a new role or a new field on SceneGuide first
— a design decision Geofencing hasn't made yet — and a matching change to hilfslinienStil itself,
not merely a table key filling in on its own.
Not covered, by design: the icon-based symbols — the four snap markers, the rotate/scale
control handles' own icon, the pin-shaped point's fixed color. These are already deliberately
distinguished by shape, not color alone (so a color-blind user can still tell a corner snap
from a midpoint snap); branding them would undercut that. Everything drawn as a plain circle or
line is overridable; everything drawn as a fixed symbol is not. Concretely, this is where the
exception is visible: overriding griffe.hover still leaves the rotate/scale handles' own hover
color yellow (STEUER_FARBE), overriding hilfslinien.rasthilfe still leaves the four snap
markers magenta (SNAP_SVG), and overriding punkte.normal still leaves a pin-shaped point blue
(PIN_FARBE) — only the circle shape of a point follows punkte. The label plate's drop shadow,
its glow blur, and a dashed guide line's own dash pattern (HILFSLINIE_GESTRICHELT, distinct from
SceneGuide.style) are fixed the same way, for the same reason: they're a small amount of
hard-coded polish, not a themable color or line width.
A circle handle's hit area grows with an enlarged radius. Overriding griffe.normal.radius
(or mittelpunkte/steuergriffe) to something larger than the built-in default also enlarges
how far from its center a click still grabs it — otherwise a visually bigger handle would promise
more than it can actually be clicked on. It never shrinks the hit area below the ordinary pixel
tolerance, even for a radius overridden smaller than the default. The icon-based handles (rotate,
scale) are unaffected, consistent with them not being themable at all.
Precedence: an explicit override always wins over a layer color#
An application can color faces and edges two ways at once: the already-existing ebenenFarbe
adapter option (see Layers) tints every normal-state element that belongs to a
layer, and darstellung.flaechen.normal/darstellung.kanten.normal sets a fixed color
regardless of layer. When both are in play for the same field, the explicit darstellung
override wins — a layer tint never paints back over a color the application named itself. This
applies field by field: naming only farbe in the override still lets the layer color tint
fuellung, and vice versa. Without this rule, an application using both options at once would see
its brand color silently disappear on every layered element, with no error to explain why — the
same "nothing happened" a typo check elsewhere in this feature exists to prevent, just reached by
a different route.
One merge function, shared by all three adapters#
mitUeberschreibung (@vectorstream/adapter) is the single implementation of "partial, per
field": given a default table and an override, a key the override doesn't mention keeps the
default's value by reference, and a key it does mention replaces only the fields it names, via a
copy that never touches the original. Neither the default table nor the override object is ever
mutated — that, not the sharing of references as such, is what lets two adapters each hold a
different override of the same table at the same time without influencing one another, or the
built-in default either (checked in darstellung.test.ts). baueDarstellung calls it once per
table and bundles the result into a Darstellung; LeafletAdapter, OpenLayersAdapter and
MapLibreAdapter each do exactly
this.darstellung = baueDarstellung(options.darstellung);
in their constructor, and nowhere else — there is no method to change it later (see "When it applies" below).
Dash patterns: overridable on every state the type allows#
strich (the dash pattern) is a field of StrichStil, so it can be named on any state of
flaechen/kanten/griffe/mittelpunkte/steuergriffe/punkte — not only the states whose
built-in default happens to already have one (today, only preview). A dashed kanten.normal
— an ordinary corporate-design choice — works even though KANTE.normal itself has no dash
pattern to start from:
baueDarstellung({ kanten: { normal: { strich: [4, 2] } } });
hilfslinien is the one exception, and still correctly rejected: its style type
(Omit<StrichStil, 'strich'>) never had a dash pattern to begin with — a guide line's dashing
always comes solely from SceneGuide.style, never from its own table (see HILFSLINIE's own doc
comment) — so naming strich there is an unknown field, not an omission to fix.
To force a solid line on a state whose default is dashed, override strich with an empty
array (strich: []), not undefined — an explicit undefined counts as "not provided" and
leaves the default's dash pattern (or lack of one) exactly as it was (see "Nothing is silently
blanked out" below). This is checked on all three renderers and the canvas export: an empty
strich draws solid everywhere, never an invisible line — MapLibre's own line layer once needed a
dedicated fix for this (linienWerte, fix round 2), since its dash expression only falls back to
the solid pattern when the property is entirely absent, and a present-but-empty array isn't.
Validation: a typo fails loudly#
An override that names a group, a state/role/level within a group, or a field that doesn't exist
on the corresponding style type throws right at baueDarstellung — i.e. at adapter construction —
instead of being silently ignored. The same applies to a value that should be a plain object (the
override itself, a group, or a state) but isn't — a string, null, an array:
import { baueDarstellung, type DarstellungUeberschreibung } from '@vectorstream/adapter';
// Cast needed only to get the typo past the compiler for this example — without the cast,
// TypeScript already rejects `normall` as not a property of `DarstellungUeberschreibung['flaechen']`,
// which is exactly the point: most typos never reach `baueDarstellung` at runtime at all.
baueDarstellung({ flaechen: { normall: { farbe: '#7a1fa2' } } } as unknown as DarstellungUeberschreibung);
// throws a DarstellungFehler, code 'adapter.darstellungUnbekannterSchluessel':
// "Unknown key 'flaechen.normall' in the display override — valid: normal, hover, selected, preview, invalid."
The message is in English or German depending on the current locale (setLocale, see
Language), and the error carries the usual code/params (see
Validity and rejections) for an application that wants to react to it
programmatically rather than show the text as is.
Nothing is silently blanked out#
A field named with the value undefined — easy to get from { farbe: theme.primary } when
theme.primary happens to be absent — counts as "not provided" and never erases the default; a
naive merge would otherwise set that field to undefined, since object spread copies every key
regardless of its value. The same rule applies one level up: a state/role/level named with the
value undefined is likewise left untouched. And an override's own array field (a strich
pattern) is copied when it's merged in, not kept by reference — mutating the array afterward can't
reach back into the already-built Darstellung.
Values are not otherwise checked#
Validation only checks the override's shape — which groups, states and fields exist — never
the content of a value. beschriftungSchrift.familie and beschriftungKasten.hintergrund
reach cssText unchecked, the same way a font family or a background color would in any
hand-written stylesheet; farbe/fuellung fields reach canvas and renderer color APIs the same
way. This is safe for a value the application itself chose (its own brand color, its own font
stack) but not a place to pass through untrusted input — unlike SceneLabel.farbe, which
comes from scene data (potentially an imported GeoJSON file) and is therefore strictly checked
against #rgb/#rrggbb before use (see istGueltigeFarbe), a darstellung override is written
by the application's own code, once, at construction, and is trusted the same way ebenenFarbe or
any other adapter option already is.
When it applies#
The override is read once, in the constructor — not a live setting. There is no
setDarstellung() and no way to change it after the adapter is built (user decision 12.10.2026):
rebuilding the adapter is the way to switch the appearance, the same way ebenenFarbe already
works. A future dark-mode toggle, if ever added, would do so by tearing down and rebuilding the
adapter with a new darstellung option — not by mutating a live one.
Image export honors it too#
adapter.exportImage() (see Exporting the map view as an image) draws from the
same resolved Darstellung the map itself uses — zeichneFlaechenAufCanvas,
zeichneKantenAufCanvas, zeichnePunkteAufCanvas and zeichneBeschriftungenAufCanvas (the
functions bildexport.ts exports) all take the adapter's own this.darstellung as their last
argument. A corporate-design override therefore shows up in the exported PNG exactly as it shows
up on screen, on every renderer — there is no second place to apply it.