/
Terrain50.mjs
457 lines (416 loc) · 15.7 KB
/
Terrain50.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
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
"use strict";
import nnng from 'nnng';
import l from './helpers/Log.mjs';
import { write_safe, end_safe } from './helpers/StreamHelpers.mjs';
import Terrain50ValidationMessage from './Terrain50ValidationMessage.mjs';
/**
* Represents a single Terrain50 tile..
* Note that this does NOT fill it with data - meta or otherwise. It simply
* creates the necessary data structures to hold such data.
* See Terrain50.Parse() (a static method) for parsing an serialised Terrain50 string.
*/
class Terrain50 {
constructor(was_blank = false) {
/**
* The Terrain50 metadata. Currently known possible values:
*
* Example source string | Meaning
* -------------------------|----------------------------------------
* `ncols 200` | The number of columns the data has
* `nrows 200` | The number of rows the data has
* `xllcorner 440000` | The x-coordinate on the OS National Grid of the left-hand-side
* `yllcorner 410000` | The y-coordinate on the OS National Grid of the bottom edge
* `cellsize 50` | The interval (in metres, we assume) between individual pixels in the file.
* `NODATA_value -9999` | If a pixel holds this value, there is no data available for that pixel.
*
* Note that some programs (* cough * HAIL-CAESAR * cough *) require these values to be in the *exact* order as above. `terrain50` is capable of parsing them regardless of their order or location in the file, but other programs aren't so forgiving.
*
* Note also that some programs also don't support the NODATA_value header meta item, so you may need to delete this (`delete my_instance.meta.NODATA_value`) before saving it for use in these programs.
*
* For more information, see https://en.wikipedia.org/wiki/Esri_grid
*
* @example <caption>Example object for the above table</caption>
* {
* ncols: 200,
* nrows: 200,
* xllcorner: 440000,
* yllcorner: 410000
* cellsize: 50,
* NODATA_value: -9999 // May or maynot be present, depending on the input file
* }
*/
this.meta = {};
/**
* The 2D array of numbers containing the data that this Terrain50
* instance contains.
*
* - Each top-level item in the array is a row (starting from the top).
* - Each array contains values from left-right.
* @example <caption>Referencing values</caption>
* let heightmap = Terrain50.Parse(some_string);
* let x = 3, y = 4;
* console.log(`Value at (${x}, ${y}): ${heightmap[y][x]}`);
* @type {Array}
*/
this.data = [];
/**
* Whether this tile was a brand-new2 blank one created with Terrain50.Blank() or not.
* @type {boolean}
*/
this.was_blank = was_blank;
/**
* The string to use to denote newlines when serialising.
* Note that this is NOT determined from the input file.
* @type {String}
*/
this.newline = "\n";
}
/**
* Finds and returns the max value in the data 2d array.
* Ignores NODATA values.
* @return {number} The maximum value.
*/
get max_value() {
return this.data.reduce((prev, row) => Math.max(
prev,
...row.filter((x) => x !== this.meta.NODATA_value)
), -Infinity);
}
/**
* Finds and returns the min value in the data 2d array.
* Ignores NODATA values.
* @return {number} The minimum value.
*/
get min_value() {
return this.data.reduce((prev, row) => Math.min(
prev,
...row.filter((x) => x !== this.meta.NODATA_value)
), Infinity);
}
/**
* Shifts the values in this Terrain50 instance such that the minimum value is a given value.
* @param {number} new_min The new minimum value.
* @return {number} The amount the values were shifted by
*/
shift(new_min) {
let shift_amount = new_min - this.min_value;
for(let row of this.data) {
for(let i = 0; i < row.length; i++) {
row[i] += shift_amount;
}
}
return shift_amount;
}
/**
* Scans this Terrain50 instance and replaces all instance of a given value with another.
* @param {number} old_value The value to search for.
* @param {number} new_value THe value to replace it with.
*/
replace(old_value, new_value) {
if(typeof old_value !== "number")
throw new Error("Error: old_value is not a number");
if(typeof new_value !== "number")
throw new Error("Error: new_value is not a number");
for(let row of this.data) {
for(let i = 0; i < row.length; i++) {
if(row[i] == old_value) {
row[i] = new_value;
}
}
}
}
/**
* Round all values in this Terrain50 instance to the nearest integer.
* Lowers precision (sometimes quite considerably), but some programs don't
* understand floating-point numbers.
*/
round() {
for(let row of this.data) {
for(let i = 0; i < row.length; i++) {
row[i] = Math.round(row[i]);
}
}
}
/**
* Trims this Terrain50 instance to match the given metadata object.
* Note that the reference meta object MUST have an identical cell size to this Terrain50 instance that is to be trimmed.
* @param {Object} meta The metadata from another Terrain50 instance to trim to match.
* @return {void}
*/
trim(meta) {
let us = { w: this.meta.ncols, h: this.meta.nrows },
them = { w: meta.ncols, h: meta.nrows };
let us_geo = { x: this.meta.xllcorner, y: this.meta.yllcorner },
them_geo = { x: meta.xllcorner, y: meta.yllcorner };
let diff = {
x: Math.floor((them_geo.x - us_geo.x) / this.meta.cellsize),
y: Math.floor((them_geo.y - us_geo.y) / this.meta.cellsize)
};
let trim = {
top: us.h - (diff.y + them.h),
right: us.w - (diff.x + them.w),
left: diff.x,
bottom: diff.y,
};
l.debug(`Our meta:`, this.meta);
l.debug(`Their meta:`, meta);
l.debug(`Diff:`, diff);
l.debug(`Trim amounts:`, trim);
// Trim the top and bottom
this.data.splice(0, trim.top);
this.data.splice(this.data.length - trim.bottom, trim.bottom);
// Trim the sides
for(let row of this.data) {
row.splice(0, trim.left);
row.splice(row.length - trim.right, trim.right);
}
this.meta.nrows = this.meta.nrows - (trim.top + trim.bottom);
this.meta.ncols = this.meta.ncols - (trim.left + trim.right);
this.meta.xllcorner = meta.xllcorner;
this.meta.yllcorner = meta.yllcorner;
l.debug(`Meta after trim:`, this.meta);
l.debug(`Actual size:`, this.data[0].length, this.data.length);
}
/**
* Analyses the frequency of the data values in this Terrain50 instance.
* Values are rounded down before being counted.
* @return {Map} A key → value map of data values → count that they occur.
*/
analyse_frequencies(ignore_nodata = false) {
let result = new Map();
for(let row of this.data) {
for(let value of row) {
// Optionally ignore NODATA values
if(ignore_nodata && value == this.meta.NODATA_value)
continue;
// Round it down and count it
let val_floor = Math.floor(value);
if(!result.has(val_floor))
result.set(val_floor, 0);
result.set(val_floor, result.get(val_floor) + 1);
}
}
return result;
}
/*
* ███████ ██████ █████ ██ ███████
* ██ ██ ██ ██ ██ ██
* ███████ ██ ███████ ██ █████
* ██ ██ ██ ██ ██ ██
* ███████ ██████ ██ ██ ███████ ███████
*/
/**
* Scales the *resolution* of this Terrain50 instance by a given scale factor.
* The bounding box will not change.
* @param {number} scale_factor The scale factor to apply.
*/
scale(scale_factor) {
// Scale the 2d data array
if(scale_factor < 1)
this.__scale_down(scale_factor);
else if(scale_factor > 1)
this.__scale_up(scale_factor);
else
return; // Nothing to do - scale factor 1 = keep it the same
// Handle the metadata
this.meta.cellsize *= 1 / scale_factor; // As we reduce the number of cells, the cells themselves get bigger
this.meta.nrows *= scale_factor;
this.meta.ncols *= scale_factor;
}
__scale_down(scale_factor) {
let numbers_per_group = 1 / scale_factor;
if(numbers_per_group != Math.floor(numbers_per_group))
throw new Error("Error: Can't scale down by scale factor that would result in a non-integer number of cells (e.g. a scale factor of 0.25 is ok as 4 hi-res cells = 1 low-res cell, but a value of 0.3 isn't because that would be 3.33333… hi-res cells for every low-res cell)");
let new_data = [];
/* This could be accelerated wwith GPU.js in theory - as could many of the operations here. It doesn't seem worth it for just a single operation though. */
for(let y = 0; y < this.data.length; y += numbers_per_group) {
// Temporary array to hold the values that need averaging
let values_row = [];
for(let x = 0; x < this.data[y].length; x += numbers_per_group) {
// Extract the values to average for this cell
let values_cell = [];
for(let y_scan = y; y_scan < y + numbers_per_group; y_scan++)
values_cell.push(...this.data[y_scan].slice(x, x + numbers_per_group));
values_row.push(values_cell);
}
// Average all the values in this row & then push them into the new data array
new_data.push(values_row
.map((values) =>
values.filter((x) => x !== this.meta.NODATA_value)
.reduce((t, x) => t + x, 0) / values.length
)
);
}
this.data = new_data;
}
/**
* Scale up handler - don't call this directly.
* You probably want the regular scale() method.
* @param {number} scale_factor The positive integer value to scale by.
* @private
*/
__scale_up(scale_factor) {
let new_data = [];
for(let y = 0; y < this.data.length; y++) {
let row = [];
for(let x = 0; x < this.data[y].length; x++) {
for(let i = 0; i < scale_factor; i++)
row.push(x);
}
for(let i = 0; i < scale_factor; i++)
new_data.push(row);
}
this.data = new_data;
}
/**
* Serialises this Terrain50 and writes it to a stream.
* Note that the stream is *not* closed automatically by - this must be done manually.
* v1.6 adds the new do_close argument, which for safely auto-closing the stream when done.
* @example
* import fs from 'fs';
* // ....
* let output = fs.createWriteStream("path/to/output.asc");
* await my_instance.serialise(output);
* output.end((callback) => { console.log("stream ended successfully")}); // Don't forget to end the stream - this method doesn't do this automatically
* @example Using promises
* import fs from 'fs';
* // ....
* let output = fs.createWriteStream("path/to/output.asc");
* my_instance.serialise(output).then(() => {
* output.end((callback) => { console.log("stream ended successfully")}); // Don't forget to end the stream - this method doesn't do this automatically
* })
* @example Autoclosing
* // terrain50 v1.6+
* // ....
* let output = fs.createWriteStream("path/to/output.asc");
* await my_instance.serialise(output, true);
* @param {stream.Writable} stream The writable stream to write it to as we serialise it.
* @param {boolean} do_close Whether to close the stream after writing (default: true).
*/
async serialise(stream, do_close = false) {
for(let key in this.meta) {
await write_safe(stream, `${key} ${this.meta[key]}${this.newline}`);
}
let seen_lengths = [];
for(let key in this.data) {
let row = this.data[key];
if(!seen_lengths.some((el) => el.value == row.length)) seen_lengths.push({count: 0, value: row.length});
seen_lengths.find((el) => el.value == row.length).count++;
await write_safe(stream, row.join(" "));
await write_safe(stream, this.newline);
}
l.info(`Seen lengths while serialising: (value → count) ${seen_lengths.map((el) => `${el.value} → ${el.count}`).join(", ")}`);
if(do_close) {
await end_safe(stream);
}
}
/*
* ██ ██ █████ ██ ██ ██████ █████ ████████ ███████
* ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██
* ██ ██ ███████ ██ ██ ██ ██ ███████ ██ █████
* ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██
* ████ ██ ██ ███████ ██ ██████ ██ ██ ██ ███████
*/
/**
* Validates this Terrain50 instance.
* If you want to validate a source string that you are having issues
* parsing, try the Terrain50.Validate() static method instead.
* @return {Terrain50ValidationMessage[]} An array of validation issues detected.
*/
validate() {
let errors = [];
Terrain50.RequiredMetaHeaderKeys.forEach((meta_key) => {
if(typeof this.meta[meta_key] === "undefined") {
errors.push(new Terrain50ValidationMessage("error", "TE004",
`Required metadata header ${meta_key} not found.`
));
}
});
for(let meta_key in this.meta) {
if(!Terrain50.ValidMetaHeaderKeys.includes(meta_key)) {
errors.push(new Terrain50ValidationMessage("warning", "TW001",
`Unknown meta header key ${meta_key}. Valid header keys: ${Terrain50.ValidMetaHeaderKeys.join(", ")}.`
));
}
}
if(typeof this.meta["nrows"] !== "undefined" && this.meta["nrows"] !== this.data.length) {
errors.push(new Terrain50ValidationMessage("error", "TE005",
`Unexpected row count: Expected ${meta["nrows"]} rows from metadata, but found ${this.data.length} in data.`
));
}
if(this.data.length === 0) {
errors.push(new Terrain50ValidationMessage("error", "TE007",
`No data rows detected.`
));
}
let row_counts = this.data.reduce((result, next_row) => {
if(!result.includes(next_row.length)) result.push(next_row.length);
return result;
}, []);
if(row_counts.length > 1) {
errors.push(new Terrain50ValidationMessage("error", "TE008",
`More than 1 data row element count detected (seen ${row_counts.join(", ")} - but expected only 1 row element count)`
));
}
return errors;
}
/**
* Convert this Terrain50 instance into a GeoJSON feature.
* Useful for debugging, as it can (almost) be pasted into http://geojson.io/ to quickly visualise it with minimal effort.
* @example
* // .....
* let geojson = {
* type: "FeatureCollection",
* features: []
* };
* for(let next in my_instances) {
* geojson.features.push(next.to_geojson_feature());
* }
* console.log(JSON.stringify(geojson));
* @return {object} This Terrain50 instance, as a GeoJSON feature.
*/
to_geojson_feature() {
let offset_x = this.meta.ncols * this.meta.cellsize,
offset_y = this.meta.nrows * this.meta.cellsize
return {
type: "Feature",
geometry: {
type: "Polygon",
coordinates: [
[
nnng.from(this.meta.xllcorner, this.meta.yllcorner).reverse(),
nnng.from(this.meta.xllcorner + offset_x, this.meta.yllcorner).reverse(),
nnng.from(this.meta.xllcorner + offset_x, this.meta.yllcorner + offset_y).reverse(),
nnng.from(this.meta.xllcorner, this.meta.yllcorner + offset_y).reverse(),
// The first & last points in a GeoJSON ploygon need to be identical
nnng.from(this.meta.xllcorner, this.meta.yllcorner).reverse()
]
]
},
properties: Object.assign({}, this.meta) // Shallow clone
}
}
}
/**
* An array of metadata valid header items.
* If a header item is *not* in this list, then it isn't necessarily invalid.
* It just means that we haven't encountered it before.
* @type {Array}
*/
Terrain50.ValidMetaHeaderKeys = [
"ncols", "nrows",
"xllcorner", "yllcorner",
"cellsize",
"NODATA_value"
];
/**
* AN array of required metadata header keys.
* SHould be a subset of Terrain50.ValidMetaHeaderKeys.
* @type {Array}
*/
Terrain50.RequiredMetaHeaderKeys = [
"ncols", "nrows",
"xllcorner", "yllcorner",
"cellsize"
];
export default Terrain50;