Adventures in Self-Hosted Mapping

3 months ago 19

d3.js is a dataviz library that I have been interested in for a long time. For my personal and professional plotting needs so far I've always either used matplotlib or MATLAB's plotting features, but I've also always wondered about d3. The examples page shows all kinds of cool plots, including some maps! And I know that the New York Times makes extensive use of d3 with their awesome election coverage like this page.

It appears that there are plenty of examples on how to make a world map with d3. The basic idea is to load a GeoJSON (or better, TopoJSON) file describing the shape of all countries on Earth (see here for a GeoJSON Earth and here for a TopoJSON Earth) into a d3 SVG plot and then to add markers to it. A basic world map looks like this:

d3-basic-world-map.png Figure 4: A world map drawn with d3.js

So this seems easy enough, right? Shall we, then?

First we need to load the d3 and TopoJSON libraries. Since I like to self-host, I serve them from my server. To add the relevant code to my HTML header during my static site generation with Org-mode, I add the following:

#+HTML_HEAD_EXTRA: <script src="/res/d3.v7.min.js"></script> #+HTML_HEAD_EXTRA: <script src="/res/topojson.min.js"></script>

d3 can be downloaded from d3js.org and I pulled TopoJSON from cdnjs.com.

Now we need some GeoJSON points to plot. Let's make some in JavaScript.

Generate some GeoJSON points to plot

Note: The JS and CSS source code blocks in this post are all "inline" org-mode source blocks basically done like this. It's a nice way to embed JavaScript into the final HTML output both as executable code in <script> tags and as readable code blocks. I added some minification as an intermediate step, but it roughly works like in the linked reddit thread.

const raw_data = `Alaska 60.7938 -145.1080 Hawaii 19.8261 -155.4701 Vandenberg 34.7482 -120.5224 Shriever 38.8013 -104.5241 Greenland 64.1885 -51.6305 New Hampshire 42.9476 -71.6212 USNO Washington 38.9217 -77.0664 Cape Canaveral 28.4685 -80.5544 Ecuador -0.8867 -80.4261 Uruguay -34.7620 -55.8979 Ascension -7.9721 -14.4027 South Africa -26.5470 27.7835 United Kingdon 50.4631 -4.9707 Bahrain 26.0344 50.5616 Diego Garcia -7.2721 72.3677 South Korea 37.4548 126.7639 Guam 13.5897 144.9133 Kwajalein 8.7198 167.7331 Australia -35.3473 139.0051 New Zealand -39.2609 177.8659`; const locations = (() => { const locs = []; raw_data.split("\n").forEach(line => { const elems = line.split(" "); const lon = elems.pop(); const lat = elems.pop(); const name = elems.join(" "); const id = elems.join("_").toLowerCase(); locs.push({ geometry: { type: "Point", coordinates: [+lon, +lat] }, properties: { name: name, id: id, logo: null } }); }) return {type: "FeatureCollection", features: locs}; })();

We then need a div to attach our map to. In Org-mode, we can just do inline HTML:

#+HTML: <div id="d3-map-container"></div>

Next, we need some code to generate the map. I do this by defining a WorldMap class which loads the TopoJSON, adds markers based on the GeoJSON points we defined, and makes the map zoomable (using d3-zoom) and reset-able (using a plan HTML button). I also highlight each country on mouse hover and zoom to the country containing each marker (stolen from the Zoom to bounding box example).

