Published
Edited
May 29, 2022
12 stars
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
elevation_chart_container = html``
Insert cell
Insert cell
Insert cell
Insert cell
map = {
let map = new maplibregl.Map({
container,
style: styles[initial_type],
center: [-82.53041, 35.585827],
zoom: 11,
maxBounds: [
[-83, 35.3],
[-82, 35.9]
]
});
map.on("load", async function () {
map.addControl(
new maplibregl.GeolocateControl({
positionOptions: {
enableHighAccuracy: true
},
trackUserLocation: true
})
);
if (Object.keys(other_files).indexOf(params.get("gpx")) > -1) {
let pts = get_trkpts(await other_files[params.get("gpx")].text());
add_path(map, pts);
make_elevation_chart(pts, map);
}
});
return map;
}
Insert cell

initial_type = {
if (params.get("type") == "topo") {
return "topo";
} else {
return "streets";
}
}
Insert cell
// The height of the map.
// Should be redefined to "100%" on embed.
height = "720px"
Insert cell
global_markers = ({ start_marker: null, stop_marker: null })
Insert cell
function add_path(map, pts, fit = true) {
// Remove the path, if already there
if (map.getLayer("path")) {
map.removeLayer("path");
}
if (map.getSource("route")) {
map.removeSource("route");
}
// And the start stop markers, too.
if (global_markers.start_marker) {
global_markers.start_marker.remove();
global_markers.start_marker = null;
}
if (global_markers.stop_marker) {
global_markers.stop_marker.remove();
global_markers.stop_marker = null;
}

// Add geoJSON describing the route
map.addSource("route", {
type: "geojson",
data: {
type: "FeatureCollection",
features: [
{
type: "Feature",
geometry: {
type: "LineString",
coordinates: pts
}
}
]
}
});
// Use the geoJSON to add the layer
map.addLayer({
id: "path",
type: "line",
source: "route",
layout: {
"line-join": "round",
"line-cap": "round"
},
paint: {
"line-color": "#00b",
"line-width": 5
}
});

// Add the markers
global_markers.start_marker = new maplibregl.Marker({ color: "#00dd00" })
.setLngLat(pts[0])
.addTo(map);
global_markers.stop_marker = new maplibregl.Marker({ color: "#dd0000" })
.setLngLat(pts.slice(-1)[0])
.addTo(map); // marker.remove();

// Fit the map to the new path. Note that
// fit=false, when called after a change of map_style
if (fit) {
map.setMaxBounds(null);

let lon_range = pts.max_lon - pts.min_lon;
let lat_range = pts.max_lat - pts.min_lat;

let r = 0.2;
let min_lon = pts.min_lon - lon_range * r;
let max_lon = pts.max_lon + lon_range * r;
let min_lat = pts.min_lat - lat_range * r;
let max_lat = pts.max_lat + lat_range * r;
map.fitBounds(
[
[min_lon, min_lat],
[max_lon, max_lat]
],
{ duration: 1600 }
);

// Well, this is odd. As far as I can tell, though,
// map.fitBounds doesn't return a Promise, nor give
// any indication as to when it completes, so we've
// got to do something to wait to for setMaxBounds.
Promises.delay(2000).then(function () {
r = 4;
min_lon = pts.min_lon - lon_range * r;
max_lon = pts.max_lon + lon_range * r;
min_lat = pts.min_lat - lat_range * r;
max_lat = pts.max_lat + lat_range * r;
map.setMaxBounds([
[min_lon, min_lat],
[max_lon, max_lat]
]);
});
}
}
Insert cell
// Parse the GPX file and return a list of [lat,lon] points.
// Each point optionally has a ele(vation) and time keys.
function get_trkpts(file_string) {
let parsed = parser.parseFromString(file_string, "text/xml");
let trkpts = parsed.getElementsByTagName("trkpt");
let pts = [];
for (let trkpt of trkpts) {
let pt = [
parseFloat(trkpt.getAttribute("lon")),
parseFloat(trkpt.getAttribute("lat"))
];
try {
let ele = trkpt.getElementsByTagName("ele")[0].textContent;
pt.ele = 3.28084 * parseFloat(ele);
} catch (e) {
("pass");
}
try {
let time = trkpt.getElementsByTagName("time")[0].textContent;
pt.time = parseTime(time);
} catch (e) {
("pass");
}
pts.push(pt);
}
let lons = pts.map((pt) => pt[0]);
pts.min_lon = d3.min(lons);
pts.max_lon = d3.max(lons);
let lats = pts.map((pt) => pt[1]);
pts.min_lat = d3.min(lats);
pts.max_lat = d3.max(lats);
return pts;
}
Insert cell
// Constructs the elevation chart

