Frontend Contributor Guide¶
This guide explains how the Svelte 5 frontend is structured, how data flows through it, and how to make common types of changes. It assumes you have local dev set up.
Architecture overview¶
main.js
└── StandaloneApp.svelte or HubApp.svelte
├── Map.svelte (OpenLayers map, all layers)
├── PlaygroundPanel.svelte (detail panel for selected playground)
├── FilterPanel.svelte (filter dropdown)
├── SearchBar.svelte (Nominatim search)
└── ...
The app mounts either StandaloneApp or HubApp based on appMode from lib/config.js. The two modes share most components; the hub adds federation components in src/hub/.
State management¶
The app uses Svelte writable stores (src/stores/). Components import stores directly — there is no top-down prop drilling for shared state.
| Store | Shape | Who writes | Who reads |
|---|---|---|---|
selection |
{ feature: OlFeature\|null, backendUrl: string } |
Map.svelte (click), AppShell (deeplink restore) | PlaygroundPanel, NearbyPlaygrounds |
filterStore |
{ private: bool, water: bool, baby: bool, … } |
FilterPanel | Map.svelte (polygon visibility), api.js (cluster params) |
activeTierStore |
null \| 'cluster' \| 'polygon' \| 'macro' |
tieredOrchestrator | Map.svelte (layer visibility) |
overlayFeaturesStore |
{ equipment: [], trees: [] } |
PlaygroundPanel | Map.svelte (equipment/tree layers) |
playgroundSourceStore |
OL VectorSource | null | Map.svelte | NearbyPlaygrounds, AppShell |
mapStore |
OL Map | null | Map.svelte | LocateButton, other map-interacting components |
location |
{ lat, lon, accuracy } \| null |
LocateButton (manual + auto-locate) | Map.svelte (location marker), PlaygroundPanel (navigation origin) |
regionFramingApplied |
null \| true \| false (region-URL framing outcome; false → unresolved, map left at default extent) |
StandaloneApp | LocateButton (auto-locate centering decision) |
hubLoadingStore |
{ loaded, total, settling } |
hubOrchestrator | InstancePanel |
macroFilteredStore |
Map<backendUrl, {count, complete, partial, missing}> \| null |
hubOrchestrator (macro tier, filter active) | MacroView |
macroCoverageStore |
{ answered, total, cantFilter[], settling } \| null |
hubOrchestrator (macro tier, filter active) | MacroView, MacroCoverageBanner |
Selection flow¶
User clicks playground polygon
│
▼
Map.svelte click handler
→ selection.select(feature, backendUrl)
→ writes URL hash (#W<osm_id>)
│
▼
PlaygroundPanel subscribes to selection
→ fetches equipment + trees + POIs + reviews
→ writes overlayFeaturesStore
│
▼
Map.svelte subscribes to overlayFeaturesStore
→ updates equipment and tree OL layers
Filter flow¶
User toggles filter in FilterPanel
│
▼
filterStore updated
│
├──► Map.svelte: polygon layer re-renders
│ (matchesFilters() hides non-matching polygons)
│
└──► tieredOrchestrator.rerun()
→ re-fetches cluster tier with active filters as query params
Each zoom tier applies filters through a different path:
| Tier | Filter path |
|---|---|
| polygon | client-side — matchesFilters() hides non-matching polygons in Map.svelte |
| cluster | server-side — active filters become filter_* query params on get_playground_clusters |
| macro (hub) | derived — see below |
Macro tier (hub) filter path¶
The macro tier is normally zero-fetch: MacroView.svelte renders one ring per backend straight from the cached get_meta totals, and panning at min zoom dispatches no requests. get_meta has no filter dimension, so when a filter is active those totals would ignore it.
When a filter is active, hubOrchestrator instead fans out the filter-aware get_playground_clusters RPC once per backend (scoped to each backend's own bbox), sums the returned buckets into a per-backend {count, complete, partial, missing}, and publishes it progressively on macroFilteredStore. MacroView overrides each ring's props from that store as entries settle; a backend with no entry yet (or a pre-tier peer that 404s) keeps its cached-meta ring. Clearing all filters sets the store back to null and restores the zero-fetch path.
Alongside the filtered aggregate, hubOrchestrator publishes coverage on macroCoverageStore — { answered, total, cantFilter[], settling } where total is the in-scope healthy backends, answered is those that returned a filtered total, cantFilter lists backends that couldn't apply the filter (no bbox, or a 404 on the cluster RPC), and settling is true while the fan-out is still in flight. Those backends keep their unfiltered ring but are flagged _cantFilter so they render the distinct "unfiltered" variant instead of silently reading as a filtered subset, and MacroCoverageBanner shows "filter covers N of M regions" whenever answered < total once the fan-out has settled (settling === false) — so a normal load doesn't flash "covers 0 of N" mid-fetch, while an all-legacy hub still never silently presents partial coverage as complete (#688).
Macro ring variants (hub/macroRingStyle.js), in macroRingStyleFn priority order: offline (dashed grey) → importing (blue "updating") → cantFilter (full segments + dashed grey halo + "unfiltered" — backend couldn't apply the filter, so its whole catalogue is shown, marked as such) → filtered-empty (grey "no match" — healthy backend, but the filter excludes every playground) → degraded (amber "no data" — backend reachable but empty) → healthy (filled segments + count). Backend-health states outrank the filter outcome; "cant-filter" is a partial-coverage condition that outranks the filter results so it's never mistaken for a filtered ring; "no match" is distinct from the amber "no data" degraded ring.
Runtime configuration¶
lib/config.js reads window.APP_CONFIG (written by oci/app/docker-entrypoint.sh at container startup) and exports named constants. In dev (no container), the constants use hardcoded defaults.
Config constants used across the codebase:
| Constant | Default | Notes |
|---|---|---|
appMode |
'standalone' |
'standalone' or 'hub' |
apiBaseUrl |
'' |
Empty → Overpass fallback |
osmRelationId |
62700 |
Fulda (dev default) |
clusterMaxZoom |
13 |
Zoom threshold for tier switch |
macroMaxZoom |
7 |
Hub macro view threshold |
defaultLocale |
'' |
UI language; empty → browser language → en |
regionLang |
'de' |
Language the served OSM data is named in |
regionCountry |
'de' |
Country for opening_hours holiday resolution |
regionState |
'' |
Optional state refinement; empty → library default |
openingHoursAddress |
derived | { country_code, state? } for the opening_hours constructor — use this, never a literal |
The tiered orchestrator¶
lib/tieredOrchestrator.js is the data-fetching heart of standalone mode. attachTieredOrchestrator() wires to the OL map's moveend event and:
- Determines the active tier from
view.getZoom()vsclusterMaxZoom - Publishes the tier to
activeTierStore - Cancels any in-flight request via
AbortController - Calls the right API function (
fetchPlaygroundClustersorfetchPlaygroundsBbox) - Populates the corresponding OL
VectorSource
The orchestrator is created in StandaloneApp.svelte on mount and torn down on destroy.
OpenLayers layers¶
Map.svelte owns five OL layers beyond the basemap:
| Layer | zIndex | Visible when |
|---|---|---|
playgroundLayer |
10 | $activeTierStore === 'polygon' |
clusterLayer |
12 | $activeTierStore === 'cluster' |
treeLayer |
15 | A playground is selected |
equipmentLayer |
20 | A playground is selected |
pitchLayer |
9 | filterStore.standalonePitches === true |
Layer visibility is driven by reactive $: statements that subscribe to the stores above.
Deeplinks¶
lib/deeplink.js handles URL hash encode/decode. Two formats:
#W<osm_id>— standalone (no slug)#<slug>/W<osm_id>— hub (slug identifies the backend)
selection.select() automatically writes the hash. On page load, AppShell.svelte reads the hash and dispatches fetchPlaygroundByOsmId to hydrate the polygon source before selecting.
Adding a new filter¶
Standard boolean filter (default off)¶
Most filters default to false (inactive). Enabling one restricts the map to playgrounds that have the feature. Example: water playground filter.
1. app/src/stores/filters.js — add the key to defaultFilters and add match logic to matchesFilters():
export const defaultFilters = {
…
myNewFilter: false,
};
// in matchesFilters():
if (filters.myNewFilter && !props.my_flag) return false;
2. app/src/lib/api.js — add the cluster-tier RPC param to clusterFilterMap:
3. importer/api.sql — add a parameter to get_playground_clusters() (default false) and a WHERE clause. Also drop the old function signature and update the GRANT. Run make db-apply to apply.
4. app/src/components/FilterPanel.svelte — add the icon to FILTER_ICONS and a translation key to locales/*.json.
5. app/src/components/FilterChips.svelte — add the key to FILTER_KEYS. Don't skip this. The chip bar is the only way to clear an active filter from outside the panel; a filter missing from FILTER_KEYS can be set but shows no removable chip. FILTER_KEYS must stay in sync with the boolean filters in defaultFilters (excluding the standalonePitches layer toggle and the show* completeness states, which are not chip-rendered).
6. Unit tests — add cases to app/src/stores/filters.test.js.
Visibility filter (default on)¶
Use this pattern when users toggle which categories to show rather than requiring a feature. Example: the completeness filter (showComplete/showPartial/showMissing).
Key differences from the standard pattern:
- Default value is
true(show all); deactivating hides a category. matchesFilters()checks the prop and returnsfalseto hide.hasActiveFilters()detects activity via!filters.myVisibilityKey.activeFilterCount()increments whenfalse, nottrue.clearAll()usesfilterStore.set({ ...defaultFilters })— already resets visibility filters totrueautomatically.- Cluster RPC params default
true; pass'false'only when deactivated:
// api.js — in fetchPlaygroundClusters:
if (filters.showMyCategory === false) params.set('filter_my_category', 'false');
- SQL param defaults
true; add a WHERE clause that ORs across all enabled states:
Internationalisation¶
Translations live in locales/*.json and are loaded by lib/i18n.js using svelte-i18n. In components, use the $t store:
Add new keys to locales/en.json and locales/de.json. Translation to other languages happens via Weblate.
Language attributes¶
Two languages coexist on the page, and mixing them up is a WCAG failure:
- Interface text is in the UI locale.
setupI18n()writes it todocument.documentElement.lang, so translated strings need nolangof their own — they inherit the right one. (index.htmlstill ships a staticlang="de"; it is overwritten during init, before anything renders.) - OSM-derived text — playground names, and anything else read straight
from a tag — is in the region's language, not the interface's. Every
element rendering it carries
lang={regionLang}.
The rule matters most where the two meet. getPlaygroundTitle() returns
either OSM name tags or the translated nearby.defaultName placeholder,
and the returned string cannot tell you which. Ask hasOsmName(attr):
$: titleLang = hasOsmName(attr) ? regionLang : $locale;
…
<h2 lang={titleLang}>{getPlaygroundTitle(attr, $_)}</h2>
Current name render sites: HoverPreview, PlaygroundPanel (both header
variants), NearbyPlaygrounds. If you add another, annotate it — an
unannotated name silently inherits the interface language.
The search combobox¶
SearchBar.svelte implements the ARIA 1.2 combobox pattern over the Nominatim suggestion list. If you touch it, keep these invariants:
- The input owns the list.
role="combobox"+aria-autocomplete="list"+aria-controlspointing at the results container'sid,aria-expandedmirroring list visibility,aria-busymirroring the in-flight request. Ids come from a module-scoped counter (searchbar-<n>-…) — the component is in Svelte legacy mode, so$props.id()is unavailable. - DOM focus never leaves the input. Arrow keys move an
activeIndexexposed viaaria-activedescendant; options are not tab stops (tabindex="-1"). Roving tabindex would break continued typing. The tab order is input → clear button → out of the card. - Options stay real
<button>s. Touch screen readers (TalkBack, VoiceOver) navigate with a virtual cursor that ignoresaria-activedescendantand activates elements in place, so options must be natively activatable.role="option"overrides the button role without conflict. - Dismissal is focus containment, never a timer.
focusouton.search-cardtestscurrentTarget.contains(e.relatedTarget). The old 200 ms hide timer raced both a mouse click in Safari and a touch scroll inside the list on iOS. mousedown, notpointerdown, is prevented on the results container.mousedownis synthesised after a tap completes, so suppressing it cannot break scrolling;pointerdownfires at touch start and would.- One highlight only.
mousemovewritesactiveIndexand only.activeis styled — a separate:hoverrule would let the pointer highlight a different row than the onearia-activedescendantannounces.activeIndexresets on every reassignment ofresults(search, clear, error path), since the 450 ms debounce can swap the result set under a stale index.
Regression coverage: tests/searchbar-a11y.spec.js (stubs Nominatim via page.route). Safari mouse selection, iOS scroll-in-list, and TalkBack navigation are manual-only — Playwright's Chromium does not reproduce those quirks.
Playground themes¶
playground:theme=* (OSM) is meant to describe a whole playground's motto — a ship, castle, or octopus playground. The tag reaches the frontend on both the playground polygon and equipment features via the hstore_to_jsonb(tags) merge in api.sql (it is not an osm2pgsql column, so it is not stripped).
In practice the key is dominated by two kinds of non-theme usage: tagging noise (playground/play are ~53% of all uses) and device-shape values — horse, duck, elephant, … tagged on a single playground=springy rider to describe its shape, not a playground theme. A springy can be shaped as anything, so those values are noise for our purposes. We therefore honour only an allowlist of documented whole-playground themes (SUPPORTED_THEMES in lib/playgroundThemes.js: ship, castle, spiderweb, water, adventure, rocket, dragon, octopus, circus). Everything else is dropped at the single splitThemes choke point, so it never reaches any consumer. Extend the allowlist by adding an entry to THEME_ICONS (the icon map is the allowlist) plus an equipAttr.themes.* label in each locale.
lib/playgroundThemes.js owns the presentation:
themeIcon(value)— curated emoji per allowlisted theme value.FALLBACK_ICON(✨) is a defensive backstop only; it never renders for non-allowlisted values, which are filtered out upstream.themeName(value, t)— localised label fromequipAttr.themes.*, falling back to the raw value.themeOf(props)— the first allowlisted theme on a single feature, used for the inline device symbol.areaThemesOf(props)— allowlisted themes on the playground's own area tag; drives the prominent banner near the title.aggregatePlaygroundThemes(areaProps, deviceProps)— deduped, ordered theme list for a playground (area theme first, then device themes by frequency).PlaygroundPanelrenders this as an icon-only chip row folded onto the Equipment header in the overview;EquipmentList/EquipmentTooltiprender the per-device symbol inline.
Themes are panel-only — no map symbols (discovery is deferred).
Style system¶
The app uses Bootstrap 5 (component classes) and Tailwind CSS 4 (utility classes) side by side. The design system primitives in src/components/ui/ (Badge, Button, Card, etc.) wrap Bootstrap with Tailwind utilities. Prefer these over raw Bootstrap classes in new components.
The mapping-detail palette¶
src/lib/completenessPalette.js is the single source of truth for the three mapping-detail colours (complete / partial / missing). Never hardcode these values anywhere else.
Each bucket exposes base, fill, stroke and hatch. The module documents which surface uses which field; the short version is that anything opaque takes base, and only shapes the basemap shows through — or backgrounds carrying text — take fill. Getting this wrong is silent: nothing errors, two surfaces simply render the same bucket in different colours.
Consumers today: vectorStyles.js (polygons), clusterStyle.js, hub/macroRingStyle.js, CompletenessLegend.svelte, PlaygroundPanel.svelte, FilterPanel.svelte, NearbyPlaygrounds.svelte, hub/InstancePanelDrawer.svelte.
Svelte <style> blocks cannot import, so components that colour elements via CSS classes read the palette in <script> and expose it as custom properties (--detail-complete and friends) on the wrapping element. FilterPanel, NearbyPlaygrounds and InstancePanelDrawer all follow that pattern — copy it rather than reintroducing literals.
Two rendering traps worth knowing:
- OpenLayers'
renderPolygonGeometryhandles fill, stroke and text only. Animagestyle attached to a Polygon is silently dropped — no render, no warning. Give it ageometryfunction returning the polygon's interior point. hub/macroRingStyle.jscoloursrestricted(violet-600) on a different axis from the mapping-detail buckets — access, not detail.deriveMacroRingsetsrestricted: 0whenever a backend reports completeness and routes the whole count into it only when completeness is unknown, sorestrictedandmissingnever share a ring. They do sit side by side as separate rings on the same macro view, which is why the two colours still have to stay apart.
See also¶
- Local Development — dev server setup
- Testing Guide — how to write and run tests
- Add a Device — adding a new playground device type
- Source Tree Analysis — annotated directory map
Basemap layers¶
The basemap is configuration-driven (app/src/lib/config.js), not hardcoded:
basemapStyleUrlset → aVectorTileLayerstyled withol-mapbox-style, which is dynamically imported so raster deployments do not carry its ~310 kB.- otherwise → an XYZ
TileLayerbuilt frombasemapUrl.
Two things are easy to get wrong here, and both were caught in review:
- Attribution belongs to the source, not the layer. OpenLayers resolves it via
layer.getSource().getAttributions(), so passingattributionsas a layer option silently does nothing. On the vector path it must be applied afterapplyStyle()has created the source. - The vector fallback is gated on
basemapUrlIsExplicit. When a style fails to load, falling back tobasemapUrlis only safe if the operator set it; the compiled-in default is a third party that a vector deployment may have chosen vector to avoid.
macroOutlineLayer (zIndex 1) draws a bundled Natural Earth world outline under the hub macro tier, fetched lazily on first use from /basemap/world-110m.json (absolute — a relative path breaks under region URLs). It exists because the basemap tileset covers only the federation's own countries.