Build a route elevation profile with Node

Sample your route's waypoints with the Elevation API and draw a distance-based SVG profile that preserves unknown heights.

Working example

ParseAPI
Node

Set up ParseAPI

Install the SDK in your application and set PARSEAPI_KEY in your server environment.

Terminal
npm install @parseapi/sdk@1.14.0
export PARSEAPI_KEY='YOUR_SECRET_API_KEY'

Create a client and reuse it for the calls below:

JavaScript
import { parseAPI } from '@parseapi/sdk';

const parse = parseAPI();
Get an API key ↗

Show the ground beneath a route

Add an elevation profile to a route your application already has. The Node app lets someone choose a set of sample waypoints, then draws their ground elevation with a distance axis and the lowest and highest known samples.

The server keeps the secret key and uses the official SDK. The browser receives the profile it needs to draw the chart.

1. Supply the waypoints

Keep each vertex as a [latitude, longitude] pair. The app's ROUTES object contains two short waypoint sketches near Denver:

JavaScript
export const ROUTES = {
  denver: { name: 'Denver sample', path: [[39.7392, -104.9903], [39.7508, -104.9766], [39.77, -104.9]] },
  foothills: { name: 'Foothills sample', path: [[39.7328, -105.2385], [39.739, -105.224], [39.747, -105.214]] }
};

Use your application's route data in this object.

These coordinates describe the path to sample. The API connects each adjacent pair by its shortest great-circle arc. It does not find a road or trail between them. Include the turns your actual route needs.

2. Sample the path with one SDK call

Choose 64 evenly spaced samples for this chart:

JavaScript
const profile = await parse.elevation.path(route.path, 64, { signal });

profile.points contains the ordered samples, including both endpoints. Each point has latitude, longitude, elevation in meters, and resolution in meters when known. elevation: null means the height is unavailable. Zero and negative heights are valid.

The API accepts 2-512 vertices and 2-512 samples. Locations in a batch must be within 25 km of one another, and the request must fit the terrain coverage limit described in the Elevation reference. Keep a longer route in appropriately bounded sections rather than presenting a failed request as an empty profile.

3. Put each sample on the chart

Because the API spaces samples uniformly along the supplied path, each sample's index gives its horizontal position. Its elevation gives the vertical position. The app calculates the path's great-circle distance from the original waypoints for the axis.

Start a new SVG segment after every unknown elevation:

JavaScript
for (const [index, point] of points.entries()) {
  if (point.elevation === null) { connected = false; continue; }
  const x = 20 + index / (points.length - 1) * 760;
  const y = 220 - (point.elevation - low) / (high - low) * 200;
  path += `${connected ? 'L' : 'M'}${x.toFixed(2)},${y.toFixed(2)} `;
  dots.push({ x, y });
  connected = true;
}

M starts a segment and L continues it. A null sample breaks the line instead of being plotted at zero or joined across. Small circles keep isolated known samples visible. The chart scales to the known minimum and maximum, including a flat profile.

4. Render the selected route

The browser applies the generated path to its SVG:

JavaScript
document.querySelector('#line').setAttribute('d', result.chart.path);

The app also shows the route distance, known elevation range, and number of available samples. An all-null result shows unknown heights and an unavailable message. A failed lookup shows a retry state.

Changing the selected route clears the old profile and cancels its request. Late answers cannot replace the new choice. The server allows only the route IDs your app supplies, with an eight-second SDK timeout and no retries, so a chart request stays bounded.

Heights describe ground terrain. They do not measure bridge decks or buildings, and this sampling is not a precise ascent calculation or a navigation plan.

Complete example

Use the complete implementation as a reference when adding this feature to your app. It includes the validation and failure handling around the calls above.

View and copy the complete source
route-elevation-profile.node.mjs
import { createServer } from 'node:http';
import { pathToFileURL } from 'node:url';
import { parseAPI } from '@parseapi/sdk';

// Your application supplies its own route. These are sample waypoint sketches.
// example:routes
export const ROUTES = {
  denver: { name: 'Denver sample', path: [[39.7392, -104.9903], [39.7508, -104.9766], [39.77, -104.9]] },
  foothills: { name: 'Foothills sample', path: [[39.7328, -105.2385], [39.739, -105.224], [39.747, -105.214]] }
};
// /example:routes
const SAMPLES = 64;

export function routeDistance(path) {
  let distance = 0;
  for (let index = 1; index < path.length; index += 1) {
    const [a, b] = [path[index - 1], path[index]].map(point => point.map(value => value * Math.PI / 180));
    const h = Math.sin((b[0] - a[0]) / 2) ** 2 + Math.cos(a[0]) * Math.cos(b[0]) * Math.sin((b[1] - a[1]) / 2) ** 2;
    distance += 6371.0088 * 2 * Math.asin(Math.sqrt(Math.min(1, h)));
  }
  return distance;
}

