|
<!DOCTYPE html> |
|
<meta charset="utf-8"> |
|
<style> |
|
#sphere { |
|
stroke: black; |
|
stroke-width: 1; |
|
fill: rgba(10,10,10,0.05); |
|
} |
|
.links path { stroke-width: 0} |
|
#countries path { |
|
fill: none; |
|
stroke: none; |
|
} |
|
#graticule { |
|
fill: none; |
|
stroke: #aaa; |
|
stroke-width: 0.5 |
|
} |
|
.polygons { |
|
stroke: #444; |
|
} |
|
|
|
.sites { |
|
stroke: black; |
|
fill: white; |
|
} |
|
|
|
</style> |
|
|
|
<svg width="960" height="600"></svg> |
|
|
|
<script src="https://unpkg.com/d3@5"></script> |
|
<script src="https://unpkg.com/topojson"></script> |
|
<script src="https://unpkg.com/d3-geo-voronoi@0"></script> |
|
<script src="https://unpkg.com/d3-geo-polygon"></script> |
|
<script src="kruskal.js"></script> |
|
|
|
|
|
<script> |
|
|
|
|
|
var radians = Math.PI / 180, degrees = 1 / radians; |
|
|
|
d3.json("https://unpkg.com/visionscarto-world-atlas/world/110m.json").then(function(world) { |
|
world = topojson.feature(world, world.objects.countries); |
|
|
|
var width = 960, height = 500; |
|
|
|
|
|
var n = world.features.length; |
|
|
|
function spherical(cartesian) { |
|
return [ |
|
Math.atan2(cartesian[1], cartesian[0]), |
|
Math.asin(Math.max(-1, Math.min(1, cartesian[2]))) |
|
]; |
|
} |
|
|
|
function to_degrees(v){ |
|
return v.map(d => d * degrees); |
|
} |
|
|
|
function normalize (a) { |
|
var d = Math.sqrt(a[0]*a[0] + a[1]*a[1] + a[2]*a[2]); |
|
return a.map (e => e / d); |
|
} |
|
var g = (1 + Math.sqrt(5)) / 2; |
|
var vertices = [ |
|
[0, 1, g], |
|
[g, 0, 1], |
|
[1, g, 0] |
|
]; |
|
vertices = d3.merge([ |
|
vertices, |
|
vertices.map(d => d.map(e => e * (e == 1 ? -1 : 1))), |
|
vertices.map(d => d.map(e => e * (e > 1 ? -1 : 1))), |
|
vertices.map(d => d.map(e => e * (e > 0 ? -1 : 1))) |
|
]) |
|
.map(normalize) |
|
.map(spherical) |
|
.map(to_degrees) |
|
|
|
|
|
var points = { |
|
type: "FeatureCollection", |
|
features: vertices.map(function(f, i) { |
|
return { |
|
type: "Point", |
|
index: i, |
|
coordinates: f |
|
} |
|
}) |
|
} |
|
|
|
var v = d3.geoVoronoi()(points); |
|
|
|
|
|
var links = v.links().features.map(d => d.properties)//.filter(d => d.urquhart) |
|
|
|
// prefer certain links |
|
links.forEach(l => { |
|
var u = d3.extent([l.source.index, l.target.index]).join('-'); |
|
l.length = 1 - 0.5 * (['0-1', '1-4', '1-8', '2-4', '2-5', '3-7', '3-8', '6-10', '8-9'].indexOf(u) > -1) |
|
}) |
|
|
|
var k = { |
|
type: "FeatureCollection", |
|
features: kruskal(links).map(l => ({ |
|
type:"LineString", |
|
coordinates: [l.source.coordinates, l.target.coordinates], |
|
properties: l |
|
})) |
|
}; |
|
|
|
|
|
var myriahedral = function(poly, faceProjection) { |
|
|
|
// it is possible to pass a specific projection on each face |
|
// by default is is a gnomonic projection centered on the face's centroid |
|
// scale 1 by convention |
|
var i = 0; |
|
faceProjection = faceProjection || function(face) { |
|
var c = d3.geoCentroid({type: "MultiPoint", coordinates: face}); |
|
return d3.geoGnomonic() |
|
.scale(1) |
|
.translate([0, 0]) |
|
.rotate([-c[0], -c[1]]); |
|
}; |
|
|
|
// the faces from the polyhedron each yield |
|
// - face: its vertices |
|
// - contains: does this face contain a point? |
|
// - project: local projection on this face |
|
var faces = poly.map(function(face) { |
|
var polygon = face.slice(); |
|
face = face.slice(0,-1); |
|
return { |
|
face: face, |
|
contains: function(lambda, phi) { |
|
// todo: use geoVoronoi.find() instead? |
|
return d3.geoContains({ type: "Polygon", coordinates: [ polygon ] }, |
|
[lambda * degrees, phi * degrees]); |
|
}, |
|
project: faceProjection(face) |
|
}; |
|
}); |
|
|
|
// Build a tree of the faces, starting with face 0 (North Pole) |
|
// which has no parent (-1) |
|
var parents = [-1]; |
|
var search = poly.length - 1; |
|
do { |
|
k.features.forEach(l => { |
|
var s = l.properties.source.index, |
|
t = l.properties.target.index; |
|
if (parents[s] !== undefined && parents[t] === undefined) { |
|
parents[t] = s; |
|
search --; |
|
} |
|
else if (parents[t] !== undefined && parents[s] === undefined) { |
|
parents[s] = t; |
|
search --; |
|
} |
|
}); |
|
} while (search > 0); |
|
|
|
console.log('vertices', JSON.stringify(vertices)); |
|
console.log('parents', JSON.stringify(parents)); |
|
|
|
parents |
|
.forEach(function(d, i) { |
|
var node = faces[d]; |
|
node && (node.children || (node.children = [])).push(faces[i]); |
|
}); |
|
|
|
//console.log('faces', faces) |
|
|
|
|
|
// Polyhedral projection |
|
var proj = d3.geoPolyhedral(faces[0], function(lambda, phi) { |
|
for (var i = 0; i < faces.length; i++) { |
|
if (faces[i].contains(lambda, phi)) return faces[i]; |
|
} |
|
}) |
|
.angle(108) |
|
.rotate([-8,0,-32]) |
|
.fitExtent([[20,20],[width-20, height-20]], {type:"Sphere"}) |
|
|
|
proj.faces = faces; |
|
return proj; |
|
}; |
|
|
|
|
|
var projection = myriahedral( |
|
v.polygons().features.map(d => d.geometry.coordinates[0]) |
|
); |
|
|
|
//projection = d3.geoBertin1953();//.rotate([0.1,0,0.0001]) |
|
//projection = d3.geoPolyhedralButterfly();//.rotate([0.1,0,0.0001]) |
|
var path = d3.geoPath().projection(projection); |
|
|
|
var svg = d3.select("svg"); |
|
|
|
if (1) svg.append('path') |
|
.attr('id', 'sphere') |
|
.datum({ type: "Sphere" }) |
|
.attr('d', path); |
|
|
|
if (1) svg.append('path') |
|
.attr('id', 'graticule') |
|
.datum(d3.geoGraticule()()) |
|
.attr('d', path); |
|
|
|
|
|
|
|
|
|
var countries = svg.append('g').attr('id', 'countries') |
|
|
|
|
|
if(1) countries |
|
.selectAll('path') |
|
.data(world.features) |
|
.enter() |
|
.append('path') |
|
.attr("d", path) |
|
.style('fill', (_,i) => d3.schemePaired[i%10]); |
|
|
|
|
|
projection.rotate([0,0,0]) |
|
|
|
// this is a bit tedious… |
|
var ko = k.features.map(d => { |
|
var x = d.coordinates.map(projection), |
|
delta = [x[1][0]-x[0][0], x[1][1]-x[0][1]]; |
|
var angle = Math.atan2(delta[1], delta[0]), |
|
len = Math.sqrt(delta[0]*delta[0] + delta[1]*delta[1]); |
|
var A = 0.61803 /* g - 1 - epsilon */, B = 36 * radians; |
|
var y0 = [x[0][0] + len * Math.cos(angle + B) * A, |
|
x[0][1] + len * Math.sin(angle + B) * A |
|
]; |
|
var y1 = [x[0][0] + len * Math.cos(angle - B) * A, |
|
x[0][1] + len * Math.sin(angle - B) * A |
|
]; |
|
return [y0,y1].map(projection.invert); |
|
|
|
}); |
|
if (1) svg.append('g') |
|
.selectAll('path') |
|
.data([{type:"MultiLineString", coordinates: ko}]) |
|
.enter() |
|
.append('path') |
|
.attr('d', path) |
|
.attr('fill', 'none') |
|
.attr('stroke', 'black') |
|
.attr('stroke-width', 0.5) |
|
.attr('stroke-dasharray', '3') |
|
|
|
|
|
|
|
if (1) svg.append('g') |
|
.attr('class', 'sites') |
|
.selectAll('circle') |
|
.data(points.features) |
|
.enter() |
|
.append('circle') |
|
.attr('transform', d => `translate(${projection(d.coordinates)})`) |
|
.attr('r', 10); |
|
|
|
if (1) svg.append('g') |
|
.selectAll('text') |
|
.data(points.features) |
|
.enter() |
|
.append('text') |
|
.text((d,i) => i) |
|
.attr('transform', d => `translate(${projection(d.coordinates)})`) |
|
.attr('text-anchor', 'middle') |
|
.attr('dy', 5); |
|
|
|
svg.append('g') |
|
.attr('class', 'links') |
|
.selectAll('path') |
|
.data(k.features) |
|
.enter() |
|
.append('path') |
|
.attr('d', path) |
|
.attr('stroke', 'black') |
|
.attr('fill', 'none'); |
|
|
|
|
|
// gentle animation |
|
if (0) d3.interval(function(elapsed) { |
|
projection.rotate([ elapsed / 150, 0, 0.001 ]); |
|
svg.selectAll('path') |
|
.attr('d', path); |
|
}, 50); |
|
|
|
|
|
|
|
}); |
|
|
|
|
|
|
|
</script> |