function make_elevation_chart(trkpts, map) {
let size = d3.min([window.innerWidth, window.innerHeight]);
let w = size < 600 ? 0.98 * size : 600;
let h = w / 3;

let pad_left = 40;
let pad_bottom = 20;
let svg = d3
.create("svg")
.attr("class", "elevation_chart")
.attr("width", w)
.attr("height", h);
svg
.append("rect")
.attr("width", w)
.attr("height", h)
.attr("fill", "white")
.attr("opacity", 0.5);

let lngLats = trkpts; //.map((pt) => [pt[1], pt[0]]);
let elevation_path = [];
let cummulative_length = 0;
let R = 3;

let distance_to_point_map = new Map();
for (let i = R; i < trkpts.length - R; i++) {
cummulative_length =
cummulative_length + d3.geoDistance(lngLats[i - 1], lngLats[i]) * 3958.8;
let elevation = d3.mean(trkpts.slice(i - R, i + R).map((pt) => pt.ele));
elevation_path.push([cummulative_length, elevation]);
distance_to_point_map.set(cummulative_length, lngLats[i]);
}

let path_length = elevation_path.slice(-1)[0][0];
let elevations = trkpts.map((o) => o.ele);
let min_elevation = d3.min(elevations);
let max_elevation = d3.max(elevations);
let elevation_pad = 500;

let elevation_path2 = [[0, min_elevation - elevation_pad]]
.concat(elevation_path)
.concat([[path_length, min_elevation - elevation_pad]]);

let x_scale = d3.scaleLinear().domain([0, path_length]).range([pad_left, w]);
let y_scale = d3
.scaleLinear()
.domain([min_elevation - elevation_pad, max_elevation + elevation_pad])
.range([h - pad_bottom, 0]);
let pts_to_path = d3
.line()
.x((d) => x_scale(d[0]))
.y((d) => y_scale(d[1]));

svg
.append("path")
.attr("d", pts_to_path(elevation_path2))
.style("stroke", "black")
.style("stroke-width", "0px")
.style("stroke-linejoin", "round")
.style("opacity", 0.7)
.style("fill", "#eee");
svg
.append("path")
.attr("d", pts_to_path(elevation_path))
.style("stroke", "black")
.style("stroke-width", "3px")
.style("stroke-linejoin", "round")
.style("fill", "none");
svg
.append("g")
.attr("transform", `translate(0, ${h - pad_bottom})`)
.call(d3.axisBottom(x_scale));
svg
.append("g")
.attr("transform", `translate(${pad_left})`)
.call(d3.axisLeft(y_scale));
let position_marker = svg
.append("g")
.attr("class", "position_marker")
.style("opacity", 0);
position_marker
.append("line")
.attr("stroke-width", "1px")
.attr("stroke", "black")
.attr("y1", 0)
.attr("y2", h)
.attr("x1", w / 2)
.attr("x2", w / 2);
position_marker
.append("circle")
.attr("r", "5px")
.attr("cx", w / 2)
.attr("cy", h / 2)
.attr("fill", "#0ff")
.attr("stroke", "black");

let lengths = elevation_path.map((pt) => pt[0]);
svg
.on("touchmove", (e) => e.preventDefault()) // prevent scrolling
.on("pointerenter", function () {
position_marker.style("opacity", 1);
map.addSource("point", {
type: "geojson",
data: {
type: "Feature",
geometry: {
type: "Point",
coordinates: trkpts[20]
}
}
});
map.addLayer({
id: "point",
type: "circle",
source: "point",
paint: {
"circle-radius": 6,
"circle-color": "cyan"
// "stroke-color": "black"
}
});
})
.on("pointermove", function (evt) {
evt.preventDefault();
let distance = x_scale.invert(d3.pointer(evt)[0]);
let i = binarySearch(lengths, distance);
let x = x_scale(distance);
let elevation = elevations[i];
let y = y_scale(elevation);
position_marker.select("line").attr("x1", x).attr("x2", x);
position_marker.select("circle").attr("cx", x).attr("cy", y);
// global.gpx_path.getLayers()[1].setLatLng(trkpts[i]);
map.getSource("point").setData({
type: "Feature",
geometry: {
type: "Point",
coordinates: trkpts[i]
}
});
})
.on("pointerleave", function () {
position_marker.style("opacity", 0);
map.removeLayer("point");
map.removeSource("point");
});

svg
.append("text")
.attr("x", 50)
.attr("y", 20)
.text(formatTime(trkpts[0].time));
svg
.append("text")
.attr("x", 50)
.attr("y", 40)
.text(`${d3.format("0.1f")(path_length)} miles`);

d3.select(elevation_chart_container).selectAll(".elevation_chart").remove();
d3.select(elevation_chart_container).append(() => svg.node());
}
Insert cell
// Given an *ordered* array arr and a value t, this finds the index
// of the laregest element of arr that is less than t.
// Used when we hover over the elevation chart to quickly find the
// corresponding point on the path.

