DataSet.js

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}`
// }