-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathuniform-geodesic-grid.mjs
executable file
·188 lines (168 loc) · 6.18 KB
/
uniform-geodesic-grid.mjs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
#!/usr/bin/env node
/* eslint-disable newline-per-chained-call */
const d3 = {
...(await import("d3-geo")),
...(await import("d3-geo-projection")),
...(await import("d3-geo-polygon")),
...(await import("d3-geo-voronoi")),
};
import * as cmd from "commander";
// Parse the arguments
const args = cmd.program
.name('uniform-geodesic-grid')
.description('Generate a homogenously spaced hexagonal geodesic grid.')
.version('0.2.0')
.addOption(new cmd
.Option("--cell-spacing <km>", "target distance between cell centers in kilometers")
.argParser((value) => {
value = parseFloat(value)
if (isNaN(value)) throw new cmd.InvalidArgumentError();
if (value > 700 || value < 30) throw new cmd.InvalidArgumentError('Cell spacing must be between 30 and 700 km');
return value
})
.conflicts("k")
.conflicts("cellArea")
)
.addOption(new cmd
.Option("--cell-area <km²>", "target cell area in km²")
.argParser((value) => {
value = parseFloat(value)
if (isNaN(value)) throw new cmd.InvalidArgumentError();
if (value > 50000 || value < 800) throw new cmd.InvalidArgumentError('Cell area must be between 800 and 50\'000 km²');
return value
})
.conflicts("k")
.conflicts("cellSpacing")
)
.addOption(new cmd
.Option("--k <subdivisions>", "number of triangle edge subdivisions for grid generation")
.argParser((value) => {
value = parseInt(value)
if (isNaN(value) || value <= 0 ) throw new cmd.InvalidArgumentError();
if (value > 250) throw new cmd.InvalidArgumentError('Number of subdivisions should not exceed 250');
return value
})
.default(30)
.conflicts("cellArea")
.conflicts("cellSpacing")
)
.addOption(new cmd
.Option("--pretty", "pretty-format the JSON output")
.default(false)
)
.parse().opts()
// K is the number of triangle side subdivisions
const K = (() => {
if ((args.cellArea == undefined) && (args.cellSpacing == undefined)) return args.k
const earth_radius = 6371.01
const earth_surface_area = 4 * Math.PI * earth_radius ** 2
// estimate the cell area from spacing as area of regular hexagon
// note: planar geometry is used for simplicity since the relative error is sufficiently low
const cell_area = (args.cellArea) ?? (0.5*Math.sqrt(3)*args.cellSpacing**2)
// there are 10*K^2 + 2 cells
return Math.round(Math.sqrt( (earth_surface_area/cell_area - 2)/10))
})()
// utility functions
function* range(start, end) {
if (start >= end) return
for (let i = start; i < end; i++) yield i;
}
function* rasterizeTriangle(tri, k, includeEdges = false) {
const step = 1.0 / k;
const offset = includeEdges ? 0 : 1
for (let u = k - offset; u >= offset; u -= 1) {
for (let v = offset; v <= k - u - offset; v += 1) {
const barycentrics = [u * step, v * step, (k - u - v) * step];
// calculate the point coordinates from barycentrics
yield tri[0].map((_, ci) => {
return barycentrics.reduce((sum, w, vi) => sum + tri[vi][ci] * w, 0);
});
}
}
}
// a single icosahedron face as a geodesic triangle
const theta = (Math.atan(0.5) / Math.PI) * 180;
let triangle = [
[0, theta],
[36, -theta],
[-36, -theta],
];
triangle.centroid = d3.geoCentroid({
type: "Polygon",
coordinates: [[...triangle, triangle[0]]],
});
// Set up the Gray-Fuller projection, which we use to project
// between the geodesic triangle and the planar triangle
const proj = d3
.geoProjection(d3.geoGrayFullerRaw())
.rotate([-triangle.centroid[0], -triangle.centroid[1]])
.center([0, triangle[0][1] - triangle.centroid[1]])
.scale(1)
.translate([0, 0]);
// generate equally spaced points on the geodesic sphere
// note: only consider the point inside the triangle,
// we will add edges and vertices later since they are
// shared between multiple faces of the icosahedron
const face_points = [...rasterizeTriangle(triangle.map(proj), K, false)]
.map((p) => proj.invert(p))
.map((p) => [p, d3.geoRotation([0, 90 - theta, 180])(p)])
.flatMap( ([[x0, y0], [x1, y1]]) => [
// 10 triangles forming the middle section of the sphere
...range(0, 10).map((i) => [x0 + i * 36, (i % 2 ? -1 : 1) * y0]),
// 5 triangles forming the north cap
...range(0, 5).map((i) => [x1 + i*72, y1]),
// 5 triangles forming the south cap
...range(0, 5).map((i) => [36 - x1 + i * 72, -y1]),
])
// generate the vertices and edges
const vertices = [
[0, 90],
[0, -90],
...range(0, 10).map((i) => [((i * 36 + 180) % 360) - 180, i & 1 ? -theta : theta])
]
const edges = vertices.flatMap( (v0, i) => vertices.slice(i+1).flatMap( (v1) => {
// we know th distance between neighbouring vertices
if (Math.abs(d3.geoDistance(v0, v1) - 1.1) >= 0.01) return [];
// interpolate along the edge
const interpolator = d3.geoInterpolate(v0, v1);
return [...range(1, K).map( (i) => interpolator(i/K))]
}))
// combine the points and rotate them to place the icosahedron vertices in the ocean
const points = [...vertices, ...edges, ...face_points]
.map(d3.geoRotation([36, 0, 0]))
.map(d3.geoRotation(d3.geoAirocean().rotate()).invert)
// build the voronoi diagram of grid cells
const voronoi = d3.geoVoronoi(points);
const delaunay = voronoi.delaunay;
function as_lonlat([lon, lat]) {
return { lon: lon, lat: lat };
}
function make_cell_polygon(delaunay_polygon) {
let ring = [...delaunay_polygon.reverse().map( (j) => delaunay.centers[j])]
ring.push(ring[0])
return ring
}
// Produce GeoJSON representation of cells
const cell_features = range(0, voronoi.points.length).map((i) => ({
type: "Feature",
properties: {
// grid cell id
gid: i,
// longitude,lattitude coordinates of the cell center
...as_lonlat(voronoi.points[i]),
// cell neighbours (0-indexing)
neighbors: delaunay.neighbors[i],
// cell location on the icosahedron
icosahedron_placement: (i < vertices.length + edges.length) ? ((i < vertices.length) ? "vertex" : "edge") : "face"
},
geometry: {
type: "Polygon",
coordinates: [make_cell_polygon(delaunay.polygons[i])]
}
}));
// output the cell geometry as GeoJSON
console.log(JSON.stringify({
type: "FeatureCollection",
features: [...cell_features]
}
, null, args.pretty ? 2 : 0))