export function chartFor(points) {
  const known = points.map(point => point.elevation).filter(value => value !== null);
  if (!known.length) return { path: '', dots: [], minimum: null, maximum: null, available: 0 };
  const minimum = Math.min(...known), maximum = Math.max(...known);
  const padding = Math.max(5, (maximum - minimum) * .1);
  const low = minimum - padding, high = maximum + padding;
  const dots = [];
  let path = '', connected = false;
  // example:chart
  for (const [index, point] of points.entries()) {
    if (point.elevation === null) { connected = false; continue; }
    const x = 20 + index / (points.length - 1) * 760;
    const y = 220 - (point.elevation - low) / (high - low) * 200;
    path += `${connected ? 'L' : 'M'}${x.toFixed(2)},${y.toFixed(2)} `;
    dots.push({ x, y });
    connected = true;
  }
  // /example:chart
  return { path: path.trim(), dots, minimum, maximum, available: known.length };
}

export async function routeProfile(id, parse, signal) {
  if (!Object.hasOwn(ROUTES, id)) throw new Error('unknown_route');
  const route = ROUTES[id];
  // example:request
  const profile = await parse.elevation.path(route.path, 64, { signal });
  // /example:request
  if (!profile || !Array.isArray(profile.points) || profile.points.length !== SAMPLES ||
      profile.points.some(point => !point || !Number.isFinite(point.latitude) || Math.abs(point.latitude) > 90 ||
        !Number.isFinite(point.longitude) || Math.abs(point.longitude) > 180 ||
        (point.elevation !== null && !Number.isFinite(point.elevation)))) throw new Error('unexpected_response');
  return { name: route.name, distance: routeDistance(route.path), points: profile.points, chart: chartFor(profile.points) };
}

export const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Route elevation profile</title><style>
  :root{color-scheme:light dark;--bg:#f5f6f8;--paper:#fff;--ink:#18212b;--muted:#66717f;--line:#dce1e8;--field:#f7f8fa;--accent:#2459cb;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}*{box-sizing:border-box}body{margin:0;padding:54px 20px;background:var(--bg);color:var(--ink)}main{max-width:800px;margin:auto;padding:36px;background:var(--paper);border:1px solid var(--line);border-radius:24px}h1{margin:0 0 10px;font-size:clamp(28px,5vw,38px);line-height:1.15;letter-spacing:-.035em}p{color:var(--muted);font-size:15px;line-height:1.6}label{display:block;margin-bottom:8px;font-size:14px;font-weight:600}.controls{display:flex;gap:10px}select{appearance:none;background:var(--field) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='none' stroke='%2366717f' stroke-width='2'%3E%3Cpath d='m4 6 4 4 4-4'/%3E%3C/svg%3E") no-repeat right 14px center;padding:13px 42px 13px 14px;flex:1;min-width:0;border:1px solid var(--line);border-radius:11px;color:var(--ink);font:inherit}button{min-height:46px;border:0;border-radius:11px;background:var(--accent);color:white;padding:12px 20px;font:inherit;font-weight:600;cursor:pointer}button:disabled{opacity:.55;cursor:wait}:focus-visible{outline:3px solid #80aaff;outline-offset:3px}.status{min-height:24px;margin:18px 0}section{margin-top:28px;border-top:1px solid var(--line);padding-top:26px}h2{margin:0;font-size:20px;letter-spacing:-.02em}.summary{display:flex;flex-wrap:wrap;gap:24px;margin:22px 0}.summary strong{display:block;font-size:24px;font-weight:600;color:var(--ink)}.summary span{font-size:13px;color:var(--muted)}svg{width:100%;height:auto;display:block}.profile-line{fill:none;stroke:var(--accent);stroke-width:3;stroke-linecap:round;stroke-linejoin:round}.profile-dot{fill:var(--accent)}.axis{display:flex;justify-content:space-between;font-size:13px;color:var(--muted)}.note{font-size:13px;margin-bottom:0}[hidden]{display:none!important}@media(max-width:560px){body{padding:18px 12px}main{padding:24px 20px;border-radius:18px}.controls{flex-direction:column}select{flex:auto;width:100%}}@media(prefers-color-scheme:dark){:root{--bg:#0c1016;--paper:#141b24;--ink:#edf1f7;--muted:#a2afbf;--line:#303b49;--field:#1c2531;--accent:#71a0ff}}
</style></head><body><main><h1>See the shape of your route.</h1><p>Compare ground elevation along a set of waypoints.</p>
<form id="route-form"><label for="route">Route</label><div class="controls"><select id="route" name="route"><option value="denver">Denver sample</option><option value="foothills">Foothills sample</option></select><button id="show" type="submit">Show profile</button></div></form>
<p class="status" id="status" role="status" aria-live="polite">Choose a sample to load its profile.</p>
<section id="profile" hidden><h2 id="route-name"></h2><div class="summary"><div><strong id="distance"></strong><span>Waypoint distance</span></div><div><strong id="minimum"></strong><span>Lowest known sample</span></div><div><strong id="maximum"></strong><span>Highest known sample</span></div></div>
<svg id="chart" viewBox="0 0 800 240" role="img" aria-label="Ground elevation along the supplied route"><title>Elevation profile. Gaps mark unknown elevation.</title><path id="line" class="profile-line"></path><g id="dots"></g></svg><div class="axis" id="axis"><span>0 km</span><span id="distance-end"></span></div><p class="note" id="coverage"></p></section>
<p class="note">Sample waypoints are sketches, not directions. Heights describe ground elevation, not bridge or building decks. Missing samples remain gaps.</p></main><script>
  const form = document.querySelector('#route-form'), route = document.querySelector('#route');
  const status = document.querySelector('#status'), profile = document.querySelector('#profile'), show = document.querySelector('#show');
  let request, revision = 0;
  route.addEventListener('change', () => { revision += 1; request?.abort(); profile.hidden = true; show.disabled = false; status.textContent = 'Show the profile for this route.'; });
  form.addEventListener('submit', async event => {
    event.preventDefault(); request?.abort(); const current = ++revision; const controller = new AbortController(); request = controller;
    profile.hidden = true; show.disabled = true; status.textContent = 'Loading the elevation profile.';
    const timer = setTimeout(() => controller.abort(), 10000);
    try {
      const response = await fetch('/profile?route=' + encodeURIComponent(route.value), { signal: controller.signal, cache: 'no-store' });
      const result = await response.json();
      if (current !== revision) return;
      if (controller.signal.aborted) throw new Error('request_aborted');
      if (!response.ok) throw new Error('request_failed');
      document.querySelector('#route-name').textContent = result.name;
      document.querySelector('#distance').textContent = result.distance.toFixed(2) + ' km';
      document.querySelector('#distance-end').textContent = result.distance.toFixed(2) + ' km';
      document.querySelector('#minimum').textContent = result.chart.minimum === null ? 'Unknown' : result.chart.minimum + ' m';
      document.querySelector('#maximum').textContent = result.chart.maximum === null ? 'Unknown' : result.chart.maximum + ' m';
      // example:render
      document.querySelector('#line').setAttribute('d', result.chart.path);
      // /example:render
      const dots = document.querySelector('#dots'); dots.replaceChildren();
      for (const point of result.chart.dots) { const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); dot.setAttribute('cx', point.x); dot.setAttribute('cy', point.y); dot.setAttribute('r', '2'); dot.setAttribute('class', 'profile-dot'); dots.append(dot); }
      document.querySelector('#chart').style.display = result.chart.available ? '' : 'none'; document.querySelector('#axis').hidden = !result.chart.available;
      document.querySelector('#coverage').textContent = result.chart.available + ' of ' + result.points.length + ' samples have a known height. Unknown heights are not interpolated.';
      profile.hidden = false; status.textContent = result.chart.available ? 'Profile ready.' : 'No elevation samples are available for this route.';
    } catch { if (current === revision) status.textContent = 'The profile could not load. Try again.'; }
    finally { clearTimeout(timer); if (current === revision) show.disabled = false; }
  });
