Proof of Concept — An interactive web application for route planning with GraphHopper, built to favour streets not yet covered by street-level photos. Routes can be calculated with a routing penalty applied to already-covered segments, encouraging the discovery of new streets. Currently uses Panoramax as the coverage data provider, with the goal of supporting multiple imagery sources.
Serve the project with any static file server, e.g. Python:
python -m http.server 8000Then open http://localhost:8000 in your browser.
The application reads the GraphHopper URL from a .env file in the project root.
-
Copy the example file:
cp .env.example .env
-
Set your GraphHopper instance URL:
GRAPHHOPPER_URL=http://127.0.0.1:8989 -
Restart the web server.
If no .env is found the application falls back to the default public instance (https://ghroute.vizsim.de).
Note: The
.envfile is served as a static file and is therefore readable by the client. For production use, embed environment variables at build time.
Photo-coverage avoidance requires a custom GraphHopper fork that exposes the photo_coverage and photo_coverage_only360 encoded values per road segment. The fork is available at atchisson/graphhopper.
Standard profiles (car, foot) also work without the fork but without photo-coverage avoidance.
- Multi-profile routing: Bicycle (
bike_customizable), Car (car_customizable), Walking (foot) — selectable from the routing panel - Photo coverage avoidance: Apply a custom-model penalty to road segments already covered by street-level imagery, encouraging routes through uncovered streets
- Standard photos (
photo_coverage): penalises segments with any photo coverage - 360° photos (
photo_coverage_only360): penalises only segments with equirectangular/360° coverage - Strength slider: continuous exponential scale from weak (multiplier ≈ 0.5) to strong (multiplier ≈ 0.01)
- Standard photos (
- Waypoints: add intermediate stops with drag-and-drop reordering and automatic order optimisation
- Interactive route planning: click on the map or use the address search to set start, end, and waypoints
- Photo coverage overlay: two colour-coded layers driven by the avoidance checkboxes
- Orange (
#f97316) — standard flat photo sequences - Dark orange (
#c2410c) — 360° (equirectangular) sequences - Layers appear automatically when the corresponding avoidance checkbox is ticked
- Orange (
- Elevation profile: interactive canvas-based elevation profile with hover, distance markers, and surface / road-class overlay
- Basemaps: OpenStreetMap raster and ESRI Satellite, with automatic light/dark mode
- Terrain & hillshade: Mapterhorn DEM data (optional toggles)
- Router coverage rectangle: a subtle dashed polygon shows the geographic area supported by the active GraphHopper instance on first load
- Permalink: full state saved in the URL (map position, route points, profile, elevation type)
- GPX export: download the calculated route as a
.gpxfile - Multilingual UI: English, French, German — auto-detected from the browser, switchable at runtime
- MapLibre GL JS (v5.14.0) — map rendering
- GraphHopper Routing API — route calculation via custom fork
- Photon (
photon.komoot.io) — address geocoding
| Source | Purpose |
|---|---|
| ghroute.vizsim.de | GraphHopper routing API |
| OpenStreetMap | Base map raster tiles |
| ESRI World Imagery | Satellite basemap |
| Panoramax | Street-level photo coverage vector tiles |
| Mapterhorn | Terrain and hillshade DEM |
missing_mapillary_gh-routing/
├── index.html # Main HTML
├── main.js # Entry point & map initialisation
├── style.css # Application stylesheet
├── style_light-dark.json # MapLibre style (light/dark raster base)
├── .env.example # GraphHopper URL template
├── js/
│ ├── config/
│ │ └── envConfig.js # Reads .env at runtime
│ ├── i18n/
│ │ ├── i18n.js # Translation engine (dot-notation keys)
│ │ ├── en.json
│ │ ├── fr.json
│ │ └── de.json
│ ├── routing/
│ │ ├── routing.js # API calls, route parsing
│ │ ├── routingUI.js # UI handlers (inputs, buttons, checkboxes)
│ │ ├── routeState.js # Centralised state (profile, points, custom model)
│ │ ├── routeRecalculator.js # Debounced route recalculation
│ │ ├── routeVisualization.js # Route colours & segment rendering
│ │ ├── routeInfoFormatter.js # Distance / time formatting
│ │ ├── colorSchemes.js # Colour maps (surface, road class, coverage)
│ │ ├── customModel.js # GraphHopper custom model builder
│ │ ├── gpxExport.js # GPX export
│ │ ├── waypointOptimizer.js # Nearest-neighbour waypoint ordering
│ │ ├── markers/
│ │ │ ├── markerFactory.js # Start / end / waypoint markers
│ │ │ └── waypointContextMenu.js
│ │ ├── waypoints/
│ │ │ ├── waypointList.js # Drag-and-drop waypoint list UI
│ │ │ └── waypointManager.js # Waypoint CRUD
│ │ ├── coordinates/
│ │ │ └── coordinateTooltips.js
│ │ └── heightgraph/ # Elevation profile (canvas-based)
│ │ ├── heightgraph.js
│ │ ├── heightgraphCanvas.js
│ │ ├── heightgraphConfig.js
│ │ ├── heightgraphDrawing.js
│ │ ├── heightgraphInteractivity.js
│ │ ├── heightgraphStats.js
│ │ └── heightgraphUtils.js
│ ├── mapdata/
│ │ ├── sources.js # Map source definitions (OSM, satellite, coverage tiles, terrain…)
│ │ └── basicLayers.js # Layer setup (basemaps, hillshade, coverage sequences)
│ ├── ui/
│ │ ├── setupBaseLayerControls.js # Basemap switcher + post-style-change layer restore
│ │ ├── panelPositioning.js # Fires panelPositioningComplete for heightgraph redraws
│ │ ├── toggleHandlers.js # Terrain, hillshade, dark-mode toggles
│ │ ├── contextMenu.js # Right-click map menu
│ │ ├── contextMenuBase.js
│ │ ├── mapThemeSwitcher.js
│ │ └── mapThemeInitializer.js
│ └── utils/
│ ├── permalink.js # URL state (read/write)
│ ├── geocoder.js # Photon geocoding
│ └── constants.js # App-wide constants & layer IDs
└── thumbs/ # Basemap thumbnail images
When a checkbox is ticked, two things happen simultaneously:
-
Map layer: the corresponding coverage vector tile layer becomes visible on the map, showing which streets are already photographed (orange = standard, dark orange = 360°).
-
Routing penalty: a
custom_model.priorityrule is injected into the GraphHopper POST request:{ "if": "photo_coverage", "multiply_by": 0.07 } // standard coverage { "if": "photo_coverage_only360", "multiply_by": 0.035 } // 360° onlyThe multiplier is controlled by the strength slider using an exponential curve:
multiplier = base × 0.02^(strength/100)— so the middle position (50) gives approximately a 7× penalty, and the maximum gives roughly 50×.
Both rules can be combined. When both are active, segments with 360° coverage are doubly penalised (both rules apply).
Live demo: https://vizsim.github.io/missing_mapillary_gh-routing/
License will be added soon.
- MapLibre GL JS — open-source map rendering
- GraphHopper — routing engine
- OpenStreetMap — community map data
- Panoramax — open street-level photo platform (panoramax.xyz)
- Mapterhorn — terrain and hillshade DEM (mapterhorn.com)
- Photon / Komoot — geocoding API