import * as util from './utils.js'
/**
* A DataSet is an object with width/height and an array
* of numbers of length = width * height.
*
* The array can be a TypedArray or a JavaScript Array.
*/
class DataSet {
width
height
data
// Return an empty dataset of given width, height, dataType
/**
* Factory method returning an empty dataset of given
* width, height, dataType
*
* @static
* @param {number} width The integer width of the array
* @param {number} height The integer height of the array
* @param {Object} Type Array (default) or one of the typed array types
* @returns {DataSet} The resulting DataSet with no values assigned
*/
static emptyDataSet(width, height, Type = Array) {
return new DataSet(width, height, new Type(width * height))
}
/**
* Creates an instance of DataSet.
* Checks data is right size, throws an error if not.
*
* @param {number} width The integer width of the array
* @param {number} height The integer height of the array
* @param {Array} data The array of numbers of length width * height
*/
constructor(width, height, data) {
if (data.length !== width * height) {
throw Error(
`new DataSet length: ${data.length} !== ${width} * ${height}`
)
}
Object.assign(this, { width, height, data })
}
// Checks x,y are within DataSet. Throw error if not.
checkXY(x, y) {
if (!this.inBounds(x, y)) {
throw Error(`DataSet: x,y out of range: ${x}, ${y}`)
}
}
// true if x,y in dataset bounds
inBounds(x, y) {
return (
util.isBetween(x, 0, this.width - 1) &&
util.isBetween(y, 0, this.height - 1)
)
}
dataType() {
return this.data.constructor
}
type() {
return this.constructor
}
// Given x,y in data space, return index into data
toIndex(x, y) {
return x + y * this.width
}
// Given index into data, return dataset [x, y] position
toXY(i) {
return [i % this.width, Math.floor(i / this.width)]
}
// Get dataset value at x,y, assuming that x,y valididated previously
// getXY (x, y) { return this.data[this.toIndex(Math.floor(x), Math.floor(y))] }
getXY(x, y) {
return this.data[this.toIndex(x, y)]
}
// Set the data value at x,y to num. assume x,y valid
// setXY (x, y, num) { this.data[this.toIndex(Math.floor(x), Math.floor(y))] = num }
setXY(x, y, num) {
this.data[this.toIndex(x, y)] = num
}
// Wrapper for sampling, defaults to "nearest". Checks x,y valid as well.
// Use this for individual sampling.
sample(x, y, useNearest = true) {
this.checkXY(x, y)
return useNearest ? this.nearest(x, y) : this.bilinear(x, y)
}
// Nearest neighbor sampling, w/o x,y validity check, i.e. our inner loops
nearest(x, y) {
return this.getXY(Math.round(x), Math.round(y))
}
// Billinear sampling w/o x,y validity check, i.e. our inner loops
bilinear(x, y) {
// Billinear sampling works by making two linear interpolations (lerps)
// in the x direction, and a third in the y direction, between the
// two x results. See wikipedia:
// [bilinear sampling](http://en.wikipedia.org/wiki/Bilinear_interpolation)
// The diagram shows the three lerps
// const [x0, y0] = [Math.floor(x), Math.floor(y)] // replaced by next line for speed
const x0 = Math.floor(x)
const y0 = Math.floor(y)
const i = this.toIndex(x0, y0)
const w = this.width
const dx = x - x0
const dy = y - y0
const dx1 = 1 - dx
const dy1 = 1 - dy
const f00 = this.data[i]
// Edge case: fij is 0 if beyond data array; undefined -> 0.
// This cancels the given component's factor in the result.
const f10 = this.data[i + 1] || 0 // 0 at bottom right corner
const f01 = this.data[i + w] || 0 // 0 at all bottom row
const f11 = this.data[i + 1 + w] || 0 // 0 at end of next to bottom row
// This is a bit involved but:
// ```
// If dx = 0; dx1 = 1, dy != 0
// -> vertical linear interpolation
// fxy = f00(1-dy) + f01(dy) i.e. y-lerp
//
// If dx != 0; dy = 0, dx !=0
// -> horizontal linear interpolation
// fxy = f00(1-dx) + f10(dx) i.e. x-lerp
// ```
return f00 * dx1 * dy1 + f10 * dx * dy1 + f01 * dx1 * dy + f11 * dx * dy
}
// Return a copy of this, with new data array
clone() {
return new DataSet(this.width, this.height, this.data.slice(0))
}
// Return new (empty) dataset, defaulting to this type
emptyDataSet(width, height, type = this.dataType()) {
return DataSet.emptyDataSet(width, height, type) // see static above
}
// Return new (empty) array of this type
emptyArray(length) {
const Type = this.type()
return new Type(length)
}
// Create new dataset of size width/height/type by resampling each point.
// Type is not this.type() due to integer/float differences. Default Array.
// If same size, return a copy of this.
// NOTE: This used to calc an x & y scale and apply it:
// const xScale = (this.width - 1) / (width - 1)
// const yScale = (this.height - 1) / (height - 1)
// ...
// ds.setXY(x, y, this.sample(x * xScale, y * yScale, useNearest))
// .. which had precision errors.
// Multiplying first, then dividing more accurate.
resample(width, height, useNearest = true, Type = Array) {
if (width === this.width && height === this.height) return this.clone()
const ds = DataSet.emptyDataSet(width, height, Type)
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
ds.setXY(
x,
y,
this.sample(
(x * (this.width - 1)) / (width - 1),
(y * (this.height - 1)) / (height - 1),
useNearest
)
)
}
}
return ds
}
// Scale each data element to be between min/max
// This is a linear scale from this dataset's min/max
// y = mx + b
// utils.objects.js:
// export function normalize(array, lo = 0, hi = 1) {
// const [min, max] = [arrayMin(array), arrayMax(array)]
// const scale = 1 / (max - min)
// return array.map(n => lerp(lo, hi, scale * (n - min)))
// }
scale(min, max) {
// const data = ds.data
const dsMin = this.min()
const dsMax = this.max()
const dsDelta = dsMax - dsMin
const delta = max - min
const m = delta / dsDelta
const b = min - m * dsMin
// const scaledData = data.map((x) => m * x + b)
// return new DataSet(ds.width, ds.height, scaledData)
return this.map(x => m * x + b)
}
// Return a rectangular subset of the dataset.
// Returned dataset is of same array type as this.
subset(x, y, width, height) {
if (x + width > this.width || y + height > this.height) {
console.log('subset: x+width', x + width, 'this.width', this.width)
console.log(
'subset: y+height',
y + height,
'this.height',
this.height
)
throw Error('DataSet.subSet: params out of range')
}
const ds = this.emptyDataSet(width, height)
for (let i = 0; i < width; i++) {
for (let j = 0; j < height; j++) {
ds.setXY(i, j, this.getXY(i + x, j + y))
}
}
return ds
}
// Crop this dataSet by removing top, bottom rows, left, right columns.
// You may pass in an obj with top, bottom, left, right key/val pairs
crop(top, bottom, left, right) {
if (bottom === undefined) {
// note var required, let/const have initialization error
var { top, bottom, left, right } = top
}
const width = this.width - left - right
const height = this.height - top - bottom
// console.log('crop top bottom left right', top, bottom, left, right)
// console.log('crop height', this.height, '->', height)
// console.log('crop width', this.width, '->', width)
// console.log('crop this width/height', this.width, this.height)
return this.subset(left, top, width, height)
}
// Return maped dataset by applying f to each dataset element
map(f) {
return new DataSet(this.width, this.height, this.data.map(f))
}
// Return the column of data at position x as this array's type
col(x) {
const [w, h, data] = [this.width, this.height, this.data]
if (x >= w) throw Error(`col: x out of range width: ${w} x: ${x}`)
const colData = this.emptyArray(h)
for (let i = 0; i < h; i++) colData[i] = data[x + i * w]
return colData
}
// Return the row of data at position y as this array's type
row(y) {
const [w, h] = [this.width, this.height]
if (y >= h) throw Error(`row: y out of range height: ${h} x: ${y}`)
return this.data.slice(y * w, (y + 1) * w)
}
// Convert this dataset's data to new type. Precision may be lost.
// Does nothing if current data is already of this Type.
convertType(type) {
this.data = util.convertArrayType(this.data, type)
}
// Concatinate a dataset of equal height to my right to my east.
// New DataSet is of same type as this.
//
// NOTE: concatWest is dataset.concatEast(this)
concatEast(ds) {
const [w, h] = [this.width, this.height]
const [w1, h1] = [ds.width, ds.height]
if (h !== h1) throw Error(`concatEast: heights not equal ${h}, ${h1}`)
const ds1 = this.emptyDataSet(w + w1, h)
// copy this into new dataset
for (let x = 0; x < w; x++) {
for (let y = 0; y < h; y++) {
ds1.setXY(x, y, this.getXY(x, y))
}
}
// copy ds to the left side
for (let x = 0; x < w1; x++) {
for (let y = 0; y < h1; y++) {
ds1.setXY(x + w, y, ds.getXY(x, y))
}
}
return ds1
}
// Concatinate a dataset of equal width to my south, returning new DataSet.
// New DataSet is of same type as this.
//
// NOTE: concatNorth is dataset.concatSouth(this)
concatSouth(dataset) {
const [w, h, data] = [this.width, this.height, this.data]
if (w !== dataset.width) {
throw Error(`concatSouth: widths not equal ${w}, ${dataset.width}`)
}
const data1 = util.concatArrays(data, dataset.data)
return new DataSet(w, h + dataset.height, data1)
}
// return dataset x,y given x,y in a euclidean space defined by tlx, tly, w, h
// x,y is in topleft-bottomright box: [tlx,tly,tlx+w,tly-h], y positive util.
// Ex: NetLogo's coords: x, y, minXcor, maxYcor, numX, numY
transformCoords(x, y, tlx, tly, w, h) {
const xs = ((x - tlx) * (this.width - 1)) / w
const ys = ((tly - y) * (this.height - 1)) / h
return [xs, ys]
}
// get a sample using a transformed euclidean coord system; see above
coordSample(x, y, tlx, tly, w, h, useNearest = true) {
const [xs, ys] = this.transformCoords(x, y, tlx, tly, w, h)
return this.sample(xs, ys, useNearest)
}
// Return Array 3x3 neighbor values of the given x,y of the dataset.
// Off-edge neighbors revert to nearest edge value.
neighborhood(x, y, array = []) {
array.length = 0 // in case user supplied an array to reduce GC
const clampNeeded =
x === 0 || x === this.width - 1 || y === 0 || y === this.height - 1
for (let dy = -1; dy <= +1; dy++) {
for (let dx = -1; dx <= +1; dx++) {
let x0 = x + dx
let y0 = y + dy
if (clampNeeded) {
x0 = util.clamp(x0, 0, this.width - 1)
y0 = util.clamp(y0, 0, this.height - 1)
}
array.push(this.data[this.toIndex(x0, y0)])
}
}
return array
}
// Return a new dataset of this array type convolved with the
// given kernel 3x3 matrix.
// See [Convolution](https://en.wikipedia.org/wiki/Kernel_(image_processing))
//
// If cropped, do not convolve the edges, returning a smaller dataset.
// If not, convolve the edges by extending edge values, returning
// dataset of same size.
convolve(kernel, factor = 1, crop = false) {
const [x0, y0, h, w] = crop // optimization not needed, only called once
? [1, 1, this.height - 1, this.width - 1]
: [0, 0, this.height, this.width]
const newDS = this.emptyDataSet(w, h)
const newData = newDS.data
let i = 0
for (let y = y0; y < h; y++) {
for (let x = x0; x < w; x++) {
const nei = this.neighborhood(x, y)
// remind: use reduce if performant
let sum2 = 0
for (let i2 = 0; i2 < kernel.length; i2++) {
// sum2 += kernel[i2] * nei[i2] // Chrome can't optimize compound let
sum2 = sum2 + kernel[i2] * nei[i2]
}
newData[i++] = sum2 * factor // newDS.data[newDS.toIndex(x, y)] = sum2 * factor
}
}
return newDS
}
// A few common convolutions. dzdx/y are also called horiz/vert Sobel
dzdx(n = 2, factor = 1 / 8) {
return this.convolve([-1, 0, 1, -n, 0, n, -1, 0, 1], factor)
}
dzdy(n = 2, factor = 1 / 8) {
return this.convolve([1, n, 1, 0, 0, 0, -1, -n, -1], factor)
}
laplace8() {
return this.convolve([-1, -1, -1, -1, 8, -1, -1, -1, -1])
}
laplace4() {
return this.convolve([0, -1, 0, -1, 4, -1, 0, -1, 0])
}
blur(factor = 0.0625) {
// 1/16 = 0.0625
return this.convolve([1, 2, 1, 2, 4, 2, 1, 2, 1], factor)
}
edge() {
return this.convolve([1, 1, 1, 1, -7, 1, 1, 1, 1])
}
// Create two new Array convolved datasets, slope and aspect, common in
// the use of an elevation data set. See Esri tutorials for
// [slope](http://goo.gl/ZcOl08) and [aspect](http://goo.gl/KoI4y5)
//
// It also returns the two derivitive DataSets, dzdx, dzdy for
// those wanting to use the results of the two convolutions.
//
// Use this.convertType to convert to typed array
slopeAndAspect(cellSize = 1, posAngle = true) {
const dzdx = this.dzdx() // sub left z from right
const dzdy = this.dzdy() // sub bottom z from top
let [aspect, slope] = [[], []]
const [h, w] = [dzdx.height, dzdx.width]
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const [gx, gy] = [dzdx.getXY(x, y), dzdy.getXY(x, y)]
// slope.push(Math.atan(util.distance(gx, gy)) / cellSize) // radians
slope.push(Math.atan(util.distance(0, 0, gx, gy)) / cellSize)
// if (noNaNs)
// while (gx === gy) {
// gx += util.randomNormal(0, 0.0001)
// gy += util.randomNormal(0, 0.0001)
// }
// radians in [-PI,PI], downhill
// let rad = (gx === gy && gy === 0) ? NaN : Math.atan2(-gy, -gx)
let rad = Math.atan2(-gy, -gx)
// positive radians in [0,2PI] if desired
if (posAngle && rad < 0) rad += 2 * Math.PI
aspect.push(rad)
}
}
slope = new DataSet(w, h, slope)
aspect = new DataSet(w, h, aspect)
return { slope, aspect, dzdx, dzdy }
}
// Return max/min/extent/sum of data
max() {
// return util.arrayMax(this.data)
return this.data.reduce((a, b) => Math.max(a, b))
}
min() {
// return util.arrayMin(this.data)
return this.data.reduce((a, b) => Math.min(a, b))
}
extent() {
return [this.min(), this.max()]
}
sum() {
return this.data.reduce((a, b) => a + b)
// return this.data.reduce((a, b) => a + b, 0)
}
// Return new dataset scaled between lo, hi values
normalize(lo = 0, hi = 1, round = false) {
const [min, max] = this.extent()
const scale = 1 / (max - min)
let data = this.data.map(n => util.lerp(lo, hi, scale * (n - min)))
if (round) data = data.map(n => Math.round(n))
return new DataSet(this.width, this.height, data)
}
// Test that this has same width, height, data as dataset.
// Note: does not require equal array type (Array or TypedArray)
equals(dataset) {
return (
this.width === dataset.width &&
this.height === dataset.height &&
util.arraysEqual(this.data, dataset.data)
)
}
}
export default DataSet
// REMIND: limit to data that can be 24 bit. Error otherwise.
// DataType of Int8, 16, Int24 OK, others need testing.
// Possibly use precision to minimize byte size to 3, rgb?
//
// Convert dataset to an image context object.
//
// This can be used to "visualize" the data by normalizing
// which will scale the data to use the entire RGB space.
// It can also be used to create tiles or image-as-data if
// the defaults are used.
//
// Due to
// [alpha-premultiply](https://en.wikipedia.org/wiki/Alpha_compositing),
// the best we can do as data is 24 bit ints.
// You can simulate floats/fixed by multiplying the dataset
// the dividing on conversion back.
//
// Our preferred transport is in the works, likely in the
// tile datasets via blobs or arraybuffers. Sigh.
// toContext (normalize = false, gray = false, alpha = 255) {
// const [w, h, data] = [this.width, this.height, this.data]
// let idata
// if (normalize) {
// idata = gray
// ? util.normalize8(data) : util.normalizeInt(data, 0, Math.pow(2, 24) - 1)
// } else {
// idata = data.map((a) => Math.round(a))
// }
// const ctx = util.createCtx(w, h)
// const id = ctx.getImageData(0, 0, w, h)
// const ta = id.data // ta short for typed array
// for (let i = 0; i < idata.length; i++) {
// const [num, j] = [idata[i], 4 * i] // j = byte index into ta
// if (gray) {
// ta[j] = ta[j + 1] = ta[j + 2] = Math.floor(num); ta[j + 3] = alpha
// } else {
// ta[j] = (num >> 16) & 0xff
// ta[j + 1] = (num >> 8) & 0xff
// ta[j + 2] = num & 0xff
// ta[j + 3] = alpha // if not 255, image will be premultiplied.
// }
// }
// ctx.putImageData(id, 0, 0)
// return ctx
// }
//
// // Convert dataset to a canvas, which can be used as an image
// toCanvas (normalize = false, gray = false, alpha = 255) {
// return this.toContext(gray, normalize, alpha).canvas
// }
// // Convert dataset to a base64 string
// toDataUrl (normalize = false, gray = false, alpha = 255) {
// return util.ctxToDataUrl(this.toContext(gray, normalize, alpha))
// }
// // Get/Set name, useful for storage key.
// setName(string) {
// this.name = string
// return this
// }
// getName() {
// return this.name ? this.name : this.makeName()
// }
// makeName() {
// const { width, height } = this
// const sum = this.sum().toFixed(2)
// return `${this.dataType().name}-${width}-${height}-${sum}`
// }