A Pen by Andreas Borgen on CodePen.
Created
April 19, 2020 20:35
-
-
Save Sphinxxxx/4bd331f8a964a78059db5aac30c63a62 to your computer and use it in GitHub Desktop.
Render Shapefile to SVG
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<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> | |
--> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
//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; | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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