function binarySearch(arr, t, bail = 100) {
let cnt = 0;
let m = 0;
let n = arr.length;
let a = arr[0];
let b = arr[n - 1];
if (t <= a) {
return 0;
} else if (t >= b) {
return n - 1;
} else {
let k;
while (n - m > 1 && cnt++ < bail) {
k = Math.floor((m + n) / 2);
let c = arr[k];
if (t <= c) {
b = c;
n = k;
} else {
a = c;
m = k;
}
}
return k;
}
}
Insert cell
Insert cell
formatTime = d3.timeFormat("%a, %b %e, %Y")
Insert cell
function parseTime(s) {
let t1 = parseTime1(s);
let t2 = parseTime2(s);
if (t1) {
return t1;
} else {
return t2;
}
}
Insert cell
parseTime2 = d3.utcParse("%Y-%m-%dT%H:%M:%SZ")
Insert cell
parseTime1 = d3.utcParse("%Y-%m-%dT%H:%M:%S.%LZ")
Insert cell
parser = new DOMParser()
Insert cell
params = new URLSearchParams(location.search)
Insert cell
Insert cell
Insert cell
// These can files can be accessed via a query string like ?gpx=CadillacMountain
// Useful for sharing rides with friends.
other_files = ({
CadillacMountain: await FileAttachment("CadillacMountain.gpx"),
Towpath: await FileAttachment("Towpath.05.28.22-reduced.gpx"),
Towpath2: await FileAttachment("Towpath.05.29.22.gpx")
})
Insert cell
Insert cell
Insert cell
// Let's download and parse the streets and topo style files to JSON right at the outset.
// That makes the switching between the two styles more efficient.
styles = ({
streets: await fetch(
"https://api.maptiler.com/maps/streets/style.json?key=W5IvZO3kQ9M5HwKinY7E"
).then(async function (r) {
return JSON.parse(await r.text());
}),
topo: await fetch(
"https://api.maptiler.com/maps/topo/style.json?key=W5IvZO3kQ9M5HwKinY7E"
).then(async function (r) {
return JSON.parse(await r.text());
})
})
Insert cell
// MapLibre-gl library
maplibregl = require("maplibre-gl@2.1.9")
Insert cell

Purpose-built for displays of data

Observable is your go-to platform for exploring data and creating expressive data visualizations. Use reactive JavaScript notebooks for prototyping and a collaborative canvas for visual data exploration and dashboard creation.
Learn more