Skip to content

Instantly share code, notes, and snippets.

@Sphinxxxx
Created April 19, 2020 20:35
Show Gist options
  • Save Sphinxxxx/4bd331f8a964a78059db5aac30c63a62 to your computer and use it in GitHub Desktop.
Save Sphinxxxx/4bd331f8a964a78059db5aac30c63a62 to your computer and use it in GitHub Desktop.
Render Shapefile to SVG
<script>console.clear();</script>
<script src="https://d3js.org/d3.v4.min.js"></script>
<script src="https://d3js.org/d3-array.v1.min.js"></script>
<script src="https://d3js.org/d3-geo.v1.min.js"></script>
<script src="https://d3js.org/d3-geo-projection.v1.min.js"></script>
<script src="https://unpkg.com/shapefile"></script>
<!-- ABOUtils.dropFile()
<script src="https://codepen.io/Sphinxxxx/pen/VejGLv.js"></script>
-->
<h2>Upload (drop) a .shp Shapefile,<br/>or a .shp + a .dbf file</h2>
<p>
Based on <a href="https://bl.ocks.org/mbostock/2dd741099154a4da55a7db31fd96a892">Streaming Shapefile</a>
</p>
<!--
<canvas width="120" height="60"></canvas>
-->
//TODO: Find the width/height automatically:
// https://stackoverflow.com/questions/39202969/scale-path-to-fit-svg-height-without-blind-trial-and-error
// https://stackoverflow.com/questions/14492284/center-a-map-in-d3-given-a-geojson-object
const svg = d3.select("body").append("svg")
.attr("id", "map")
.attr("width", 1920)
.attr("height", 960)
//Matches world coordinates from http://www.naturalearthdata.com/downloads/
.attr("viewBox", "0 10 960 480")
//Zoom/pan functionality
//https://coderwall.com/p/psogia/simplest-way-to-add-zoom-pan-on-d3-js
.call(d3.zoom().on("zoom", function () {
svg.attr("transform", d3.event.transform);
//document.querySelector('#map').style.fontSize = (4/d3.event.transform.k) + 'px';
}))
.append("g").attr("id", "zoomer")
;
const projection = d3.geoEquirectangular()/*.geoAlbersUsa()
.scale(128.5)
.translate([canvas.width / 2, canvas.height / 2])*/,
path = d3.geoPath()
.projection(projection);
let labelCount = 0, allLabels = [];
function drawShapefile(inputSHP, inputDBF) {
var drawn = 0,
logStuff = 0;
function parseSource(source) {
//console.log(source.bbox);
//console.log('pS', source, arguments);
const shapeGroup = svg.append("g")
.attr('class', 'shapefile')
.attr('color', '#' + Math.floor(Math.random() * 12*12*12).toString(12).padStart(3, '0'));
const dd_ = {};
function parseFeature(result) {
if(result.done) {
console.log('Labels', labelCount);
console.log(JSON.stringify(dd_));
//DEBUG
renderLabels(allLabels, svg);
//DEBUG
return;
}
//console.log('pF', result, result.value.geometry.type);
const feat = result.value,
geom = feat.geometry,
props = feat.properties,
label = props.name || props.display;
if(drawn < logStuff) { console.log(result); }
const group = shapeGroup.append("g").attr('class', 'feature'),
code = Number(props.iso_a3)
? props.adm0_a3 //Partially-recognized state, e.g. Kosovo
: props.iso_a3;
if(code) {
if(Number(code)) { console.warn(feat); }
group.attr('data-code', code);
}
function pointMarker(coord) {
group.append('circle')
.attr('class', 'point-marker')
.attr('cx', coord[0])
.attr('cy', coord[1])
.attr('r', '.5em')
;
}
//Outline
//console.log(geom.type);
if(geom.type === 'Point') {
const coord = path.centroid(geom); //geom.coordinates;
pointMarker(coord);
}
else {
var pathData = path(feat);
if(drawn < logStuff) { console.log(pathData); }
if(code) {
if(dd_[code]) {
console.warn('Duplicate code', code);
}
//Parse the polylines for each separate territory:
dd_[code] = pathData
.split('M').filter(x => x.trim())
.map(x => x.match(/[0-9\.]+/g).map(x => Number( Number(x).toFixed(1) )));
}
group.append("path")
.attr("d", pathData);
}
//Label
//https://bost.ocks.org/mike/map/
var center;
//Center the label on the largest area if the geometry has multiple areas:
//http://stackoverflow.com/questions/16019637/d3-finding-the-area-of-a-geo-polygon-in-d3
if(geom.type === 'MultiPolygon') {
var largestArea = 0, largestGeom;
geom.coordinates.forEach(function(coord, i) {
var geomCopy = {
//Nope, each polygon is wrapped in a single-element MultiPolygon-ish array..
// type: 'Polygon',
type: geom.type,
coordinates: [coord]
},
area = path.area(geomCopy);
if(area > largestArea) {
largestArea = area;
largestGeom = geomCopy
};
});
center = path.centroid(largestGeom);
}
else {
center = path.centroid(geom);
}
//console.log(center);
if(label) {
if(geom.type !== 'Point') {
pointMarker(center);
}
//group.append("text")
// .attr("transform", 'translate(' + projection(geom.coordinates) + ')')
// .text(feat.properties.name);
//group.append("text")
// //.attr("transform", 'translate(' + path.centroid(feat) + ')')
// .attr('x', center[0])
// .attr('y', center[1])
// .text(feat.properties.name || feat.properties.display);
allLabels.push({
x: center[0],
y: center[1],
label: feat.properties.name || feat.properties.display,
});
labelCount++;
}
drawn++;
source.read().then(parseFeature);
}
source.read().then(parseFeature);
}
shapefile.open(inputSHP, inputDBF)
.then(parseSource)
.catch(function(error) {
console.error(error.stack);
});
}
(function dropFiles(target) {
function handleFiles(files) {
var shp, dbf, datas = {};
function readData(file) {
if(!file) { return; }
//Make an ArrayBuffer from the file data instead of sending the blob url,
//because shapefile() would corrupt that url by adding a ".shp" extension.
var reader = new FileReader();
reader.onload = function (e) {
datas[file.name] = reader.result;
renderFiles();
}
reader.readAsArrayBuffer(file);
}
function renderFiles() {
if(shp && !datas[shp.name]) { return; }
if(dbf && !datas[dbf.name]) { return; }
drawShapefile(shp && datas[shp.name], dbf && datas[dbf.name]);
}
files.forEach(function(file) {
var name = file.name;
datas[name] = null;
if(name.match(/\.shp$/i)) {
shp = file;
}
else if(name.match(/\.dbf$/i)) {
dbf = file;
}
});
if(!shp) {
console.error('No .shp file found');
}
readData(dbf);
readData(shp);
}
target.ondragover = function (e) { e.preventDefault(); };
//target.ondragend = function () { return false; };
target.ondrop = function (e) {
e.preventDefault();
var files = e.dataTransfer.files;
if(files && files.length)
handleFiles(Array.from(files));
}
})(document.body);
//var defaultUrl = "https://cdn.rawgit.com/matplotlib/basemap/master/lib/mpl_toolkits/basemap/data/UScounties.shp"
//defaultUrl = "https://cdn.rawgit.com/gwaldron/osgearth/2.8/data/usa.shp";
//drawShapefile(defaultUrl);
/*
Create a force layout for the nodes' labels
https://codepen.io/Sphinxxxx/pen/PJbaXm?editors=0110
*/
function renderLabels(nodes, svg) {
//Reset:
svg.selectAll(".anchorLink").remove();
svg.selectAll(".anchorNode").remove();
//https://stackoverflow.com/questions/21990857/d3-js-how-to-get-the-computed-width-and-height-for-an-arbitrary-element
//http://es6-features.org/#ObjectMatchingDeepMatching
const { width: w, height: h } = svg.node().getBoundingClientRect();
var labelAnchors = [];
var labelAnchorLinks = [];
for(var i = 0; i < nodes.length; i++) {
const node = nodes[i];
labelAnchors.push({
node: node,
_ldist: node.labelDistance || 0,
//_llen: (node.labelDistance || 1)*2,
x: node.x,
y: node.y,
});
labelAnchors.push({
node: node,
_ldist: node.labelDistance || 0,
x: node.x + ((node.x < w/2) ? -100 : 100),
y: node.y, //+ ((node.y < h/2) ? -10 : 10),
});
};
for(var i = 0; i < nodes.length; i++) {
labelAnchorLinks.push({
source : i * 2,
target : i * 2 + 1,
});
};
//Render the UI before we set up the force,
//because we need to know the length of each label:
var anchorLink = svg.selectAll("line.anchorLink").data(labelAnchorLinks).enter().append("svg:line").attr("class", "anchorLink");
var anchorNode = svg.selectAll("g.anchorNode").data(labelAnchors).enter().append("svg:g").attr("class", "anchorNode");
anchorNode.append("svg:circle").attr("r", ".5em").attr('class', (d, i) => (i % 2) ? 'drag' : '');
anchorNode.append("svg:text").attr("class", "label")
//Add labels to all the anchors (i.e. duplicate labels),
//because we need to measure the label length at the draggable outliers which do all the forcing:
.text((d, i) => /*(i % 2) ? '' :*/ `${d.node.label}`);
//Measure the length of each label's <text> element:
anchorNode.each(function(d, i) {
//d.llen = this.childNodes[1].clientWidth;
//Firefox/IE:
d._llen = d._llen || this.childNodes[1].getBoundingClientRect().width;
});
//Now that we have measured our node labels, remove the duplicate labels:
//https://stackoverflow.com/questions/16260285/d3-removing-elements
svg.selectAll(".anchorNode circle.drag ~ text").remove();
/*
const force2 = d3.layout.force()
.size([w, h])
.nodes(labelAnchors)
.links(labelAnchorLinks)
//https://stackoverflow.com/questions/34355120/d3-js-linkstrength-influence-on-linkdistance-in-a-force-graph/34376334#34376334
//https://github.com/d3/d3-3.x-api-reference/blob/master/Force-Layout.md
.gravity(0)
.linkDistance(x => x.target._ldist + (x.target._llen/2))
.linkStrength(10)
//.charge(-100)
.charge(d => {
return -d._llen;
})
.chargeDistance(100)
;
force2.start();
anchorNode.call(force2.drag);
*/
const simulation = d3.forceSimulation(labelAnchors)
//.force("charge", d3.forceManyBody().strength(-100).distanceMax((d, i) => d._llen))
.force("collide", d3.forceCollide(d => d._llen*.3).strength(.02))
.force("link", d3.forceLink(labelAnchorLinks).distance((d, i) => d.target._ldist + (d.target._llen*.5)).strength(.1))
//.on("tick", ticked)
;
//Node dragging:
const node_drag = prepareDrag(simulation);
anchorNode.call(node_drag);
function updateLink(l) {
l.attr("x1", d => d.source.x)
.attr("y1", d => d.source.y)
.attr("x2", d => d.target.x)
.attr("y2", d => d.target.y);
}
function updateNode(n) {
n.attr("transform", d => `translate(${d.x},${d.y})`);
}
simulation.on("tick", function() {
anchorNode.each(function(d, i) {
//return;
if(i % 2 === 0) {
d.x = d.node.x;
d.y = d.node.y;
var text = this.childNodes[1],
target = labelAnchors[i+1];
//https://gist.github.com/conorbuck/2606166
var angleDeg = Math.round(Math.atan2(target.y - d.y, target.x - d.x) * 180/Math.PI);
if(angleDeg < 0) { angleDeg += 360; }
if((angleDeg > 90) && (angleDeg < 270)) {
text.setAttribute('text-anchor', 'end');
text.setAttribute('x', -d._ldist);
text.setAttribute('transform', `rotate(${angleDeg-180} ${0} ${0})`);
}
else {
text.setAttribute('text-anchor', 'start');
text.setAttribute('x', d._ldist);
text.setAttribute('transform', `rotate(${angleDeg} ${0} ${0})`);
}
}
//d.x = Math.round(d.x);
//d.y = Math.round(d.y);
});
anchorNode.call(updateNode);
anchorLink.call(updateLink);
});
/* Node dragging
https://bl.ocks.org/shimizu/e6209de87cdddde38dadbb746feaf3a3
https://stackoverflow.com/questions/42605261/d3-event-active-purpose-in-drag-dropping-circles
*/
function prepareDrag(simulation) {
//fx & fy:
//https://github.com/d3/d3-force/blob/v1.1.0/README.md#simulation_nodes
function dragstarted(d) {
if (!d3.event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
}
function dragged(d) {
d.fx = d3.event.x;
d.fy = d3.event.y;
}
function dragended(d) {
if (!d3.event.active) simulation.alphaTarget(0);
//To leave nodes fixed after dragging, remove these two lines:
d.fx = null;
d.fy = null;
}
var dragger = d3.drag()
.on("start", dragstarted)
.on("drag", dragged)
.on("end", dragended);
return dragger;
}
return simulation;
}
body {
margin: 0;
padding: 0 1em 1em 1em;
min-height: 100vh;
}
canvas {
background: aliceblue;
}
svg {
background: aliceblue;
font-size: 4px;
path, circle, line {
fill: transparent;
stroke: currentColor;
stroke-width: .08em;
}
text {
font-family: sans-serif;
//font-size: 10px;
alignment-baseline: middle;
//Horizontal label alignment:
//text-anchor: middle;
fill: currentColor;
stroke: none;
}
.point-marker {
opacity: .5;
}
.anchorNode circle {
fill: transparent;
stroke: gainsboro;
&:not(.drag) {
display: none;
}
&.drag {
fill: rgba(yellow, .3);
stroke: gold;
cursor: move;
}
}
.anchorLink {
stroke: rgba(black, .1);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment