This is the final tutorial in our series on building printable route directions. The full example is available on GitHub:
👉 Printable route directions example
In the previous tutorials, we covered each piece separately: requesting routes and directions, generating route overview images, creating step preview maps, and adding elevation profiles. Now let’s see how all these pieces come together in a complete working demo.
When we first built the printable directions demo, we wanted to answer a common question: how do you create route directions that users can actually print and take with them? Navigation apps are great when you have a phone, but delivery drivers, hikers, and event organizers often need paper directions they can hand out or use offline. This demo shows one way to solve that problem.
Try the live demo:
APIs and Libraries used:
- Routing API – calculate routes with elevation data
- Static Maps API – generate route preview images
- Map Tiles – Leaflet base map
- Leaflet – interactive map
- Chart.js – elevation profile
- Turf.js – geometry calculations for step previews
What you will learn:
- How to structure a complete routing application
- Combining interactive and static map elements
- Building a print-ready layout
- Connecting all the pieces from the series
Table of Contents
- Demo Overview
- Application Structure
- Waypoint Management
- Route Calculation
- Static Route Preview
- Turn-by-Turn Directions
- Elevation Profile
- Explore the Demo
- Summary
- FAQ
Demo Overview
The complete demo lets users plan a route by clicking on a map, then generates everything needed for printable directions:
- Interactive map for adding waypoints
- Static route preview showing the full route
- Turn-by-turn directions with step preview images
- Elevation profile chart
- Print button for generating a clean printout
Application Structure
The demo maintains state for waypoints, markers, the route layer, and the chart instance:
let waypoints = [];
let markers = [];
let routeLayer = null;
let chartInstance = null;
The Leaflet map is initialized with Geoapify tiles:
const map = L.map("map", {zoomControl: false}).setView([48.8566, 2.3522], 6);
L.control.zoom({position: "bottomright"}).addTo(map);
L.tileLayer(`https://maps.geoapify.com/v1/tile/osm-bright/{z}/{x}/{y}@2x.png?apiKey=${apiKey}`, {
attribution: '© <a href="https://www.geoapify.com/">Geoapify</a> © OpenMapTiles © OpenStreetMap',
maxZoom: 20
}).addTo(map);
When users click on the map, a waypoint is added:
map.on("click", async (e) => {
addWaypoint(e.latlng.lat, e.latlng.lng);
});
Waypoint Management
The addWaypoint function stores the coordinates and triggers a route calculation when there are at least two points:
function addWaypoint(lat, lon) {
waypoints.push({lat, lon});
updateWaypointList();
updateMarkers();
if (waypoints.length >= 2) {
calculateRoute();
}
}
Markers use the Geoapify Icon API with different colors for start, end, and intermediate points:
function updateMarkers() {
markers.forEach(m => m.remove());
markers = [];
waypoints.forEach((wp, i) => {
const color = i === 0 ? "%2343A047" : i === waypoints.length - 1 ? "%23E91E63" : "%232196F3";
const icon = i === 0 ? "circle" : i === waypoints.length - 1 ? "flag" : "circle";
markers.push(L.marker([wp.lat, wp.lon], {
icon: L.icon({
iconUrl: `https://api.geoapify.com/v2/icon?type=awesome&color=${color}&icon=${icon}&size=48&scaleFactor=2&apiKey=${apiKey}`,
iconSize: [36, 48],
iconAnchor: [18, 48]
})
}).addTo(map));
});
if (waypoints.length > 1) {
map.fitBounds(waypoints.map(wp => [wp.lat, wp.lon]), {padding: [50, 50]});
}
}
💡 Visual feedback
Green for start, red for destination, and blue for intermediate stops. This color coding helps users understand the route at a glance.
Different marker colors help users quickly identify start, end, and intermediate waypoints.
Route Calculation
The route request includes details=elevation to get elevation data along with the route:
async function calculateRoute() {
const waypointsParam = waypoints.map(wp => `${wp.lat},${wp.lon}`).join("|");
const url = `https://api.geoapify.com/v1/routing?waypoints=${waypointsParam}&mode=drive&details=elevation&apiKey=${apiKey}`;
try {
const response = await fetch(url);
const data = await response.json();
if (!data.features || data.features.length === 0) {
alert("No route found");
return;
}
const route = data.features[0];
drawRoute(route);
generateStaticPreview(route);
displayRouteSummary(route);
generateDirections(route);
drawElevation(route);
document.getElementById("results").classList.remove("hidden");
} catch (error) {
console.error("Error calculating route:", error);
alert("Failed to calculate route");
}
}
Once we have the route, we call five functions to update different parts of the UI. This separation keeps each piece focused and testable.
💡 Modular design
Each function handles one responsibility. If you only need the static preview, you can use
generateStaticPreviewwithout the elevation chart. This modularity came from our own experience: different projects needed different combinations of these features.
The route is drawn on the interactive map with Leaflet’s GeoJSON layer:
function drawRoute(geojson) {
if (routeLayer) map.removeLayer(routeLayer);
routeLayer = L.geoJSON(geojson, {
style: {color: "#3498db", weight: 5, opacity: 0.8}
}).addTo(map);
}
Static Route Preview
The static preview uses the same technique from Part 2 of the series. We send a POST request with the route GeoJSON:
async function generateStaticPreview(geojson) {
geojson.properties.linecolor = "#3498db";
geojson.properties.linewidth = "5";
const response = await fetch(`https://maps.geoapify.com/v1/staticmap?apiKey=${apiKey}`, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
style: "osm-bright",
width: 800,
height: 250,
scaleFactor: 2,
geojson: geojson,
markers: geojson.properties.waypoints.map(wp => ({
lat: wp.location[1],
lon: wp.location[0],
color: "#ff0000",
size: "medium"
}))
})
});
const blob = await response.blob();
document.getElementById("route-preview").src = URL.createObjectURL(blob);
}
The API automatically calculates the bounding box to fit the entire route. The resulting image is displayed directly in the page and will be included when the user prints.
ℹ️ Why use URL.createObjectURL?
Converting the blob to an object URL lets us display the image without saving it to a server. This keeps everything client-side and avoids extra infrastructure. The URL is valid until the page is closed or you call
URL.revokeObjectURL().
The static preview gives users a printable overview of their route.
Turn-by-Turn Directions
Each step in the route gets a direction card with text and a preview image. The generateDirections function builds the HTML:
function generateDirections(geojson) {
let html = "";
geojson.properties.legs.forEach((leg, legIndex) => {
leg.steps.forEach((step, i) => {
const dist = step.distance >= 1000
? `${(step.distance / 1000).toFixed(1)} km`
: `${Math.round(step.distance)} m`;
const previewUrl = generateStepPreviewUrl(legIndex, step, geojson.geometry.coordinates);
html += `
<div class="direction-step">
<div class="step-number">${i + 1}</div>
<div class="step-content">
<div class="step-instruction">${step.instruction.text}</div>
<div class="step-meta">${dist}</div>
</div>
<div class="step-preview-container loading" data-url="${previewUrl}" data-step="${i + 1}"></div>
</div>
`;
});
});
document.getElementById("directions").innerHTML = html;
// Load images with animation
document.querySelectorAll('.step-preview-container').forEach(container => {
const img = new Image();
img.onload = () => {
container.classList.remove('loading');
img.className = 'step-preview';
container.innerHTML = '';
container.appendChild(img);
};
img.alt = `Step ${container.dataset.step}`;
img.src = container.dataset.url;
});
}
The step preview URLs are generated using the technique from Part 3. Each preview shows the past route (gray), next route (pink), and the maneuver itself (white with black border):
function generateStepPreviewUrl(legIndex, step, coordinates) {
const coords = coordinates[legIndex];
const turnCoordinate = coords[step.from_index];
const markerCoordinates = `${turnCoordinate[0]},${turnCoordinate[1]}`;
// Check if it's a start or finish step
const isStart = ["StartAt", "StartAtRight", "StartAtLeft"].includes(step.instruction.type);
const isFinish = ["DestinationReached", "DestinationReachedRight", "DestinationReachedLeft"].includes(step.instruction.type);
// Extract coordinate segments with direction arrow
const past = getRelatedCoordinates(coords, step, 'past');
const next = getRelatedCoordinates(coords, step, 'next');
const maneuver = getRelatedCoordinates(coords, step, 'manoeuvre');
const maneuverArrow = getRelatedCoordinates(coords, step, 'manoeuvre-arrow');
// Build geometries array
const geometries = [];
if (!isStart) {
geometries.push(`polyline:${past};linewidth:5;linecolor:${encodeURIComponent('#ad9aad')}`);
}
if (!isFinish) {
geometries.push(`polyline:${next};linewidth:5;linecolor:${encodeURIComponent('#eb44ea')}`);
}
if (!isFinish) {
geometries.push(`polyline:${maneuver};linewidth:7;linecolor:${encodeURIComponent('#333333')};lineopacity:1`);
geometries.push(`polyline:${maneuver};linewidth:5;linecolor:${encodeURIComponent('#ffffff')};lineopacity:1`);
geometries.push(`polygon:${maneuverArrow};linewidth:1;linecolor:${encodeURIComponent('#333333')};lineopacity:1;fillcolor:${encodeURIComponent('#ffffff')};fillopacity:1`);
}
// Calculate bearing
const bearing = getBearing(coords, step) + 180;
// Add finish marker if needed
const icon = isFinish ? `&marker=lonlat:${markerCoordinates};type:material;color:%23539de4;icon:flag-checkered;icontype:awesome;whitecircle:no` : '';
return `https://maps.geoapify.com/v1/staticmap?style=osm-bright&width=250&height=150&apiKey=${apiKey}&geometry=${geometries.join('|')}¢er=lonlat:${markerCoordinates}&zoom=16&scaleFactor=2&bearing=${bearing}&pitch=45${icon}`;
}
ℹ️ Loading states
The demo shows a loading spinner while step preview images load. This provides feedback to users, especially on slower connections where generating multiple static map images takes time.
Each direction step includes a preview image showing the turn context with color-coded route segments.
Elevation Profile
The elevation chart uses Chart.js with the data from the routing response:
function drawElevation(geojson) {
if (chartInstance) chartInstance.destroy();
const elevation = geojson.properties.legs[0].elevation_range;
if (!elevation || elevation.length === 0) {
document.getElementById("elevation-section").classList.add("hidden");
return;
}
document.getElementById("elevation-section").classList.remove("hidden");
chartInstance = new Chart(document.getElementById("elevation-chart"), {
type: "line",
data: {
labels: elevation.map(p => p[0]),
datasets: [{
data: elevation.map(p => p[1]),
borderColor: "#3498db",
backgroundColor: "rgba(52, 152, 219, 0.2)",
fill: true,
tension: 0.4,
pointRadius: 0
}]
},
options: {
animation: false,
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
type: 'linear',
title: {display: true, text: "Distance (m)"}
},
y: {
type: 'linear',
beginAtZero: true,
title: {display: true, text: "Elevation (m)"}
}
},
plugins: {
legend: {display: false},
tooltip: {
displayColors: false,
callbacks: {
title: (tooltipItems) => `Distance: ${tooltipItems[0].label}m`,
label: (tooltipItem) => `Elevation: ${tooltipItem.raw}m`
}
}
}
}
});
}
💡 Handling missing elevation data
Not all routes have elevation data (depends on the travel mode). The demo checks if
elevation_rangeexists and hides the elevation section if it’s not available.
The elevation profile helps users understand the terrain along the route.
Explore the Demo
The live CodePen demo lets you:
- Click on the map to add waypoints
- See the route calculated automatically
- View the static preview, directions, and elevation chart
- Click “Print Directions” to generate a print-ready page
Experiment with the code
- Change the
modeparameter fromdrivetowalkorbicycle - Adjust the static map dimensions for different layouts
- Modify the step preview colors
- Try different map styles (
dark-matter,positron)
Print styles
The demo includes CSS for print output:
@media print {
body {
padding: 0;
background: white;
}
.map-controls,
.btn-print {
display: none !important;
}
.result-section {
page-break-inside: avoid;
}
}
This hides the interactive elements and keeps the printable content clean.
💡 Print testing tip
Use your browser’s print preview (Ctrl/Cmd + P) to test the layout without wasting paper. Chrome’s DevTools also has a “Rendering” panel where you can emulate print media to see how styles apply.
Summary
This demo brings together everything from the series:
- Routing API for route calculation and turn-by-turn instructions (Part 1)
- Static Maps API for the route overview image (Part 2)
- Step preview images with bearing and pitch (Part 3)
- Elevation profile with Chart.js (Part 4)
- Print-ready layout with CSS media queries
The key insight is that each piece works independently. You can use just the static preview, or just the elevation chart, or combine them as shown here. The modular approach makes it easy to adapt for your specific needs.
We found this structure useful because different clients had different requirements: some wanted just the route overview for confirmation emails, others needed full printable directions for field workers. By keeping the pieces separate, we could mix and match without rewriting code.
Series links:
- Part 1: Requesting routes and turn-by-turn directions
- Part 2: Generating route overview images
- Part 3: Creating step preview maps
- Part 4: Adding elevation profiles
Useful links:
FAQ
Q: Can I use this code in production?
A: Yes. The code is open source. Replace the demo API key with your own from myprojects.geoapify.com.
Q: How do I deploy this demo?
A: The demo is pure HTML, CSS, and JavaScript. Host it on any static server: GitHub Pages, Netlify, Vercel, or your own server.
Q: Does it work offline?
A: Route calculation requires internet connectivity. However, once generated, you can save or print the static images and directions for offline viewing.
Q: Can I add more than 2 waypoints?
A: Yes. The demo supports multiple waypoints. The Routing API calculates multi-leg routes automatically.
Q: How do I customize the print output?
A: Edit the CSS @media print rules. You can adjust fonts, hide sections, or change the layout for different paper sizes.
Q: Can I use this with React, Vue, or Angular?
A: Yes. The core functions (generateStaticPreview, generateDirections, drawElevation) are framework-agnostic. Extract them and integrate into your component structure.
Try It Now
Please sign up at geoapify.com and generate your own API key to start building printable route directions.




