Source: dataset.js

/**
 * Reads a file and returns the file's contents.
 * @param file a {@link Blob}
 * @return {Promise<unknown>} the file's contents
 */
const readFile = (file) => {
    return new Promise((resolve, reject) => {
        const reader = new FileReader();
        reader.onerror = reject;
        reader.onload = () => resolve(reader.result);
        console.log(file);
        reader.readAsDataURL(file);
    });
}

/**
 * Creates an image from a given source.
 * @param src the source for the image
 * @return {Promise<Image>} the created image
 */
const loadImage = async (src) => {
    return new Promise((resolve, reject) => {
        const image = new Image();
        image.onerror = reject;
        image.onload = () => resolve(image);
        image.src = src;
    });
}

/**
 * Creates an image from an image source and scales it to the requested size.
 * @param imageSource a file path or a loaded image source (see {@link loadImage})
 * @param width the width to which the created image should be scaled to.
 * @return {Promise<{densityImage: ImageData, mapToLocation: ImageData}>}
 */
const createImageData = async (imageSource, width) => {
    const image = await loadImage(imageSource);

    // get image data in target resolution
    const ratio = width / image.width;
    const scaledHeight = image.height * ratio;
    const ctx = new OffscreenCanvas(width, scaledHeight).getContext('2d');
    ctx.drawImage(image, 0, 0, image.width, image.height, 0, 0, width, scaledHeight);

    return {
        densityImage: ctx.getImageData(0, 0, width, scaledHeight),
        mapToLocation: ctx.getImageData(0, 0, width, scaledHeight)
    };
}

/**
 * Creates a gradient image.
 * @param width the width of the image.
 * @param height the height of the image.
 * @param fromTo a flat array containing the coordinates of the start and end point of the gradient ([x0, y0, x1, y1]), defaults to: [0, 0, width, 0]
 * @return gradientImage a {@link CanvasImageData.getImageData}
 */
const createGradientImage = (width, height, fromTo = undefined) => {
    const bounds = [0, 0, width, height];
    if (!fromTo) {
        // just point from left to right by default
        fromTo = [...bounds];
        fromTo[3] = 0;
    }
    const context2D = new OffscreenCanvas(width, height).getContext('2d');

    const gradient = context2D.createLinearGradient(...fromTo);
    gradient.addColorStop(0, 'black');
    gradient.addColorStop(1, 'white');

    context2D.fillStyle = gradient;
    context2D.fillRect(...bounds);

    return context2D.getImageData(...bounds);
};

/**
 * Scales a value from an original range to a target range.
 * @param oMin the minimum value of the original range.
 * @param oMax the maximum value of the original range.
 * @param tMin the minimum value of the target range.
 * @param tMax the maximum value of the target range.
 * @param v the value to scale.
 * @returns number the value of v in the target range
 */
const scaleToRange = (oMin, oMax, tMin, tMax, v) => ((v - oMin) / (oMax - oMin)) * (tMax - tMin) + tMin;

/**
 * Finds the minimum of an array of numbers (use this instead of Math.min(...array) if your array is very large).
 * @param arr an array of numbers
 * @returns number the minimum value in arr
 */
const arrayMin = (arr) => arr.reduce((min, x) => min > x ? x : min, Infinity);

/**
 * Finds the maximum of an array of numbers (use this instead of Math.max(...array) if your array is very large).
 * @param arr an array of numbers
 * @returns number the maximum value in arr
 */
const arrayMax = (arr) => arr.reduce((max, x) => max > x ? max : x, -Infinity);

/**
 * Renders a dataset to a {@link CanvasImageData.getImageData} and collects the individual data points in a backing 2d
 * array that can be used to map locations in the rendered image to their corresponding data points.
 * @param data an array containing the data to render
 * @param width the width of the image to render
 * @param height the height of the image to render
 * @param xAttribute the name of the attribute holding the x-coordinate in a single item in the given data array
 * @param yAttribute the name of the attribute holding the y-coordinate in a single item in the given data array
 * @param mapLocation a function mapping the x and y coordinate of a single item in the given data array to its location in the rendered image.
 *                    Takes an array of two floats and returns an array of two floats, defaults to the identity function.
 *                    If mapLocation is falsy, all x and y coordinates are mapped to the bounds of the rendered image.
 * @param scaleByMaxDensity a boolean flag, if set the rendered image is scaled by the maximum density in the image resulting in a grayscale image. Otherwise the result is a binary image. If your data set is sparse you'll probably want the latter.
 * @returns {{densityImage: ImageData, locationToData: any[]}} an object containing the rendered image and the backing 2d array.
 */