JavaScript for d3.js map
class WorldMap { constructor(container) { const aspect_ratio = window.screen.width > 760 ? 1.95 : 5.0 / 4.0; this.width = container.offsetWidth; this.height = this.width / aspect_ratio; this.transition_duration = 750; this.strokew = 0.7; this.marker_sz = 30; this.max_zoom = 30; this.current_country = null; this.zoom_k = 1; this.container = container; this.customBoundingBoxes = (() => { const bb = (...points) => { return { type: "Feature", geometry: {type: "MultiPoint", coordinates: points.map((a) => {a.reverse(); return a;})} }}; return { 840: bb([71.35253039479932, -167.41389563354986], [60.18225380131154, -61.79225460292338], [32.11086453205738, -119.20365476385375], [24.874180405201567, -78.0859741811507]), 250: bb([51.37034230578915, -7.821973431466914], [51.55494178546891, 9.675957786795102], [42.40029510491933, -7.06477226868229], [43.05326072126442, 10.15332373724628]), }; })(); } async load(topofile, locations) { const topo = await d3.json(topofile); this.shapes = topojson.feature(topo, topo.objects.countries); this.locations = locations; this.zoom = d3.zoom() .scaleExtent([1, this.max_zoom]) .on("zoom", (event) => { const {transform} = event; this.g.attr("transform", transform); this.zoom_k = transform.k; this.countries.attr("stroke-width", this.strokew / transform.k); this.markers.attr("transform", (d, i, n) => { const scale = 1/transform.k; const lon = d.geometry.coordinates[0]; const lat = d.geometry.coordinates[1]; var proj = this.projection([lon, lat]); proj = [proj[0] - scale * this.marker_sz / 2, proj[1] - scale * this.marker_sz]; return `translate(${proj.join(",")}) scale(${scale})` }) const pan = event.type === "zoom" && event?.sourceEvent?.type === "mousemove"; if (pan) { this.svg.style("cursor", "grabbing"); } else { this.svg.style("cursor", "zoom-in"); } this.updateControlArea(); }) .on("end", () => { this.svg.style("cursor", "default") }); this.tooltip = d3.select(this.container) .append("div") .style("opacity", 0) .style("position", "absolute") .classed("tooltip", true); this.tooltip.append("img").attr("id", "tooltip-logo"); this.tooltip.append("div").attr("id", "tooltip-text"); this.ctrl_area = d3.select(this.container) .append("span") .classed("ctrl-area", true) this.ctrl_area.append("span") .attr("id", "ctrl-area-zoom"); this.ctrl_area.append("button") .attr("id", "ctrl-area-reset") .text("Reset") .on("click", this.reset.bind(this)); this.updateControlArea(); this.svg = d3.create("svg") .classed("map-content", true) .attr("viewBox", `0 0 ${this.width} ${this.height}`) .call(this.zoom) .on("click", this.reset.bind(this)); this.projection = d3.geoNaturalEarth1() .fitSize([this.width, this.height], this.shapes); this.geoGenerator = d3.geoPath() .projection(this.projection); this.g = this.svg.append("g"); this.countries = this.g.selectAll("path") .data(this.shapes.features) .join("path") .attr("d", d => this.geoGenerator(d)) .classed("country", true) .attr("stroke-width", this.strokew) .on("click", (event, feature) => { if (!event.target) return; if (this.current_country == feature) { return this.reset(); } else { this.current_country = feature; } this.makeLocationActive(null); this.makeCountryActive(event.target); event.stopPropagation(); this.zoomToCountry(feature); }) .on("mouseover", (event, feature) => { this.tooltip.select("#tooltip-text").text(feature.properties.name); this.tooltip.select("#tooltip-logo").style("display", "none"); const rect = this.tooltip.node().getBoundingClientRect(); this.tooltip .transition() .duration(200) .style("opacity", 0.9) .style("left", 5 + "px") .style("top", (this.height - rect.height - 5) + "px"); }) .on("mouseout", (event, feature) => { this.tooltip .transition() .duration(200) .style("opacity", 0); }); var map_pin = this.svg.append("symbol") .attr("id", "map-pin") .attr("width", 35) .attr("height", 35) .attr("viewBox", "0 0 35 35"); map_pin.append("path") .attr("d", "M 10.464044,3.7408996 C 8.9682449,5.3372942 8.031323,7.6627881 " + "8.0677035,9.8501619 c 0.075952,4.5709571 2.1363335,6.3012991 " + "5.4312295,12.5923621 1.186882,2.76832 2.425634,5.697555 3.603791,10.54899 " + "0.163729,0.715622 0.323403,1.380336 0.397275,1.437925 0.07383,0.05765 " + "0.233541,-0.60944 0.397278,-1.325066 1.178154,-4.851435 2.41691,-7.778338 " + "3.603791,-10.546655 3.294897,-6.291066 5.355236,-8.02142 5.43123,-12.5923771 " + "C 26.968682,7.7779671 26.029441,5.4501537 24.533638,3.8537591 22.824933,2.0301486 " + "20.247679,0.68054565 17.499999,0.62410832 14.752323,0.56763688 12.172749,1.9172891 " + "10.464044,3.7408996 Z") .attr("style", "fill: var(--mark-bg-fill); stroke: var(--mark-bg-stroke); stroke-width: 0.313686; stroke-miterlimit: 4;") map_pin.append("circle") .attr("r", "3.6088443") .attr("cy", "10.020082") .attr("cx", "17.5") .attr("style", "fill: var(--mark-fg-fill); stroke-width: 0;") this.markers = this.g.selectAll(".mark") .data(this.locations) .enter() .append("use") .attr("class", "mark") .attr('width', this.marker_sz) .attr('height', this.marker_sz) .attr("href", "#map-pin") .attr("transform", (d) => { const lon = d.geometry.coordinates[0]; const lat = d.geometry.coordinates[1]; var proj = this.projection([lon, lat]); proj = [proj[0] - this.marker_sz/2, proj[1] - this.marker_sz]; return `translate(${proj.join(",")}) scale(1)` }) .on("mouseover", (event, feature) => { this.svg.style("cursor", "pointer"); this.moveTooltipToLocation(feature, event.target, 100); }) .on("mouseout", (event) => { this.svg.style("cursor", "default"); this.tooltip .transition() .duration(100) .style("opacity", 0); }) .on("click", (event, feature) => { event.stopPropagation(); this.makeLocationActive(feature, event.target); }); this.locations.forEach((g) => { d3.select("#outline-container-" + g.properties.id) .attr("class", "location-info"); }); return this.svg; } reset() { this.current_country = null; this.makeCountryActive(null); this.makeLocationActive(null); this.svg.transition() .duration(this.transition_duration) .call(this.zoom.transform, d3.zoomIdentity .translate(0, 0) .scale(1)); } makeLocationActive(feature, dom_elem) { const id = feature ? feature.properties.id : null; this.locations.forEach((g) => { const cid = g.properties.id; if (id === cid) { const parentCountry = this.countries.filter((d) => { return d3.geoContains(d, feature.geometry.coordinates); }); if (parentCountry.size() > 0) { this.makeCountryActive(parentCountry.node()); this.current_country = parentCountry.datum(); this.zoomToCountry(parentCountry.datum()); this.moveTooltipToLocation(feature, dom_elem, 0); } } }); } makeCountryActive(elem) { d3.selectAll(".country").classed("active", false); if (elem) { d3.select(elem).classed("active", true); } } zoomToCountry(feature) { const [[x0, y0], [x1, y1]] = this.geoGenerator.bounds(this.customBoundingBoxes[+feature.id] || feature); var k = Math.min(12, 0.9 / Math.max((x1 - x0) / this.width, (y1 - y0) / this.height)); var [t_x, t_y] = [-(x0 + x1) / 2, -(y0 + y1) / 2]; this.svg.transition() .duration(this.transition_duration) .call(this.zoom.transform, d3.zoomIdentity .translate(this.width / 2, this.height / 2) .scale(k) .translate(t_x, t_y), d3.pointer(event, this.svg.node())); } updateControlArea() { this.ctrl_area.select("#ctrl-area-zoom") .text("🔍: " + (this.zoom_k * 100).toFixed(0) + "%"); const rect = this.ctrl_area.node().getBoundingClientRect(); this.ctrl_area .style("top", (this.height - rect.height - 5) + "px") .style("left", (this.width - rect.width - 5) + "px") } moveTooltipToLocation(feature, dom_elem, duration) { if (feature.properties.logo) { this.tooltip.select("#tooltip-logo") .style("display", "block") .style("height", (Math.min(this.height/6, 60) - 10) + "px") .style("width", "auto") .attr("src", feature.properties.logo); } this.tooltip.select("#tooltip-text").text(feature.properties.name) const rect = this.tooltip.node().getBoundingClientRect(); this.tooltip .transition() .duration(duration) .style("top", (this.height - rect.height - 5) + "px") .style("left", 5 + "px") .style("opacity", 0.9); } }