</script></body></html>`;

export function createAppServer(parse) {
  return createServer(async (request, response) => {
    response.setHeader('Cache-Control', 'no-store');
    if (request.method !== 'GET') { response.writeHead(405, { Allow: 'GET' }); response.end(); return; }
    const url = new URL(request.url, 'http://127.0.0.1');
    if (url.pathname === '/') { response.setHeader('Content-Type', 'text/html; charset=utf-8'); response.end(PAGE); return; }
    if (url.pathname !== '/profile') { response.writeHead(404); response.end(); return; }
    response.setHeader('Content-Type', 'application/json');
    const id = url.searchParams.get('route') || '';
    if (!Object.hasOwn(ROUTES, id)) { response.writeHead(400); response.end(JSON.stringify({ error: 'Choose a listed route.' })); return; }
    const controller = new AbortController();
    response.once('close', () => { if (!response.writableEnded) controller.abort(); });
    try { response.end(JSON.stringify(await routeProfile(id, parse, controller.signal))); }
    catch { if (!response.destroyed) { response.writeHead(502); response.end(JSON.stringify({ error: 'The elevation lookup could not complete.' })); } }
  });
}

function main() {
  const port = Number(process.env.PORT || 8000);
  if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('PORT must be between 1 and 65535.');
  const key = process.env.PARSEAPI_KEY;
  if (!key) throw new Error('Set PARSEAPI_KEY to your secret server key.');
  const parse = parseAPI(key, { timeoutMs: 8000, retries: 0 });
  createAppServer(parse).listen(port, '127.0.0.1', () => console.log('Open http://127.0.0.1:' + port));
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
  try { main(); } catch (error) { console.error(error.message); process.exitCode = 1; }
}
Try the complete example on your computer

After completing the setup above, save the source in your application folder and start the app:

Terminal
node route-elevation-profile.node.mjs

Open 127.0.0.1:8000 to use the form. The server keeps your secret key out of the page.

For every field and parameter, see Elevation API reference.