const createImageFromData = (data, width, height, xAttribute, yAttribute, mapLocation = (xy) => xy, scaleByMaxDensity = false) => {
    const filteredData = data.filter(d => d[xAttribute] && d[yAttribute]).map(d => {
        d[xAttribute] = parseFloat(d[xAttribute]);
        d[yAttribute] = parseFloat(d[yAttribute]);
        return d;
    });
    if (!filteredData) {
        throw new Error(`Data has zero elements with both ${xAttribute} and ${yAttribute} set`);
    }
    if (!mapLocation) {
        const xCoords = filteredData.map(d => d[xAttribute]);
        const yCoords = filteredData.map(d => d[yAttribute]);
        const scaleX = scaleToRange.bind(null, arrayMin(xCoords), arrayMax(xCoords), 0, width - 1);
        const scaleY = scaleToRange.bind(null, arrayMin(yCoords), arrayMax(yCoords), 0, height - 1);
        mapLocation = (xy) => [scaleX(xy[0]), scaleY(xy[1])];
    }

    const density = new Array(width * height).fill(0.0);
    const locationToData = new Array(width * height);
    for (let i = 0; i < locationToData.length; i++) {
        locationToData[i] = [];
    }

    filteredData.forEach(d => {
        const location = mapLocation([d[xAttribute], d[yAttribute]]);
        if (location) {
            const x = Math.floor(location[0]);
            const y = Math.floor(location[1]);
            const i = (y * width) + x;
            if (locationToData[i]) {
                density[i] += 1.0;
                locationToData[i].push(d);
            } else {
                console.log(`Sample out of bounds: ${i} not in [0, ${locationToData.length})`);
            }
        }
    });
    const maxDensity = density.reduce((max, x) => max > x ? max : x, 0.0);

    return {
        densityImage: floatArrayToImageData(
            scaleByMaxDensity ? density.map(d => d / maxDensity) : density,
            width),
        locationToData
    };
}

/**
 * Creates a D3 projection.
 * @param width the width of the projection
 * @param height the height of the projection
 * @param projectionMethod the
 * @returns function a D3 projection.
 */
const createProjection = (width, height, projectionMethod = 'geoAlbersUsa') => {
    let defaultHeight = 1;
    switch (projectionMethod) {
        case 'geoAlbersUsa':
            defaultHeight = 500;
        default:
            return d3[projectionMethod]()
                .translate([width / 2, height / 2]) // translate to center
                .scale(d3[projectionMethod]().scale() * (height / defaultHeight));
    }
};

/**
 * Calculates the height for a given width and projection method.
 * @param width the width for which the height should be calculated
 * @param projectionMethod the projection method for which the height should be calculated
 * @return {number} the calculated height
 */
const getProjectionHeight = (width, projectionMethod = 'geoAlbersUsa') => {
    switch (projectionMethod) {
        case 'geoAlbersUsa':
            return 500 * (width / 960);
        default:
            throw new Error(`Unknown projection method: ${projectionMethod}`);
    }
};

/**
 * A specialization of {@link createImageFromData} for geographic projections.
 * @param data the data source
 * @param width the width of the resulting image
 * @param height the height of the resulting image. If this is not set, it is calculated using {@link getProjectionHeight}.
 * @param projectionMethod the projection method to use.
 * @param longitudeAttribute the name of the longitude attribute of items in the data array
 * @param latitudeAttribute the name of the latitude attribute of items in the data array.
 * @param scaleByMaxDensity a boolean flag, if set the rendered image is scaled by the maximum density in the image resulting in a grayscale image. Otherwise the result is a binary image. If your data set is sparse you'll probably want the latter.
 * @returns {{densityImage: ImageData, locationToData: any[]}} an object containing the rendered image and the backing 2d array.
 */
const createGeographicDataImage = (data, width, height = null, projectionMethod = 'geoAlbersUsa', longitudeAttribute = 'LON', latitudeAttribute = 'LAT', scaleByMaxDensity = false) => {
    height = height === null ? getProjectionHeight(width, projectionMethod) : height;
    const projection = createProjection(width, height, projectionMethod);
    return createImageFromData(data, width, height, longitudeAttribute, latitudeAttribute, projection, scaleByMaxDensity);
};