Let's now make this map look sort of like a world map. Water is blue, right? Let's do that and keep the countries white. Off-white maybe. We also need to style the markers a bit and buttons and popups also need to be styled, so let's write some CSS:

CSS for d3.js map
#d3-map-container { position: relative; margin-top: 10px; margin-bottom: 0px; width: calc(100% - 2px); } :root { --map-fg-color: var(--main-bg-color); --map-bg-color: #c6dbe9; } .map-content { border: solid; border-width: 1px; border-radius: 5px; padding: 0; background-color: var(--map-bg-color); .country { stroke: var(--map-bg-color); fill: var(--map-fg-color); } .country:hover, .country.active { fill: var(--code-bg-color); } use.mark { --mark-bg-fill: #ff4646; --mark-bg-stroke: #d73534; --mark-fg-fill: #590000; } use.mark:hover { --mark-bg-fill: #590000; --mark-bg-stroke: #d73534; --mark-fg-fill: #ff4646; } } .tooltip, .ctrl-area { font-family: "Charter", "serif"; font-size: 12pt; } .tooltip { background-color: var(--map-fg-color); stroke: var(--main-fg-color); display: flex; flex-direction: column; text-align: center; border: solid; border-width: 1px; border-radius: 5px; padding: 3px 5px 3px 5px; } .ctrl-area { position: absolute; display: inline-flex; gap: 0.5rem; opacity: 0.9; align-items: center; background-color: var(--map-fg-color); stroke: var(--main-fg-color); border: solid; border-width: 1px; border-radius: 5px; padding: 3px 5px 3px 5px; button { background-color: var(--code-bg-color); color: var(--main-fg-color); margin-right: 0.4rem; border-color: var(--main-fg-color); border-style: solid; border-width: 1px; border-radius: 4px; padding: 0.2rem; font-family: inherit; font-size: inherit; } button:hover { background-color: var(--main-bg-color); transition-duration: 0.2s; } }

