LuxMap

Dynamic map aggregating Luxembourg open data — transport, EV charging, addresses.

[open data] [luxembourg] [experimental]
Last updated: 2026-03-31

Abstract

LuxMap is a modular mapping tool that overlays multiple open datasets from data.public.lu onto an interactive Leaflet map of Luxembourg. Each data source is implemented as an independent JavaScript module that registers itself with a central layer manager. The tool is designed for extensibility: new datasets can be added without modifying the core application. It is aimed at anyone exploring Luxembourg's public infrastructure data in a spatial context.

Data Sources

LayerSourceFormatLicenseVolume
EV Charging Bornes de chargement DATEX II XML Open (data.public.lu) ~300 sites
Transport GTFS Luxembourg GTFS (ZIP/CSV) Open (data.public.lu) 2,797 stops
Addresses BD-Adresses GeoJSON (WGS84) Open (data.public.lu) 178,981 points

Data freshness

Known limitations

Methodology

1. Layer manager architecture

The core of LuxMap is a layer manager singleton defined in index.html. It maintains a registry of layers, each with a name, a lazy-loading function, and a Leaflet LayerGroup. When a user toggles a layer checkbox, the manager calls the module's load() function on first activation, then adds or removes the layer group from the map. Data is cached after first load — toggling off does not discard loaded data (except for the addresses module which clears and re-renders on each toggle for performance reasons).

// Module registration pattern
export function register(layerManager) {
  layerManager.register('my-layer', {
    name: 'My Layer',
    sourceUrl: 'https://...',
    async load(layerGroup, map) {
      // fetch, parse, add markers to layerGroup
      return count;
    }
  });
}

2. EV charging: DATEX II XML parsing

The EV charging module fetches XML from the data.public.lu endpoint (which redirects to the Eco-Movement API) and parses it using the browser's native DOMParser. DATEX II v3 uses five XML namespaces (d2, egi, fac, loc, locx); a custom namespace resolver enables XPath queries to extract site names, coordinates, operators, and charging point specifications. Each site is rendered as a circleMarker colour-coded by maximum power: green (<22 kW), orange (22–49 kW), red (50+ kW DC fast charging).

// Namespace resolver for DATEX II v3
const NS = {
  d2:   'http://datex2.eu/schema/3/common',
  egi:  'http://datex2.eu/schema/3/energyInfrastructure',
  fac:  'http://datex2.eu/schema/3/facilities',
  loc:  'http://datex2.eu/schema/3/locationReferencing',
  locx: 'http://datex2.eu/schema/3/extension/locx',
};

3. Transport: GTFS pre-processing

The GTFS feed is a ZIP archive containing multiple CSV files. For display purposes, only stops.txt is needed. The module implements a two-tier loading strategy: it first tries to load a pre-processed data/stops.json file (2,797 stops, ~300 KB). If that file is unavailable, it falls back to fetching the full GTFS ZIP, extracting stops.txt using JSZip (loaded dynamically from CDN), and parsing the CSV client-side. The pre-processing approach was chosen over live ZIP extraction because the GTFS archive is ~4 MB and stop locations change infrequently.

// Pre-processed stop format (stops.json)
{
  "id": "000200403005",
  "name": "Belair, Sacré-Coeur",
  "lat": 49.610276,
  "lon": 6.113159,
  "type": "Stop"
}

4. Addresses: zoom-level rendering

The BD-Adresses dataset contains 178,981 georeferenced points — far too many to render simultaneously. The addresses module implements a viewport-aware rendering strategy: it loads all features into memory on first activation, but only creates Leaflet markers for points within the current map bounds and only when the zoom level is 14 or higher. On each moveend event, the visible markers are cleared and re-rendered. This keeps the DOM manageable while maintaining access to the full dataset.

function renderVisible() {
  layerRef.clearLayers();
  if (mapRef.getZoom() < 14) return;
  const bounds = mapRef.getBounds();
  for (const f of allFeatures) {
    const [lon, lat] = f.geometry.coordinates;
    if (!bounds.contains([lat, lon])) continue;
    // create and add marker
  }
}

5. Decision: ES modules over bundling

Each data layer is a separate ES module file loaded via dynamic import(). This was chosen over a bundler (webpack, vite) to maintain the zero-build-tool philosophy and to allow adding new modules by simply dropping a JS file into modules/ and adding one line to the import list. The trade-off is multiple HTTP requests on first load, but each module is small (<5 KB) and loaded lazily.

6. Marker differentiation

Each layer uses a distinct colour and size scheme to remain visually distinguishable when multiple layers are active simultaneously. EV charging uses red/orange/green (by power), transport uses blue (darker for stations, lighter for stops), and addresses use purple. All markers are circleMarker instances for consistent rendering at all zoom levels.

Stack & Tooling

Key Observations & Limitations

What works

Known gaps

Possible future directions