Finally, we need to actually load the map once the page is done loading:

Startup code for d3.js map
const d3Container = document.getElementById("d3-map-container") const d3Map = new WorldMap(d3Container); document.addEventListener("DOMContentLoaded", async () => { d3Map.load("/res/countries-50m.json", locations.features) .then(svg => { d3Map.makeLocationActive(null); d3Container.append(svg.node()); }); })

The end result looks like this:

Pretty cool, right? An interactive map, clickable, zoomable, panable, styled, with markers; basically all that I am looking for! You should take a second to play with it! It's fun!

But…phew…honestly…that…was…a lot? Throughout the entire experience of building this map, I oftentimes had to take a few steps back and ask myself questions like: "Why am I manually moving these markers around the map while I am zooming, just to keep them static on the map?" (cf. StackOverflow)

This method just seems like an awful lot of code just to display a relatively basic looking map without many features. Sure, it'll work as a map and it does meet my rather vaguely defined requirements, but something doesn't feel right here. I think the main issue with this approach is that even though mapping support in d3 is great, d3 really is not a mapping library. On the contrary; d3 is a plotting and dataviz library that can be beat into submission to display an interactive map. But d3 does not want to be a map, it is too low-level and generic for that.

So at this point, while I am happy with the result, I am also casually googling around again, looking for inspiration on how others have done maps on their website. This time around, having built my minimum viable product already (and having picked up some more JavaScript in the process), I am opening my mind up to new ideas yet again.

Read Entire Article